diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index cd41b0e2e48..2f4d30d6022 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -117,7 +117,7 @@ "files": [ { "path": "./dist/clerk.browser.js", - "maxSize": "148kB" + "maxSize": "152kB" }, { "path": "./dist/clerk.headless.js", diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index fe1bf5a0ddb..2fc1fd75388 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -35,7 +35,12 @@ export type ComponentControls = { props?: AvailableComponentProps; }) => void; unmountComponent: (params: { node: HTMLDivElement }) => void; - updateProps: (params: { appearance?: Appearance | undefined; node?: HTMLDivElement; props?: unknown }) => void; + updateProps: (params: { + appearance?: Appearance | undefined; + options?: ClerkOptions | undefined; + node?: HTMLDivElement; + props?: unknown; + }) => void; openModal: ( modal: T, props: T extends 'signIn' ? SignInProps : T extends 'signUp' ? SignUpProps : UserProfileProps, @@ -67,6 +72,7 @@ interface ComponentsProps { interface ComponentsState { appearance: Appearance | undefined; + options: ClerkOptions | undefined; signInModal: null | SignInProps; signUpModal: null | SignUpProps; userProfileModal: null | UserProfileProps; @@ -109,6 +115,7 @@ const componentsControls = {} as ComponentControls; const Components = (props: ComponentsProps) => { const [state, setState] = React.useState({ appearance: props.options.appearance, + options: props.options, signInModal: null, signUpModal: null, userProfileModal: null, @@ -134,7 +141,7 @@ const Components = (props: ComponentsProps) => { }); }; - componentsControls.updateProps = ({ appearance, node, props }) => { + componentsControls.updateProps = ({ node, props, ...restProps }) => { if (node && props && typeof props === 'object') { const nodeOptions = state.nodes.get(node); if (nodeOptions) { @@ -143,7 +150,7 @@ const Components = (props: ComponentsProps) => { return; } } - setState(s => ({ ...s, appearance })); + setState(s => ({ ...s, ...restProps })); }; componentsControls.closeModal = name => { @@ -245,7 +252,7 @@ const Components = (props: ComponentsProps) => { return ( - + {[...nodes].map(([node, component]) => { return ( {card.error} {onBackLinkClick && } - Use another method + {/*TODO: extract main in its own component */} {validFactors.map((factor, i) => ( onFactorSelected(factor)} - > - {getButtonLabel(factor)} - + /> ))} - Get help + @@ -86,16 +88,22 @@ const AlternativeMethodsList = (props: AlternativeMethodsProps & { onHavingTroub ); }; -export function getButtonLabel(factor: SignInFactor): string { +export function getButtonLabel(factor: SignInFactor): LocalizationKey { switch (factor.strategy) { case 'email_link': - return `Send link to ${formatSafeIdentifier(factor.safeIdentifier)}`; + return localizationKeys('signIn.alternativeMethods.blockButton__emailLink', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); case 'email_code': - return `Send code to ${formatSafeIdentifier(factor.safeIdentifier)}`; + return localizationKeys('signIn.alternativeMethods.blockButton__emailCode', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); case 'phone_code': - return `Send code to ${formatSafeIdentifier(factor.safeIdentifier)}`; + return localizationKeys('signIn.alternativeMethods.blockButton__phoneCode', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); case 'password': - return 'Sign in with your password'; + return localizationKeys('signIn.alternativeMethods.blockButton__password'); default: throw `Invalid sign in strategy: "${factor.strategy}"`; } diff --git a/packages/clerk-js/src/ui/SignIn/HavingTrouble.tsx b/packages/clerk-js/src/ui/SignIn/HavingTrouble.tsx index f30604f9409..251e0339013 100644 --- a/packages/clerk-js/src/ui/SignIn/HavingTrouble.tsx +++ b/packages/clerk-js/src/ui/SignIn/HavingTrouble.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { localizationKeys } from '../customizables'; import { ErrorCard } from '../elements'; import { PropsOfComponent } from '../styledSystem'; @@ -8,7 +9,7 @@ export const HavingTrouble = (props: PropsOfComponent) => { return ( ); diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorOneCodeForm.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorOneCodeForm.tsx index ac00dbaa2fe..a9b1120e680 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorOneCodeForm.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCoreClerk, useCoreSignIn, useSignInContext } from '../../ui/contexts'; import { useSupportEmail } from '../../ui/hooks/useSupportEmail'; import { useRouter } from '../../ui/router'; +import { LocalizationKey } from '../customizables'; import { VerificationCodeCard, VerificationCodeCardProps } from '../elements'; import { useCardState } from '../elements/contexts'; import { handleError } from '../utils'; @@ -15,10 +16,11 @@ export type SignInFactorOneCodeCard = Pick { @@ -71,6 +73,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => cardSubtitle={props.cardSubtitle} formTitle={props.formTitle} formSubtitle={props.formSubtitle} + resendButton={props.resendButton} onCodeEntryFinishedAction={action} onResendCodeClicked={prepare} safeIdentifier={props.factor.safeIdentifier} diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailCodeCard.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailCodeCard.tsx index 2f4c4648a15..8f6c4171827 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailCodeCard.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailCodeCard.tsx @@ -2,7 +2,7 @@ import { EmailCodeFactor } from '@clerk/types'; import React from 'react'; import { useEnvironment } from '../../ui/contexts'; -import { Flow } from '../customizables'; +import { Flow, localizationKeys } from '../customizables'; import { SignInFactorOneCodeCard, SignInFactorOneCodeForm } from './SignInFactorOneCodeForm'; type SignInFactorOneEmailCodeCardProps = SignInFactorOneCodeCard & { factor: EmailCodeFactor }; @@ -14,10 +14,11 @@ export const SignInFactorOneEmailCodeCard = (props: SignInFactorOneEmailCodeCard ); diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailLinkCard.tsx index 0bd48b5bbdc..d2e47b10833 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -6,7 +6,7 @@ import { useCoreClerk, useCoreSignIn, useEnvironment, useSignInContext } from '. import { useMagicLink } from '../../ui/hooks/useMagicLink'; import { useRouter } from '../../ui/router/RouteContext'; import { EmailLinkStatusCard } from '../common'; -import { Flow } from '../customizables'; +import { Flow, localizationKeys } from '../customizables'; import { VerificationCodeCardProps, VerificationLinkCard } from '../elements'; import { useCardState } from '../elements/contexts'; import { handleError } from '../utils'; @@ -83,10 +83,11 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard return ( { @@ -51,8 +54,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) {card.error} - Enter your password - to continue to {displayConfig.applicationName} + + - Continue + - Use another method + diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorOnePhoneCodeCard.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorOnePhoneCodeCard.tsx index 1a8984e51e2..20d3fb559f8 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorOnePhoneCodeCard.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorOnePhoneCodeCard.tsx @@ -1,8 +1,8 @@ import { PhoneCodeFactor } from '@clerk/types'; import React from 'react'; -import { useEnvironment } from '../../ui/contexts'; -import { Flow } from '../customizables'; +import { useEnvironment } from '../contexts'; +import { Flow, localizationKeys } from '../customizables'; import { SignInFactorOneCodeCard, SignInFactorOneCodeForm } from './SignInFactorOneCodeForm'; type SignInFactorOnePhoneCodeCardProps = SignInFactorOneCodeCard & { factor: PhoneCodeFactor }; @@ -14,10 +14,11 @@ export const SignInFactorOnePhoneCodeCard = (props: SignInFactorOnePhoneCodeCard ); diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorTwoCodeForm.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorTwoCodeForm.tsx index 61dc44fdb53..5e155e613b9 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorTwoCodeForm.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useCoreClerk, useCoreSignIn, useSignInContext } from '../../ui/contexts'; import { useSupportEmail } from '../../ui/hooks/useSupportEmail'; +import { LocalizationKey } from '../customizables'; import { useCardState, VerificationCodeCard, VerificationCodeCardProps } from '../elements'; import { handleError } from '../utils'; @@ -14,10 +15,11 @@ export type SignInFactorTwoCodeCard = Pick { @@ -67,6 +69,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => cardSubtitle={props.cardSubtitle} formTitle={props.formTitle} formSubtitle={props.formSubtitle} + resendButton={props.resendButton} onCodeEntryFinishedAction={action} onResendCodeClicked={prepare} safeIdentifier={'safeIdentifier' in props.factor ? props.factor.safeIdentifier : undefined} diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorTwoPhoneCodeCard.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorTwoPhoneCodeCard.tsx index 6d4b49cd669..42c121f8e3b 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorTwoPhoneCodeCard.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorTwoPhoneCodeCard.tsx @@ -2,7 +2,7 @@ import { PhoneCodeFactor } from '@clerk/types'; import React from 'react'; import { useCoreSignIn } from '../../ui/contexts'; -import { Flow } from '../customizables'; +import { Flow, localizationKeys } from '../customizables'; import { SignInFactorTwoCodeCard, SignInFactorTwoCodeForm } from './SignInFactorTwoCodeForm'; type SignInFactorTwoPhoneCodeCardProps = SignInFactorTwoCodeCard & { factor: PhoneCodeFactor }; @@ -21,10 +21,11 @@ export const SignInFactorTwoPhoneCodeCard = (props: SignInFactorTwoPhoneCodeCard diff --git a/packages/clerk-js/src/ui/SignIn/SignInFactorTwoTOTPCard.tsx b/packages/clerk-js/src/ui/SignIn/SignInFactorTwoTOTPCard.tsx index 8a22f53e157..4b32523ba6e 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInFactorTwoTOTPCard.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInFactorTwoTOTPCard.tsx @@ -1,7 +1,7 @@ import { TOTPFactor } from '@clerk/types'; import React from 'react'; -import { Flow } from '../customizables'; +import { Flow, localizationKeys } from '../customizables'; import { SignInFactorTwoCodeCard, SignInFactorTwoCodeForm } from './SignInFactorTwoCodeForm'; type SignInFactorTwoTOTPCardProps = SignInFactorTwoCodeCard & { factor: TOTPFactor }; @@ -11,10 +11,11 @@ export const SignInFactorTwoTOTPCard = (props: SignInFactorTwoTOTPCardProps) => ); diff --git a/packages/clerk-js/src/ui/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/SignIn/SignInStart.tsx index f032ab3ad95..a2d37e8a963 100644 --- a/packages/clerk-js/src/ui/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/SignIn/SignInStart.tsx @@ -7,7 +7,7 @@ import { useCoreClerk, useCoreSignIn, useEnvironment, useSignInContext } from '. import { useNavigate } from '../../ui/hooks'; import { useSupportEmail } from '../../ui/hooks/useSupportEmail'; import { getClerkQueryParam } from '../../utils/getClerkQueryParam'; -import { Col, descriptors, Flow } from '../customizables'; +import { Col, descriptors, Flow, localizationKeys } from '../customizables'; import { Card, CardAlert, @@ -44,11 +44,12 @@ export function _SignInStart(): JSX.Element { const instantPasswordField = useFormControl('password', '', { type: 'password', - label: 'Password', + label: localizationKeys('formFieldLabel__password'), + placeholder: localizationKeys('formFieldInputPlaceholder__password') as any, }); + const identifierField = useFormControl('identifier', '', { - type: identifierInputDisplayValues.fieldType, - label: identifierInputDisplayValues.label, + ...identifierInputDisplayValues, isRequired: true, }); @@ -56,6 +57,7 @@ export function _SignInStart(): JSX.Element { if (!organizationTicket) { return; } + status.setLoading(); card.setLoading(); signIn @@ -184,8 +186,12 @@ export function _SignInStart(): JSX.Element { {card.error} - Sign in - to continue to {displayConfig.applicationName} + + {/*TODO: extract main in its own component */} - No account? - Sign up + + No account? + + + Sign up + diff --git a/packages/clerk-js/src/ui/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/SignUp/SignUpContinue.tsx index 0cf5c67673e..bb944724853 100644 --- a/packages/clerk-js/src/ui/SignUp/SignUpContinue.tsx +++ b/packages/clerk-js/src/ui/SignUp/SignUpContinue.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { withRedirectToHome } from '../../ui/common/withRedirectToHome'; import { useCoreClerk, useCoreSignUp, useEnvironment, useSignUpContext } from '../../ui/contexts'; import { useNavigate } from '../../ui/hooks'; -import { descriptors, Flex, Flow } from '../customizables'; +import { descriptors, Flex, Flow, localizationKeys } from '../customizables'; import { Card, CardAlert, @@ -47,13 +47,38 @@ function _SignUpContinue() { return ; } + // TODO: This form should be shared between SignUpStart and SignUpContinue const formState = { - firstName: useFormControl('first_name', '', { type: 'text', label: 'First name' }), - lastName: useFormControl('last_name', '', { type: 'text', label: 'Last name' }), - emailAddress: useFormControl('email_address', '', { type: 'email', label: 'Email address' }), - username: useFormControl('username', '', { type: 'text', label: 'Username' }), - phoneNumber: useFormControl('phone_number', '', { type: 'tel', label: 'Phone number' }), - password: useFormControl('password', '', { type: 'password', label: 'Password' }), + firstName: useFormControl('firstName', '', { + type: 'text', + label: localizationKeys('formFieldLabel__firstName'), + placeholder: localizationKeys('formFieldInputPlaceholder__firstName'), + }), + lastName: useFormControl('lastName', '', { + type: 'text', + label: localizationKeys('formFieldLabel__lastName'), + placeholder: localizationKeys('formFieldInputPlaceholder__lastName'), + }), + emailAddress: useFormControl('emailAddress', '', { + type: 'email', + label: localizationKeys('formFieldLabel__emailAddress'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), + }), + username: useFormControl('username', '', { + type: 'text', + label: localizationKeys('formFieldLabel__username'), + placeholder: localizationKeys('formFieldInputPlaceholder__username'), + }), + phoneNumber: useFormControl('phoneNumber', '', { + type: 'tel', + label: localizationKeys('formFieldLabel__phoneNumber'), + placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), + }), + password: useFormControl('password', '', { + type: 'password', + label: localizationKeys('formFieldLabel__password'), + placeholder: localizationKeys('formFieldInputPlaceholder__password'), + }), } as const; const hasEmail = !!formState.emailAddress.value; @@ -124,9 +149,8 @@ function _SignUpContinue() { {card.error} - {/* TODO: copy? */} - Fill in missing fields - to continue to {displayConfig.applicationName} + + - Have an account? - Sign in + + diff --git a/packages/clerk-js/src/ui/SignUp/SignUpEmailCodeCard.tsx b/packages/clerk-js/src/ui/SignUp/SignUpEmailCodeCard.tsx index a4012859d84..f55b6c3d0f9 100644 --- a/packages/clerk-js/src/ui/SignUp/SignUpEmailCodeCard.tsx +++ b/packages/clerk-js/src/ui/SignUp/SignUpEmailCodeCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useCoreSignUp, useEnvironment } from '../../ui/contexts'; -import { Flow } from '../customizables'; +import { Flow, localizationKeys } from '../customizables'; import { SignUpVerificationCodeForm } from './SignUpVerificationCodeForm'; export const SignUpEmailCodeCard = () => { @@ -27,10 +27,11 @@ export const SignUpEmailCodeCard = () => { return ( { return ( diff --git a/packages/clerk-js/src/ui/SignUp/SignUpPhoneCodeCard.tsx b/packages/clerk-js/src/ui/SignUp/SignUpPhoneCodeCard.tsx index f9e2906e79e..50384908965 100644 --- a/packages/clerk-js/src/ui/SignUp/SignUpPhoneCodeCard.tsx +++ b/packages/clerk-js/src/ui/SignUp/SignUpPhoneCodeCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useCoreSignUp, useEnvironment } from '../../ui/contexts'; -import { Flow } from '../customizables'; +import { Flow, localizationKeys } from '../customizables'; import { withCardStateProvider } from '../elements'; import { SignUpVerificationCodeForm } from './SignUpVerificationCodeForm'; @@ -28,10 +28,11 @@ export const SignUpPhoneCodeCard = withCardStateProvider(() => { return ( {card.error} - Create your account - to continue to {displayConfig.applicationName} + Create your account + + to continue to {displayConfig.applicationName} + - Have an account? - Sign in + + Have an account? + + + Sign in + diff --git a/packages/clerk-js/src/ui/SignUp/SignUpVerificationCodeForm.tsx b/packages/clerk-js/src/ui/SignUp/SignUpVerificationCodeForm.tsx index 65b2b05f974..18d524e830b 100644 --- a/packages/clerk-js/src/ui/SignUp/SignUpVerificationCodeForm.tsx +++ b/packages/clerk-js/src/ui/SignUp/SignUpVerificationCodeForm.tsx @@ -3,14 +3,16 @@ import React from 'react'; import { useCoreClerk, useSignUpContext } from '../../ui/contexts'; import { useNavigate } from '../../ui/hooks/useNavigate'; +import { LocalizationKey } from '../customizables'; import { VerificationCodeCard, VerificationCodeCardProps } from '../elements'; import { completeSignUpFlow } from './util'; type SignInFactorOneCodeFormProps = { - cardTitle: string; - cardSubtitle: string; - formTitle: string; - formSubtitle: string; + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + resendButton: LocalizationKey; prepare: () => Promise | undefined; attempt: (code: string) => Promise; safeIdentifier: string | undefined | null; @@ -50,6 +52,7 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) cardSubtitle={props.cardSubtitle} formTitle={props.formTitle} formSubtitle={props.formSubtitle} + resendButton={props.resendButton} onCodeEntryFinishedAction={action} onResendCodeClicked={props.prepare} safeIdentifier={props.safeIdentifier || ''} diff --git a/packages/clerk-js/src/ui/UserButton/CurrentAccountActions.tsx b/packages/clerk-js/src/ui/UserButton/CurrentAccountActions.tsx index 10528819635..2f2306b2567 100644 --- a/packages/clerk-js/src/ui/UserButton/CurrentAccountActions.tsx +++ b/packages/clerk-js/src/ui/UserButton/CurrentAccountActions.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, Col, Flex, Icon, Spinner, Text } from '../customizables'; +import { Button, Col, Flex, Icon, LocalizationKey, Spinner, Text } from '../customizables'; import { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; import { useCardState } from '../elements/contexts'; import { useLoadingStatus } from '../hooks'; @@ -10,9 +10,9 @@ export const Actions = (props: PropsOfComponent) => { return ; }; -type ActionProps = PropsOfComponent & { +type ActionProps = Omit, 'label'> & { icon: React.ComponentType; - label: string; + label: LocalizationKey; iconBoxElementDescriptor?: ElementDescriptor; iconBoxElementId?: ElementId; iconElementDescriptor?: ElementDescriptor; @@ -88,14 +88,13 @@ export const Action = (props: ActionProps) => { )} - {label} - + /> ); }; diff --git a/packages/clerk-js/src/ui/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/UserButton/UserButtonPopover.tsx index e5cd1052277..d0d9290b06d 100644 --- a/packages/clerk-js/src/ui/UserButton/UserButtonPopover.tsx +++ b/packages/clerk-js/src/ui/UserButton/UserButtonPopover.tsx @@ -2,7 +2,7 @@ import { ActiveSessionResource } from '@clerk/types'; import React from 'react'; import { useCoreSession, useCoreUser, useEnvironment, useUserButtonContext } from '../contexts'; -import { descriptors, Flex, Flow, Link, useAppearance } from '../customizables'; +import { descriptors, Flex, Flow, Link, localizationKeys, useAppearance } from '../customizables'; import { BaseCard, PoweredByClerkText, UserPreview } from '../elements'; import { RootBox } from '../elements/RootBox'; import { CogFilled, Plus, SignOut, SignOutDouble } from '../icons'; @@ -34,7 +34,7 @@ export const UserButtonPopover = React.forwardRef ); @@ -54,7 +54,7 @@ export const UserButtonPopover = React.forwardRef @@ -86,7 +86,7 @@ export const UserButtonPopover = React.forwardRef diff --git a/packages/clerk-js/src/ui/UserProfile/ActiveDevicesSection.tsx b/packages/clerk-js/src/ui/UserProfile/ActiveDevicesSection.tsx index a95a8f9058f..375dcacff98 100644 --- a/packages/clerk-js/src/ui/UserProfile/ActiveDevicesSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/ActiveDevicesSection.tsx @@ -3,7 +3,7 @@ import { SessionWithActivitiesResource } from '@clerk/types'; import React from 'react'; import { useCoreSession, useCoreUser } from '../contexts'; -import { Badge, Col, descriptors, Flex, Icon, Spinner, Text } from '../customizables'; +import { Badge, Col, descriptors, Flex, Icon, localizationKeys, Spinner, Text } from '../customizables'; import { DeviceLaptop, DeviceMobile } from '../icons'; import { mqu } from '../styledSystem'; import { LinkButtonWithDescription } from './LinkButtonWithDescription'; @@ -22,7 +22,7 @@ export const ActiveDevicesSection = () => { return ( {!sessionsWithActivities.length && ( @@ -56,15 +56,15 @@ const DeviceAccordion = (props: { session: SessionWithActivitiesResource }) => { {isCurrent && ( )} {!isCurrent && ( @@ -124,7 +124,7 @@ const DeviceInfo = (props: { session: SessionWithActivitiesResource }) => { gap={2} > {title} - {isCurrent && This device} + {isCurrent && } & { href?: string }) => { @@ -42,6 +42,7 @@ const PAGE_TO_ROOT = { export const Breadcrumbs = (props: BreadcrumbsProps) => { const router = useRouter(); const { navigateToFlowStart } = useNavigateToFlowStart(); + const { t } = useLocalizations(); const currentPage = (router.currentPath || '').split('/').pop(); const { title } = props; @@ -87,7 +88,7 @@ export const Breadcrumbs = (props: BreadcrumbsProps) => { marginRight: t.space.$2, })} /> - {root.name} + {t(root.name)} { )} - {title} - + /> ); diff --git a/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsPage.tsx b/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsPage.tsx index 31c70f743ae..86c10cb3637 100644 --- a/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsPage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsPage.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useWizard, Wizard } from '../common'; import { useCoreUser } from '../contexts'; -import { Col, Image, Text } from '../customizables'; +import { Col, Image, localizationKeys, Text } from '../customizables'; import { ArrowBlockButton, useCardState, withCardStateProvider } from '../elements'; import { useEnabledThirdPartyProviders, useNavigate } from '../hooks'; import { useRouter } from '../router'; @@ -14,7 +14,7 @@ import { ContentPage } from './Page'; import { SuccessPage } from './SuccessPage'; export const ConnectedAccountsPage = withCardStateProvider(() => { - const title = 'Add connected account'; + const title = localizationKeys('userProfile.connectedAccountPage.title'); const user = useCoreUser(); const { params } = useRouter(); @@ -29,7 +29,7 @@ export const ConnectedAccountsPage = withCardStateProvider(() => { ); @@ -52,13 +52,7 @@ const AddConnectedAccount = () => { // TODO: Decide if we should keep using this strategy // If yes, refactor and cleanup: card.setLoading(strategy); - // const redirectUrl = stateParam.add(window.location.href, { - // c: 'userProfile', - // la: 'createExternalAccount', - // lam: { strategy }, - // }); user - // .createExternalAccount({ strategy: strategy, redirect_url: redirectUrl.toString() }) .createExternalAccount({ strategy: strategy, redirect_url: window.location.href }) .then(res => { if (res.verification?.externalVerificationRedirectURL) { @@ -73,20 +67,16 @@ const AddConnectedAccount = () => { return ( - - {unconnectedStrategies.length - ? 'Select a provider to connect your account.' - : 'There are no available external account providers.'} - + {unconnectedStrategies.map(strategy => ( connect(strategy)} @@ -94,8 +84,6 @@ const AddConnectedAccount = () => { isDisabled={card.isLoading} icon={ { sx={theme => ({ width: theme.sizes.$5 })} /> } - > - {`Connect ${strategyToDisplayData[strategy].name} account`} - + textLocalizationKey={localizationKeys('userProfile.connectedAccountPage.socialButtonsBlockButton', { + provider: strategyToDisplayData[strategy].name, + })} + /> ))} - Cancel + ); diff --git a/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsSection.tsx b/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsSection.tsx index 7ec9bdfa8ef..a6c36a07663 100644 --- a/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/ConnectedAccountsSection.tsx @@ -2,7 +2,7 @@ import { OAuthStrategy } from '@clerk/types'; import { ExternalAccountResource } from '@clerk/types/src'; import { useCoreUser } from '../contexts'; -import { Badge, Col, descriptors, Flex, Image } from '../customizables'; +import { Badge, Col, descriptors, Flex, Image, localizationKeys } from '../customizables'; import { useCardState, UserPreview } from '../elements'; import { useEnabledThirdPartyProviders } from '../hooks'; import { useNavigate } from '../hooks/useNavigate'; @@ -22,7 +22,7 @@ export const ConnectedAccountsSection = () => { return ( {accounts.map(account => ( @@ -32,11 +32,10 @@ export const ConnectedAccountsSection = () => { /> ))} navigate(`connected-account`)} - > - Connect account - + /> ); }; diff --git a/packages/clerk-js/src/ui/UserProfile/Content.tsx b/packages/clerk-js/src/ui/UserProfile/Content.tsx index 39492f49064..502ccc774ae 100644 --- a/packages/clerk-js/src/ui/UserProfile/Content.tsx +++ b/packages/clerk-js/src/ui/UserProfile/Content.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Route, Switch, useRouter } from '../router'; import { Col, descriptors } from '../customizables'; +import { Route, Switch, useRouter } from '../router'; import { common, mqu } from '../styledSystem'; import { ConnectedAccountsPage } from './ConnectedAccountsPage'; import { EmailPage } from './EmailPage'; diff --git a/packages/clerk-js/src/ui/UserProfile/EmailPage.tsx b/packages/clerk-js/src/ui/UserProfile/EmailPage.tsx index a5593c05ea7..3e705919f27 100644 --- a/packages/clerk-js/src/ui/UserProfile/EmailPage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/EmailPage.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useWizard, Wizard } from '../common'; import { useCoreUser, useEnvironment } from '../contexts'; -import { Text } from '../customizables'; +import { localizationKeys, Text } from '../customizables'; import { Form, useCardState, withCardStateProvider } from '../elements'; import { useRouter } from '../router'; import { handleError, useFormControl } from '../utils'; @@ -15,7 +15,7 @@ import { VerifyWithCode } from './VerifyWithCode'; import { VerifyWithLink } from './VerifyWithLink'; export const EmailPage = withCardStateProvider(() => { - const title = 'Add email address'; + const title = localizationKeys('userProfile.emailAddressPage.title'); const card = useCardState(); const user = useCoreUser(); const environment = useEnvironment(); @@ -32,7 +32,8 @@ export const EmailPage = withCardStateProvider(() => { const emailField = useFormControl('emailAddress', '', { type: 'text', - label: 'Email address', + label: localizationKeys('formFieldLabel__emailAddress'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), isRequired: true, }); @@ -60,11 +61,14 @@ export const EmailPage = withCardStateProvider(() => { autoFocus /> - - {preferMagicLinks - ? 'An email containing a verification link will be sent to this email address.' - : 'An email containing a verification code will be sent to this email address.'} - + @@ -87,7 +91,15 @@ export const EmailPage = withCardStateProvider(() => { ); diff --git a/packages/clerk-js/src/ui/UserProfile/EmailSection.tsx b/packages/clerk-js/src/ui/UserProfile/EmailSection.tsx index 1d30f657a5d..44933b42a09 100644 --- a/packages/clerk-js/src/ui/UserProfile/EmailSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/EmailSection.tsx @@ -2,7 +2,7 @@ import { EmailAddressResource } from '@clerk/types'; import React from 'react'; import { useCoreUser } from '../contexts'; -import { Badge, Col } from '../customizables'; +import { Badge, Col, localizationKeys } from '../customizables'; import { useCardState } from '../elements'; import { useNavigate } from '../hooks/useNavigate'; import { handleError } from '../utils'; @@ -18,7 +18,7 @@ export const EmailsSection = () => { return ( {user.emailAddresses.sort(primaryIdentificationFirst(user.primaryEmailAddressId)).map(email => ( @@ -29,11 +29,10 @@ export const EmailsSection = () => { ))} navigate('email-address')} - > - Add an email address - + /> ); }; @@ -51,7 +50,7 @@ const EmailAccordion = ({ email }: { email: EmailAddressResource }) => { return ( Primary : undefined} + badge={isPrimary ? : undefined} > {isPrimary && isVerified && ( @@ -80,7 +79,12 @@ const EmailAccordion = ({ email }: { email: EmailAddressResource }) => { {!isPrimary && !isVerified && ( Unverified} + titleLabel={ + + } subtitle='This email address has not been verified and may be limited in functionality' actionLabel='Complete verification' onClick={() => navigate(`email-address/${email.id}`)} diff --git a/packages/clerk-js/src/ui/UserProfile/FormButtons.tsx b/packages/clerk-js/src/ui/UserProfile/FormButtons.tsx index 114ee082d6b..f2b9cad60ed 100644 --- a/packages/clerk-js/src/ui/UserProfile/FormButtons.tsx +++ b/packages/clerk-js/src/ui/UserProfile/FormButtons.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Flex } from '../customizables'; +import { Flex, localizationKeys } from '../customizables'; import { Form } from '../elements'; import { PropsOfComponent } from '../styledSystem'; import { useNavigateToFlowStart } from './NavigateToFlowStartButton'; @@ -23,11 +23,10 @@ export const FormButtons = (props: FormButtonsProps) => { {submitLabel || 'Continue'} - Cancel - + /> ); }; diff --git a/packages/clerk-js/src/ui/UserProfile/LinkButtonWithDescription.tsx b/packages/clerk-js/src/ui/UserProfile/LinkButtonWithDescription.tsx index c3a6ef53e7b..5a0a808c602 100644 --- a/packages/clerk-js/src/ui/UserProfile/LinkButtonWithDescription.tsx +++ b/packages/clerk-js/src/ui/UserProfile/LinkButtonWithDescription.tsx @@ -1,18 +1,19 @@ -import { Button, Col, Flex, Text } from '../customizables'; +import { Button, Col, Flex, LocalizationKey, Text, useLocalizations } from '../customizables'; import { useLoadingStatus } from '../hooks'; import { PropsOfComponent } from '../styledSystem'; -type LinkButtonWithTextDescriptionProps = PropsOfComponent & { - title: string; +type LinkButtonWithTextDescriptionProps = Omit, 'title'> & { + title: LocalizationKey; titleLabel?: React.ReactNode; - subtitle: string; - actionLabel?: string; + subtitle: LocalizationKey; + actionLabel?: LocalizationKey; onClick?: (e: React.MouseEvent) => Promise; }; export const LinkButtonWithDescription = (props: LinkButtonWithTextDescriptionProps) => { const { title, subtitle, actionLabel, titleLabel, onClick: onClickProp, ...rest } = props; const status = useLoadingStatus(); + const { t } = useLocalizations(); const onClick = (e: React.MouseEvent) => { status.setLoading(); @@ -28,27 +29,28 @@ export const LinkButtonWithDescription = (props: LinkButtonWithTextDescriptionPr gap={2} align='center' > - {title} + {titleLabel} - {subtitle} - + /> {actionLabel && ( + /> )} ); diff --git a/packages/clerk-js/src/ui/UserProfile/MfaPage.tsx b/packages/clerk-js/src/ui/UserProfile/MfaPage.tsx index fab2b6c547d..eecaf716326 100644 --- a/packages/clerk-js/src/ui/UserProfile/MfaPage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/MfaPage.tsx @@ -2,7 +2,7 @@ import { VerificationStrategy } from '@clerk/types'; import React from 'react'; import { useCoreUser, useEnvironment } from '../contexts'; -import { Col, Grid, Text } from '../customizables'; +import { Col, Grid, localizationKeys, Text } from '../customizables'; import { TileButton, useCardState, withCardStateProvider } from '../elements'; import { AuthApp, Mobile } from '../icons'; import { mqu } from '../styledSystem'; @@ -19,7 +19,7 @@ export const MfaPage = withCardStateProvider(() => { userSettings: { attributes }, } = useEnvironment(); const user = useCoreUser(); - const title = 'Add two-step verification'; + const title = localizationKeys('userProfile.mfaPage.title'); const [selectedMethod, setSelectedMethod] = React.useState(); // Calculate second factors available to add on first use only diff --git a/packages/clerk-js/src/ui/UserProfile/MfaSection.tsx b/packages/clerk-js/src/ui/UserProfile/MfaSection.tsx index 7a61c5a6e1f..c165c5d7641 100644 --- a/packages/clerk-js/src/ui/UserProfile/MfaSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/MfaSection.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useCoreUser, useEnvironment } from '../contexts'; +import { localizationKeys } from '../customizables'; import { useNavigate } from '../hooks'; import { MfaPhoneCodeAccordion } from './MfaPhoneCodeAccordion'; import { MfaTOTPAccordion } from './MfaTOTPAccordion'; @@ -27,7 +28,7 @@ export const MfaSection = () => { return ( {showTOTP && } @@ -43,11 +44,10 @@ export const MfaSection = () => { {secondFactorsAvailableToAdd.length > 0 && ( navigate('multi-factor')} - > - Add two-step verification - + /> )} ); diff --git a/packages/clerk-js/src/ui/UserProfile/Navbar.tsx b/packages/clerk-js/src/ui/UserProfile/Navbar.tsx index 5bc0044d9b4..4593d9cd5ab 100644 --- a/packages/clerk-js/src/ui/UserProfile/Navbar.tsx +++ b/packages/clerk-js/src/ui/UserProfile/Navbar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, Col, descriptors, Flex, Icon } from '../customizables'; +import { Button, Col, descriptors, Flex, Icon, localizationKeys, useLocalizations } from '../customizables'; import { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; import { usePopover, useSafeLayoutEffect } from '../hooks'; import { Menu, TickShield, User } from '../icons'; @@ -19,9 +19,15 @@ export const NavbarContextProvider = (props: React.PropsWithChildren<{}>) => { }; export const BaseRoutes = [ - { name: 'Account', id: 'account', icon: User, path: '/' } as const, - { name: 'Security', id: 'security', icon: TickShield, path: '/security' } as const, + { name: localizationKeys('userProfile.start.headerTitle__account'), id: 'account', icon: User, path: '/' } as const, + { + name: localizationKeys('userProfile.start.headerTitle__security'), + id: 'security', + icon: TickShield, + path: '/security', + } as const, ] as const; + type BaseRouteId = typeof BaseRoutes[number]['id']; type NavBarProps = { @@ -35,6 +41,7 @@ export const NavBar = (props: NavBarProps) => { const [activeId, setActiveId] = React.useState('account'); const { close } = useNavbarContext(); const { navigateToFlowStart } = useNavigateToFlowStart(); + const { t } = useLocalizations(); const navigateAndScroll = async (id: BaseRouteId) => { if (contentRef.current) { @@ -62,7 +69,7 @@ export const NavBar = (props: NavBarProps) => { for (const entry of entries) { const id = entry.target?.id?.split('section-')[1]; if (entry.isIntersecting && id) { - return setActiveId(id as any); + return setActiveId(id as unknown as any); } } }; @@ -97,7 +104,7 @@ export const NavBar = (props: NavBarProps) => { icon={r.icon} isActive={activeId === r.id} > - {r.name} + {t(r.name)} ))} @@ -222,6 +229,7 @@ const NavButton = (props: NavButtonProps) => { export const NavbarMenuButtonRow = (props: PropsOfComponent) => { const { open } = useNavbarContext(); + const { t } = useLocalizations(); return ( ) => { icon={Menu} size='sm' /> - Menu + {t(localizationKeys('userProfile.mobileButton__menu'))} ); diff --git a/packages/clerk-js/src/ui/UserProfile/Page.tsx b/packages/clerk-js/src/ui/UserProfile/Page.tsx index c3f4565caf7..ba5d3203315 100644 --- a/packages/clerk-js/src/ui/UserProfile/Page.tsx +++ b/packages/clerk-js/src/ui/UserProfile/Page.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { Col, descriptors } from '../customizables'; +import { Col, descriptors, LocalizationKey } from '../customizables'; import { CardAlert, Header, useCardState } from '../elements'; import { PropsOfComponent } from '../styledSystem'; import { Breadcrumbs } from './Breadcrumbs'; import { NavbarMenuButtonRow } from './Navbar'; type PageProps = PropsOfComponent & { - headerTitle: string; + headerTitle: LocalizationKey; }; const Page = (props: PageProps) => { @@ -19,18 +19,16 @@ const Page = (props: PageProps) => { elementDescriptor={descriptors.page} gap={8} {...rest} - sx={t => ({ - // This needs to be less than the modal's max height - minHeight: t.sizes.$120, - // paddingBottom: '80px', - // maxHeight: `min(${t.sizes.$176}, calc(100vh - ${t.sizes.$20}))`, - })} + sx={t => ({ minHeight: t.sizes.$120 })} > {card.error} - {headerTitle} + {children} diff --git a/packages/clerk-js/src/ui/UserProfile/PasswordPage.tsx b/packages/clerk-js/src/ui/UserProfile/PasswordPage.tsx index 40966fbeb1d..b0e47637f0c 100644 --- a/packages/clerk-js/src/ui/UserProfile/PasswordPage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/PasswordPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useWizard, Wizard } from '../common'; import { useCoreUser } from '../contexts'; +import { localizationKeys } from '../customizables'; import { Form, useCardState, withCardStateProvider } from '../elements'; import { handleError, useFormControl } from '../utils'; import { FormButtons } from './FormButtons'; @@ -9,13 +10,18 @@ import { ContentPage } from './Page'; import { SuccessPage } from './SuccessPage'; export const PasswordPage = withCardStateProvider(() => { + const title = localizationKeys('userProfile.passwordPage.title'); const user = useCoreUser(); const card = useCardState(); const wizard = useWizard(); - const passwordField = useFormControl('password', '', { type: 'password', label: 'New password', isRequired: true }); + const passwordField = useFormControl('password', '', { + type: 'password', + label: localizationKeys('formFieldLabel__newPassword'), + isRequired: true, + }); const confirmField = useFormControl('confirmPassword', '', { type: 'password', - label: 'Confirm password', + label: localizationKeys('formFieldLabel__confirmPassword'), isRequired: true, }); @@ -42,7 +48,7 @@ export const PasswordPage = withCardStateProvider(() => { return ( - + { ); diff --git a/packages/clerk-js/src/ui/UserProfile/PasswordSection.tsx b/packages/clerk-js/src/ui/UserProfile/PasswordSection.tsx index b1250a7c6e7..984b3330ee9 100644 --- a/packages/clerk-js/src/ui/UserProfile/PasswordSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/PasswordSection.tsx @@ -1,6 +1,6 @@ import { useCoreUser } from '../contexts'; +import { localizationKeys, Text } from '../customizables'; import { useNavigate } from '../hooks/useNavigate'; -import { Text } from '../customizables'; import { ProfileSection } from './Section'; import { AddBlockButton } from './UserProfileBlockButtons'; @@ -14,7 +14,7 @@ export const PasswordSection = () => { return ( {passwordEnabled && ( @@ -28,9 +28,12 @@ export const PasswordSection = () => { - {passwordEnabled ? 'Change password' : 'Set password'} - + textLocalizationKey={ + passwordEnabled + ? localizationKeys('userProfile.start.passwordSection.primaryButton__changePassword') + : localizationKeys('userProfile.start.passwordSection.primaryButton__setPassword') + } + /> ); }; diff --git a/packages/clerk-js/src/ui/UserProfile/PhoneSection.tsx b/packages/clerk-js/src/ui/UserProfile/PhoneSection.tsx index ab3a5cd1424..4c3b8c904dd 100644 --- a/packages/clerk-js/src/ui/UserProfile/PhoneSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/PhoneSection.tsx @@ -2,7 +2,7 @@ import { PhoneNumberResource } from '@clerk/types'; import React from 'react'; import { useCoreUser } from '../contexts'; -import { Badge, Col, Text } from '../customizables'; +import { Badge, Col, localizationKeys, Text } from '../customizables'; import { useCardState } from '../elements'; import { useNavigate } from '../hooks'; import { getFlagEmojiFromCountryIso, handleError, parsePhoneString, stringToFormattedPhoneString } from '../utils'; @@ -18,7 +18,7 @@ export const PhoneSection = () => { return ( {user.phoneNumbers.sort(primaryIdentificationFirst(user.primaryPhoneNumberId)).map(phone => ( @@ -29,11 +29,10 @@ export const PhoneSection = () => { ))} navigate('phone-number')} - > - Add a phone number - + /> ); }; @@ -62,7 +61,7 @@ const PhoneAccordion = ({ phone }: { phone: PhoneNumberResource }) => { } title={formattedPhone} - badge={isPrimary ? Primary : undefined} + badge={isPrimary ? : undefined} > {isPrimary && isVerified && ( @@ -91,7 +90,12 @@ const PhoneAccordion = ({ phone }: { phone: PhoneNumberResource }) => { {!isPrimary && !isVerified && ( Unverified} + titleLabel={ + + } subtitle='This phone number has not been verified and may be limited in functionality' actionLabel='Complete verification' onClick={() => navigate(`phone-number/${phone.id}`)} diff --git a/packages/clerk-js/src/ui/UserProfile/ProfilePage.tsx b/packages/clerk-js/src/ui/UserProfile/ProfilePage.tsx index f2e5a820104..1d61d007774 100644 --- a/packages/clerk-js/src/ui/UserProfile/ProfilePage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/ProfilePage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useWizard, Wizard } from '../common'; import { useCoreUser, useEnvironment } from '../contexts'; -import { Button, Col, Flex, Text } from '../customizables'; +import { Button, Col, Flex, localizationKeys, Text } from '../customizables'; import { Avatar, FileDropArea, Form, useCardState, withCardStateProvider } from '../elements'; import { handleError, useFormControl } from '../utils'; import { FormButtons } from './FormButtons'; @@ -10,7 +10,7 @@ import { ContentPage } from './Page'; import { SuccessPage } from './SuccessPage'; export const ProfilePage = withCardStateProvider(() => { - const title = 'Update profile'; + const title = localizationKeys('userProfile.profilePage.title'); const card = useCardState(); const user = useCoreUser(); const [avatarChanged, setAvatarChanged] = React.useState(false); @@ -20,8 +20,16 @@ export const ProfilePage = withCardStateProvider(() => { const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); - const firstNameField = useFormControl('firstName', user.firstName || '', { type: 'text', label: 'First name' }); - const lastNameField = useFormControl('lastName', user.lastName || '', { type: 'text', label: 'Last name' }); + const firstNameField = useFormControl('firstName', user.firstName || '', { + type: 'text', + label: localizationKeys('formFieldLabel__firstName'), + placeholder: localizationKeys('formFieldInputPlaceholder__firstName'), + }); + const lastNameField = useFormControl('lastName', user.lastName || '', { + type: 'text', + label: localizationKeys('formFieldLabel__lastName'), + placeholder: localizationKeys('formFieldInputPlaceholder__lastName'), + }); const userInfoChanged = (showFirstName && firstNameField.value && firstNameField.value !== user.firstName) || @@ -70,7 +78,7 @@ export const ProfilePage = withCardStateProvider(() => { ); @@ -116,26 +124,24 @@ const AvatarUploader = (props: AvatarUploaderProps) => { optimize /> - Profile image + - {/* TODO */} - {/**/} - {/* Remove image*/} - {/**/} + /> diff --git a/packages/clerk-js/src/ui/UserProfile/RootPage.tsx b/packages/clerk-js/src/ui/UserProfile/RootPage.tsx index 63cbba75cf1..3eecf78492e 100644 --- a/packages/clerk-js/src/ui/UserProfile/RootPage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/RootPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useEnvironment } from '../contexts'; -import { Col, descriptors } from '../customizables'; +import { Col, descriptors, localizationKeys } from '../customizables'; import { Header } from '../elements'; import { ActiveDevicesSection } from './ActiveDevicesSection'; import { ConnectedAccountsSection } from './ConnectedAccountsSection'; @@ -37,8 +37,11 @@ export const RootPage = () => { gap={8} > - Account - Manage your account information + + {showUsername && } @@ -53,8 +56,11 @@ export const RootPage = () => { gap={8} > - Security - Manage your security preferences + + {showPassword && } {showMfa && } diff --git a/packages/clerk-js/src/ui/UserProfile/Section.tsx b/packages/clerk-js/src/ui/UserProfile/Section.tsx index 3e4349bb1cd..7a0a3d6d64b 100644 --- a/packages/clerk-js/src/ui/UserProfile/Section.tsx +++ b/packages/clerk-js/src/ui/UserProfile/Section.tsx @@ -1,10 +1,13 @@ import { ProfileSectionId } from '@clerk/types'; -import { Col, descriptors, Flex, Text } from '../customizables'; +import { Col, descriptors, Flex, LocalizationKey, Text } from '../customizables'; import { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; import { PropsOfComponent } from '../styledSystem'; -type ProfileSectionProps = PropsOfComponent & { title: string; id: ProfileSectionId }; +type ProfileSectionProps = Omit, 'title'> & { + title: LocalizationKey; + id: ProfileSectionId; +}; export const ProfileSection = (props: ProfileSectionProps) => { const { title, children, id, ...rest } = props; @@ -16,13 +19,12 @@ export const ProfileSection = (props: ProfileSectionProps) => { gap={2} > - {title} - + /> { }; type SectionHeaderProps = PropsOfComponent & { + localizationKey: LocalizationKey; textElementDescriptor?: ElementDescriptor; textElementId?: ElementId; }; export const SectionHeader = (props: SectionHeaderProps) => { - const { textElementDescriptor, textElementId, children, ...rest } = props; + const { textElementDescriptor, textElementId, localizationKey, ...rest } = props; return ( ({ borderBottom: `${theme.borders.$normal} ${theme.colors.$blackAlpha100}` })} > - {children} - + /> ); }; diff --git a/packages/clerk-js/src/ui/UserProfile/SuccessPage.tsx b/packages/clerk-js/src/ui/UserProfile/SuccessPage.tsx index fed208a092c..ccf614eaac3 100644 --- a/packages/clerk-js/src/ui/UserProfile/SuccessPage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/SuccessPage.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { Text } from '../customizables'; +import { LocalizationKey, localizationKeys, Text } from '../customizables'; import { FormButtonContainer } from './FormButtons'; import { NavigateToFlowStartButton } from './NavigateToFlowStartButton'; import { ContentPage } from './Page'; type SuccessPageProps = { - title: string; - text: string; + title: LocalizationKey; + text: LocalizationKey; }; export const SuccessPage = (props: SuccessPageProps) => { @@ -15,14 +15,16 @@ export const SuccessPage = (props: SuccessPageProps) => { return ( - {text} + - Finish - + localizationKey={localizationKeys('userProfile.formButtonPrimary__finish')} + /> ); diff --git a/packages/clerk-js/src/ui/UserProfile/UserProfileSection.tsx b/packages/clerk-js/src/ui/UserProfile/UserProfileSection.tsx index 8162db3c350..ff7d8b1c5e2 100644 --- a/packages/clerk-js/src/ui/UserProfile/UserProfileSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/UserProfileSection.tsx @@ -2,8 +2,9 @@ import { UserResource } from '@clerk/types'; import React from 'react'; import { useCoreUser } from '../contexts'; -import { useNavigate } from '../hooks'; +import { localizationKeys } from '../customizables'; import { UserPreview } from '../elements'; +import { useNavigate } from '../hooks'; import { ProfileSection } from './Section'; import { BlockButton } from './UserProfileBlockButtons'; @@ -13,7 +14,7 @@ export const UserProfileSection = () => { return ( navigate('profile')}> diff --git a/packages/clerk-js/src/ui/UserProfile/UsernamePage.tsx b/packages/clerk-js/src/ui/UserProfile/UsernamePage.tsx index db50dc52701..a94308eafca 100644 --- a/packages/clerk-js/src/ui/UserProfile/UsernamePage.tsx +++ b/packages/clerk-js/src/ui/UserProfile/UsernamePage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useWizard, Wizard } from '../common'; import { useCoreUser } from '../contexts'; +import { localizationKeys } from '../customizables'; import { Form, useCardState, withCardStateProvider } from '../elements'; import { handleError, useFormControl } from '../utils'; import { FormButtons } from './FormButtons'; @@ -12,7 +13,11 @@ export const UsernamePage = withCardStateProvider(() => { const user = useCoreUser(); const card = useCardState(); const wizard = useWizard(); - const usernameField = useFormControl('username', user.username || '', { type: 'text', label: 'Username' }); + const usernameField = useFormControl('username', user.username || '', { + type: 'text', + label: localizationKeys('formFieldLabel__username'), + placeholder: localizationKeys('formFieldInputPlaceholder__username'), + }); const canSubmit = usernameField.value.length > 1 && user.username !== usernameField.value; @@ -27,7 +32,7 @@ export const UsernamePage = withCardStateProvider(() => { return ( - + { ); diff --git a/packages/clerk-js/src/ui/UserProfile/UsernameSection.tsx b/packages/clerk-js/src/ui/UserProfile/UsernameSection.tsx index 701d2212b09..86819722879 100644 --- a/packages/clerk-js/src/ui/UserProfile/UsernameSection.tsx +++ b/packages/clerk-js/src/ui/UserProfile/UsernameSection.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { useCoreUser } from '../contexts'; +import { localizationKeys, Text } from '../customizables'; import { useNavigate } from '../hooks/useNavigate'; -import { Text } from '../customizables'; import { ProfileSection } from './Section'; import { AddBlockButton } from './UserProfileBlockButtons'; @@ -12,7 +12,7 @@ export const UsernameSection = () => { return ( {user.username && ( @@ -26,9 +26,12 @@ export const UsernameSection = () => { navigate('username')} - > - {user.username ? 'Change username' : 'Set username'} - + textLocalizationKey={ + user.username + ? localizationKeys('userProfile.start.usernameSection.primaryButton__changeUsername') + : localizationKeys('userProfile.start.usernameSection.primaryButton__setUsername') + } + /> ); }; diff --git a/packages/clerk-js/src/ui/UserProfile/VerifyWithCode.tsx b/packages/clerk-js/src/ui/UserProfile/VerifyWithCode.tsx index 04c4f07bb90..c139263d9f4 100644 --- a/packages/clerk-js/src/ui/UserProfile/VerifyWithCode.tsx +++ b/packages/clerk-js/src/ui/UserProfile/VerifyWithCode.tsx @@ -1,6 +1,7 @@ import { EmailAddressResource, PhoneNumberResource } from '@clerk/types'; import React from 'react'; +import { localizationKeys } from '../customizables'; import { useCardState, useCodeControl } from '../elements'; import { CodeForm } from '../elements/CodeForm'; import { useLoadingStatus } from '../hooks'; @@ -56,8 +57,9 @@ export const VerifyWithCode = (props: VerifyWithCodeProps) => { return ( <> { return ( <> diff --git a/packages/clerk-js/src/ui/UserProfile/Web3Section.tsx b/packages/clerk-js/src/ui/UserProfile/Web3Section.tsx index a88876dda6f..edbdd7ae3d8 100644 --- a/packages/clerk-js/src/ui/UserProfile/Web3Section.tsx +++ b/packages/clerk-js/src/ui/UserProfile/Web3Section.tsx @@ -1,7 +1,7 @@ import { Web3WalletResource } from '@clerk/types'; import { useCoreUser } from '../contexts'; -import { Col, Flex, Image } from '../customizables'; +import { Col, Flex, Image, localizationKeys } from '../customizables'; import { useEnabledThirdPartyProviders } from '../hooks'; import { useNavigate } from '../hooks/useNavigate'; import { LinkButtonWithDescription } from './LinkButtonWithDescription'; @@ -15,7 +15,7 @@ export const Web3Section = () => { return ( {user.web3Wallets.map(wallet => ( @@ -27,9 +27,8 @@ export const Web3Section = () => { navigate(`web3-wallet`)} - > - Add web3 wallet - + textLocalizationKey={localizationKeys('userProfile.start.web3WalletsSection.primaryButton')} + > ); }; diff --git a/packages/clerk-js/src/ui/common/constants.ts b/packages/clerk-js/src/ui/common/constants.ts index 60180bb3516..ccce92c91db 100644 --- a/packages/clerk-js/src/ui/common/constants.ts +++ b/packages/clerk-js/src/ui/common/constants.ts @@ -1,39 +1,49 @@ import type { Web3Provider } from '@clerk/types'; +import { LocalizationKey, localizationKeys } from '../localization/localizationKeys'; + const FirstFactorConfigs = Object.freeze({ email_address: { - label: 'Email address', - fieldType: 'email', + label: localizationKeys('formFieldLabel__emailAddress'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), + type: 'email', }, phone_number: { - label: 'Phone number', - fieldType: 'tel', + label: localizationKeys('formFieldLabel__phoneNumber'), + placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), + type: 'tel', }, username: { - label: 'Username', - fieldType: 'text', + label: localizationKeys('formFieldLabel__username'), + placeholder: localizationKeys('formFieldInputPlaceholder__username'), + type: 'text', }, email_address_phone_number: { - label: 'Email or phone', - fieldType: 'text', + label: localizationKeys('formFieldLabel__emailAddress_phoneNumber'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress_phoneNumber'), + type: 'text', }, email_address_username: { - label: 'Email or username', - fieldType: 'text', + label: localizationKeys('formFieldLabel__emailAddress_username'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress_username'), + type: 'text', }, phone_number_username: { - label: 'Phone number or username', - fieldType: 'text', + label: localizationKeys('formFieldLabel__phoneNumber_username'), + placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber_username'), + type: 'text', }, email_address_phone_number_username: { - label: 'Email, phone, or username', - fieldType: 'text', + label: localizationKeys('formFieldLabel__emailAddress_phoneNumber_username'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress_phoneNumber_username'), + type: 'text', }, default: { label: '', - fieldType: 'text', + placeholder: '', + type: 'text', }, -} as Record); +} as Record); export const getIdentifierControlDisplayValues = (attributes: string[]) => { const indexKey = attributes.length == 0 ? null : [...attributes].sort().join('_'); diff --git a/packages/clerk-js/src/ui/common/errorHandler.test.ts b/packages/clerk-js/src/ui/common/errorHandler.test.ts deleted file mode 100644 index cec064a58a6..00000000000 --- a/packages/clerk-js/src/ui/common/errorHandler.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { ClerkAPIResponseError } from '../../core/resources/Error'; -import { getFieldError, getGlobalError, handleError } from './errorHandler'; -import type { FieldState } from './forms'; - -describe('handleError(err, fields, setGlobalError)', () => { - it('sets fields errors and sets the first global error', () => { - const error = new ClerkAPIResponseError('Boom', { - data: [ - { - message: 'Some field error', - long_message: 'Some field error', - code: 'some_field_error', - meta: { param_name: 'foo' }, - }, - { - message: 'Some global error', - long_message: 'Some global error', - code: 'some_global_error', - }, - { - message: 'Another global error', - long_message: 'Another global error', - code: 'some_global_error', - }, - ], - status: 500, - }); - - const mockSetError = jest.fn(); - const mockSetValue = jest.fn(); - const mockSetGlobalError = jest.fn(); - - const fieldState: FieldState = { - name: 'foo', - value: '', - setValue: mockSetValue, - error: '', - setError: mockSetError, - }; - - handleError(error, [fieldState], mockSetGlobalError); - - expect(mockSetError).toHaveBeenNthCalledWith(1, undefined); - expect(mockSetError).toHaveBeenNthCalledWith(2, 'Some field error'); - expect(mockSetGlobalError).toHaveBeenNthCalledWith(1, undefined); - expect(mockSetGlobalError).toHaveBeenNthCalledWith(2, 'Some global error'); - - expect(mockSetValue).not.toHaveBeenCalled(); - }); - - it("doesn't sets global errors unless setter is provided", () => { - const error = new ClerkAPIResponseError('Boom', { data: [], status: 500 }); - - const mockSetError = jest.fn(); - const mockSetValue = jest.fn(); - - handleError(error, []); - - expect(mockSetError).not.toHaveBeenCalled(); - expect(mockSetValue).not.toHaveBeenCalled(); - }); - - it('rethrows non Clerk API errors', () => { - expect(() => { - handleError(new Error('Boom'), []); - }).toThrow('Boom'); - }); -}); - -describe('getGlobalError', () => { - it('returns the first global error or undefined', () => { - const message = 'the message'; - let err = getGlobalError( - new ClerkAPIResponseError(message, { - data: [ - { code: 'first', message }, - { code: 'second', message }, - ], - status: 400, - }), - ); - if (!err) { - fail(); - } - expect(err.code).toEqual('first'); - - err = getGlobalError( - new ClerkAPIResponseError(message, { - data: [], - status: 400, - }), - ); - expect(err).toBeUndefined(); - - expect(getGlobalError(new Error('the error'))).toBeUndefined(); - }); -}); - -describe('getFieldError', () => { - it('returns the first field error or undefined', () => { - const message = 'the message'; - let err = getFieldError( - new ClerkAPIResponseError(message, { - data: [ - { code: 'first', message, meta: { param_name: 'field' } }, - { code: 'second', message, meta: { param_name: 'field' } }, - ], - status: 400, - }), - ); - if (!err) { - fail(); - } - expect(err.code).toEqual('first'); - - err = getFieldError( - new ClerkAPIResponseError(message, { - data: [], - status: 400, - }), - ); - expect(err).toBeUndefined(); - - expect(getFieldError(new Error('the error'))).toBeUndefined(); - }); -}); diff --git a/packages/clerk-js/src/ui/common/errorHandler.ts b/packages/clerk-js/src/ui/common/errorHandler.ts deleted file mode 100644 index 14f5c7440f0..00000000000 --- a/packages/clerk-js/src/ui/common/errorHandler.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ClerkAPIError } from '@clerk/types'; - -import { isClerkAPIResponseError } from '../../core/resources/internal'; -import type { FieldState } from './forms'; - -interface ParserErrors { - fieldErrors: ClerkAPIError[]; - globalErrors: ClerkAPIError[]; -} - -function clearFieldErrors(fieldStates: Array>) { - fieldStates.forEach(field => { - field.setError(undefined); - }); -} - -function setFieldErrors(fieldStates: Array>, errors: ClerkAPIError[]) { - if (!errors || errors.length < 1) { - return; - } - - fieldStates.forEach(field => { - const error = errors.find(err => err.meta!.paramName === field.name); - if (error) { - field.setError(error.message); - } - }); -} - -function parseErrors(errors: ClerkAPIError[]): ParserErrors { - return (errors || []).reduce( - (memo, err) => { - if (err.meta!.paramName) { - memo.fieldErrors.push(err); - } else { - memo.globalErrors.push(err); - } - return memo; - }, - { - fieldErrors: Array(0), - globalErrors: Array(0), - }, - ); -} - -export function handleError( - err: Error, - fieldStates: Array>, - setGlobalError?: React.Dispatch> | ((error: string | undefined) => void), -): void { - // Throw unknown errors - if (!isClerkAPIResponseError(err)) { - throw err; - } - - // Clear global and field errors - if (typeof setGlobalError === 'function') { - setGlobalError(undefined); - } - clearFieldErrors(fieldStates); - - // Group errors to field and global - const { fieldErrors, globalErrors } = parseErrors(err.errors); - - // Show field errors if applicable - setFieldErrors(fieldStates, fieldErrors); - - // Show only the first global error until we have snack bar stacks if applicable - if (typeof setGlobalError === 'function' && globalErrors[0]) { - setGlobalError(globalErrors[0].longMessage || globalErrors[0].message); - } -} - -// Returns the first global API error or undefined if none exists. -export function getGlobalError(err: Error): ClerkAPIError | undefined { - if (!isClerkAPIResponseError(err)) { - return; - } - const { globalErrors } = parseErrors(err.errors); - if (!globalErrors.length) { - return; - } - return globalErrors[0]; -} - -// Returns the first field API error or undefined if none exists. -export function getFieldError(err: Error): ClerkAPIError | undefined { - if (!isClerkAPIResponseError(err)) { - return; - } - const { fieldErrors } = parseErrors(err.errors); - if (!fieldErrors.length) { - return; - } - return fieldErrors[0]; -} - -export function getClerkAPIErrorMessage(err: ClerkAPIError): string { - return err.longMessage || err.message; -} diff --git a/packages/clerk-js/src/ui/common/index.ts b/packages/clerk-js/src/ui/common/index.ts index 980fd75811f..0e7c6bdb11b 100644 --- a/packages/clerk-js/src/ui/common/index.ts +++ b/packages/clerk-js/src/ui/common/index.ts @@ -1,6 +1,5 @@ export * from './authPropHelpers'; export * from './constants'; -export * from './errorHandler'; export * from './forms'; export * from './redirects'; export * from './verification'; diff --git a/packages/clerk-js/src/ui/common/verification.ts b/packages/clerk-js/src/ui/common/verification.ts index f0c6ee16893..e3618cf84bc 100644 --- a/packages/clerk-js/src/ui/common/verification.ts +++ b/packages/clerk-js/src/ui/common/verification.ts @@ -1,6 +1,6 @@ import { ClerkAPIError } from '@clerk/types'; -import { getClerkAPIErrorMessage, getGlobalError } from './index'; +import { getClerkAPIErrorMessage, getGlobalError } from '../utils'; export const VerificationErrorMessage = { Incorrect: 'Incorrect, try again', diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 6430f15dda9..e27052162a0 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -18,7 +18,7 @@ export type SignUpContextType = SignUpCtx & { authQueryString: string | null; }; export const useSignUpContext = (): SignUpContextType => { - const { componentName, ...ctx } = React.useContext(ComponentContext) as SignUpCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignUpCtx; const { navigate } = useNavigate(); const { displayConfig } = useEnvironment(); const { queryParams } = useRouter(); @@ -84,7 +84,7 @@ export type SignInContextType = SignInCtx & { }; export const useSignInContext = (): SignInContextType => { - const { componentName, ...ctx } = React.useContext(ComponentContext) as SignInCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignInCtx; const { navigate } = useNavigate(); const { displayConfig } = useEnvironment(); const { queryParams } = useRouter(); @@ -147,7 +147,7 @@ export type UserProfileContextType = UserProfileCtx & { // `routing` and `path` // TODO: remove if not needed during the components v2 overhaul export const useUserProfileContext = (): UserProfileContextType => { - const { componentName, ...ctx } = React.useContext(ComponentContext) as UserProfileCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx; const { queryParams } = useRouter(); if (componentName !== 'UserProfile') { @@ -163,7 +163,7 @@ export const useUserProfileContext = (): UserProfileContextType => { }; export const useUserButtonContext = () => { - const { componentName, ...ctx } = React.useContext(ComponentContext) as UserButtonCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserButtonCtx; const { navigate } = useNavigate(); const { displayConfig } = useEnvironment(); diff --git a/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx b/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx index 1714a458b61..7cf0847f12f 100644 --- a/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx +++ b/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { createContextAndHook, useDeepEqualMemo } from '../utils'; +import { useDeepEqualMemo } from '../hooks'; +import { createContextAndHook } from '../utils'; import { AppearanceCascade, parseAppearance, ParsedAppearance } from './parseAppearance'; type AppearanceContextValue = ParsedAppearance; diff --git a/packages/clerk-js/src/ui/customizables/index.ts b/packages/clerk-js/src/ui/customizables/index.ts index b107d892378..e5f91aa05a4 100644 --- a/packages/clerk-js/src/ui/customizables/index.ts +++ b/packages/clerk-js/src/ui/customizables/index.ts @@ -1,30 +1,38 @@ +import { makeLocalizable } from '../localization'; import * as Primitives from '../primitives'; import { descriptors } from './elementDescriptors'; import { makeCustomizable } from './makeCustomizable'; - -export const Flex = makeCustomizable(Primitives.Flex); -export const Col = makeCustomizable(Primitives.Col); -export const Grid = makeCustomizable(Primitives.Grid); -// export const Button = makeCustomizable(Primitives.Button, { defaultDescriptor: descriptors.button }); -export const Button = makeCustomizable(Primitives.Button); -export const SimpleButton = makeCustomizable(Primitives.SimpleButton); -// export const SimpleButton = makeCustomizable(Primitives.SimpleButton, { defaultDescriptor: descriptors.button }); -export const Heading = makeCustomizable(Primitives.Heading); -export const Link = makeCustomizable(Primitives.Link); -export const Text = makeCustomizable(Primitives.Text); -export const Image = makeCustomizable(Primitives.Image); -export const Alert = makeCustomizable(Primitives.Alert); -export const AlertIcon = makeCustomizable(Primitives.AlertIcon); -export const Input = makeCustomizable(Primitives.Input); -export const FormControl = makeCustomizable(Primitives.FormControl); -export const FormLabel = makeCustomizable(Primitives.FormLabel); -export const FormErrorText = makeCustomizable(Primitives.FormErrorText); -export const Form = makeCustomizable(Primitives.Form); -export const Icon = makeCustomizable(Primitives.Icon); -export const Spinner = makeCustomizable(Primitives.Spinner); -export const Badge = makeCustomizable(Primitives.Badge, { defaultDescriptor: descriptors.badge }); +import { sanitizeDomProps } from './sanitizeDomProps'; export * from './Flow'; export { AppearanceProvider, useAppearance } from './AppearanceContext'; export { descriptors } from './elementDescriptors'; +export { localizationKeys, useLocalizations } from '../localization'; +export type { LocalizationKey } from '../localization'; export { generateFlowPartClassname } from './classGeneration'; + +export const Flex = makeCustomizable(sanitizeDomProps(Primitives.Flex)); +export const Col = makeCustomizable(sanitizeDomProps(Primitives.Col)); +export const Grid = makeCustomizable(sanitizeDomProps(Primitives.Grid)); +// export const Button = makeCustomizable(sanitizeDomPropsPrimitives.Button, { defaultDescriptor: descriptors.button }); + +export const Button = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Button))); +export const SimpleButton = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.SimpleButton))); + +// export const SimpleButton = makeCustomizable(sanitizeDomPropsPrimitives.SimpleButton, { defaultDescriptor: descriptors.button }); +export const Heading = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Heading))); +export const Link = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Link))); +export const Text = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Text))); +export const Image = makeCustomizable(sanitizeDomProps(Primitives.Image)); +export const Alert = makeCustomizable(sanitizeDomProps(Primitives.Alert)); +export const AlertIcon = makeCustomizable(sanitizeDomProps(Primitives.AlertIcon)); +export const Input = makeCustomizable(sanitizeDomProps(Primitives.Input)); +export const FormControl = makeCustomizable(sanitizeDomProps(Primitives.FormControl)); +export const FormLabel = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.FormLabel))); +export const FormErrorText = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.FormErrorText))); +export const Form = makeCustomizable(sanitizeDomProps(Primitives.Form)); +export const Icon = makeCustomizable(sanitizeDomProps(Primitives.Icon)); +export const Spinner = makeCustomizable(sanitizeDomProps(Primitives.Spinner)); +export const Badge = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Badge)), { + defaultDescriptor: descriptors.badge, +}); diff --git a/packages/clerk-js/src/ui/customizables/makeCustomizable.tsx b/packages/clerk-js/src/ui/customizables/makeCustomizable.tsx index d6ca754ea8b..aee538c4ba5 100644 --- a/packages/clerk-js/src/ui/customizables/makeCustomizable.tsx +++ b/packages/clerk-js/src/ui/customizables/makeCustomizable.tsx @@ -50,6 +50,8 @@ export const makeCustomizable = ( return ( >(Component: P): P => { + const component = React.forwardRef((props: any, ref) => { + const { elementId, elementDescriptor, localizationKey, ...restProps } = props; + return ( + + ); + }); + return component as unknown as P; +}; diff --git a/packages/clerk-js/src/ui/elements/ArrowBlockButton.tsx b/packages/clerk-js/src/ui/elements/ArrowBlockButton.tsx index 61e0afd07b0..8a2bbb84ff0 100644 --- a/packages/clerk-js/src/ui/elements/ArrowBlockButton.tsx +++ b/packages/clerk-js/src/ui/elements/ArrowBlockButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, Flex, Icon, SimpleButton, Spinner, Text } from '../customizables'; +import { Button, Flex, Icon, LocalizationKey, SimpleButton, Spinner, Text } from '../customizables'; import { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; import { ArrowRightIcon } from '../icons'; import { PropsOfComponent, ThemableCssProp } from '../styledSystem'; @@ -15,6 +15,7 @@ type ArrowBlockButtonProps = PropsOfComponent & { arrowElementId?: ElementId; spinnerElementDescriptor?: ElementDescriptor; spinnerElementId?: ElementId; + textLocalizationKey?: LocalizationKey; }; export const ArrowBlockButton = (props: ArrowBlockButtonProps) => { @@ -30,6 +31,7 @@ export const ArrowBlockButton = (props: ArrowBlockButtonProps) => { spinnerElementId, arrowElementDescriptor, arrowElementId, + textLocalizationKey, ...rest } = props; @@ -96,6 +98,7 @@ export const ArrowBlockButton = (props: ArrowBlockButtonProps) => { truncate colorScheme='inherit' variant='buttonSmallRegular' + localizationKey={textLocalizationKey} > {children} diff --git a/packages/clerk-js/src/ui/elements/BackLink.tsx b/packages/clerk-js/src/ui/elements/BackLink.tsx index 575ae1748b7..2edb0e8ad0f 100644 --- a/packages/clerk-js/src/ui/elements/BackLink.tsx +++ b/packages/clerk-js/src/ui/elements/BackLink.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Flex, Icon } from '../customizables'; +import { Flex, Icon, localizationKeys, Text } from '../customizables'; import { ElementDescriptor } from '../customizables/elementDescriptors'; import { ArrowLeftIcon } from '../icons'; import { PropsOfComponent } from '../styledSystem'; @@ -19,15 +19,16 @@ export const BackLink = (props: BackLinkProps) => { elementDescriptor={boxElementDescriptor} sx={theme => ({ marginBottom: theme.space.$2x5 })} > - + - Back + ); diff --git a/packages/clerk-js/src/ui/elements/CodeForm.tsx b/packages/clerk-js/src/ui/elements/CodeForm.tsx index c30ad77a5c2..698518767ae 100644 --- a/packages/clerk-js/src/ui/elements/CodeForm.tsx +++ b/packages/clerk-js/src/ui/elements/CodeForm.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { Col, descriptors, Text } from '../customizables'; +import { Col, descriptors, LocalizationKey, Text } from '../customizables'; import { CodeControl, useCodeControl } from './CodeControl'; import { TimerButton } from './TimerButton'; type CodeFormProps = { - title: string; - subtitle: string; + title: LocalizationKey; + subtitle: LocalizationKey; + resendButton: LocalizationKey; isLoading: boolean; success: boolean; onResendCodeClicked?: React.MouseEventHandler; @@ -14,31 +15,24 @@ type CodeFormProps = { }; export const CodeForm = (props: CodeFormProps) => { - const { subtitle, title, isLoading, success, codeControl, onResendCodeClicked } = props; + const { subtitle, title, isLoading, success, codeControl, onResendCodeClicked, resendButton } = props; return ( - - - {title} - - - {subtitle} - - + + { isDisabled={success || isLoading} showCounter={!success} sx={theme => ({ marginTop: theme.space.$6 })} - > - Resend code - + localizationKey={resendButton} + /> )} ); diff --git a/packages/clerk-js/src/ui/elements/Divider.tsx b/packages/clerk-js/src/ui/elements/Divider.tsx index 786f7d5e852..79896ea1b36 100644 --- a/packages/clerk-js/src/ui/elements/Divider.tsx +++ b/packages/clerk-js/src/ui/elements/Divider.tsx @@ -1,4 +1,4 @@ -import { descriptors, Flex, Text } from '../customizables'; +import { descriptors, Flex, localizationKeys, Text } from '../customizables'; export const Divider = () => { return ( @@ -8,13 +8,12 @@ export const Divider = () => { > ({ margin: `0 ${t.space.$4}` })} - > - or - + /> ); diff --git a/packages/clerk-js/src/ui/elements/ErrorCard.tsx b/packages/clerk-js/src/ui/elements/ErrorCard.tsx index 928a5f73a6a..6e33f55cdd3 100644 --- a/packages/clerk-js/src/ui/elements/ErrorCard.tsx +++ b/packages/clerk-js/src/ui/elements/ErrorCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import { descriptors, Flex, Flow, Icon, LocalizationKey, localizationKeys, Text } from '../customizables'; import { useSupportEmail } from '../hooks/useSupportEmail'; -import { descriptors, Flex, Flow, Icon, Text } from '../customizables'; import { Email } from '../icons'; import { CardAlert } from './Alert'; import { ArrowBlockButton } from './ArrowBlockButton'; @@ -11,8 +11,8 @@ import { Footer } from './Footer'; import { Header } from './Header'; type ErrorCardProps = { - cardTitle?: string; - cardSubtitle?: string; + cardTitle?: string | LocalizationKey; + cardSubtitle?: string | LocalizationKey; message?: string; onBackLinkClick?: React.MouseEventHandler | undefined; }; @@ -32,8 +32,8 @@ export const ErrorCard = (props: ErrorCardProps) => { {card.error} {onBackLinkClick && } - {props.cardTitle || 'Error'} - {props.cardSubtitle && {props.cardSubtitle}} + + {props.cardSubtitle && } {/*TODO: extract main in its own component */} { - If you’re experiencing difficulty signing into your account, email us and we will work with you to restore - access as soon as possible. - + localizationKey={localizationKeys('signIn.alternativeMethods.getHelp.content')} + /> { sx={theme => ({ color: theme.colors.$blackAlpha500 })} /> } - > - Email support - + /> diff --git a/packages/clerk-js/src/ui/elements/FileDropArea.tsx b/packages/clerk-js/src/ui/elements/FileDropArea.tsx index 5827cb87c29..88db1f94f6a 100644 --- a/packages/clerk-js/src/ui/elements/FileDropArea.tsx +++ b/packages/clerk-js/src/ui/elements/FileDropArea.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, Col, descriptors, Flex, Icon, Text } from '../customizables'; +import { Button, Col, descriptors, Flex, Icon, localizationKeys, Text } from '../customizables'; import { Folder } from '../icons'; import { animations, mqu } from '../styledSystem'; import { colors } from '../utils'; @@ -104,29 +104,26 @@ export const FileDropArea = (props: FileDropAreaProps) => { ) : ( <> - Drag file here, or... - + /> + /> )} - Upload a JPG, PNG, GIF, or WEBP image smaller than 10 MB - + /> ); }; diff --git a/packages/clerk-js/src/ui/elements/Footer.tsx b/packages/clerk-js/src/ui/elements/Footer.tsx index 920befb5cf4..4affa246a2f 100644 --- a/packages/clerk-js/src/ui/elements/Footer.tsx +++ b/packages/clerk-js/src/ui/elements/Footer.tsx @@ -28,6 +28,7 @@ const FooterAction = (props: React.PropsWithChildren): JSX.Element => { const FooterActionText = (props: React.PropsWithChildren): JSX.Element => { return ( ): JSX.Element => { const FooterActionLink = (props: PropsOfComponent): JSX.Element => { return ( ) => { isDisabled={isDisabled} type='submit' {...props} + localizationKey={props.localizationKey || localizationKeys('formButtonPrimary')} /> ); diff --git a/packages/clerk-js/src/ui/elements/FormControl.tsx b/packages/clerk-js/src/ui/elements/FormControl.tsx index b441ad919e4..8a8ece6c97c 100644 --- a/packages/clerk-js/src/ui/elements/FormControl.tsx +++ b/packages/clerk-js/src/ui/elements/FormControl.tsx @@ -9,23 +9,27 @@ import { FormLabel, Input, Link, + LocalizationKey, + localizationKeys, Text, + useLocalizations, } from '../customizables'; import { PropsOfComponent } from '../styledSystem'; import { useCardState } from './contexts'; import { PasswordInput } from './PasswordInput'; import { PhoneInput } from './PhoneInput'; -type FormControlProps = { +type FormControlProps = Omit, 'label' | 'placeholder'> & { id: FieldId; - label: string; isRequired?: boolean; isOptional?: boolean; errorText?: string; - actionLabel?: string; onActionClicked?: React.MouseEventHandler; isDisabled?: boolean; -} & PropsOfComponent; + label: string | LocalizationKey; + placeholder?: string | LocalizationKey; + actionLabel?: string | LocalizationKey; +}; // TODO: Convert this into a Component? const getInputElementForType = (type: FormControlProps['type']) => { @@ -33,8 +37,10 @@ const getInputElementForType = (type: FormControlProps['type']) => { }; export const FormControl = (props: FormControlProps) => { + const { t } = useLocalizations(); const card = useCardState(); - const { id, errorText, isRequired, isOptional, label, actionLabel, onActionClicked, sx, ...rest } = props; + const { id, errorText, isRequired, isOptional, label, actionLabel, onActionClicked, sx, placeholder, ...rest } = + props; const hasError = !!errorText; const isDisabled = props.isDisabled || card.isLoading; @@ -58,6 +64,7 @@ export const FormControl = (props: FormControlProps) => { sx={theme => ({ marginBottom: theme.space.$1 })} > { isRequired={isRequired} sx={{ marginRight: 'auto' }} > - {label} + {typeof label === 'string' ? label : undefined} {isOptional && !actionLabel && ( - Optional - + /> )} {actionLabel && ( { onActionClicked?.(e); }} > - {actionLabel} + {typeof actionLabel === 'string' ? actionLabel : undefined} )} @@ -101,6 +108,7 @@ export const FormControl = (props: FormControlProps) => { isDisabled={isDisabled} isRequired={isRequired} {...rest} + placeholder={t(placeholder)} /> ): JSX.Element ); }); -const Subtitle = React.memo((props: React.PropsWithChildren): JSX.Element => { +const Subtitle = React.memo((props: PropsOfComponent): JSX.Element => { return ( & { diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index 0b83aa251d6..6e552329cef 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -1,7 +1,7 @@ import { OAuthProvider, OAuthStrategy, Web3Provider, Web3Strategy } from '@clerk/types'; import React from 'react'; -import { Button, descriptors, Grid, Image, useAppearance } from '../customizables'; +import { Button, descriptors, Grid, Image, localizationKeys, useAppearance } from '../customizables'; import { useEnabledThirdPartyProviders } from '../hooks'; import { PropsOfComponent } from '../styledSystem'; import { sleep } from '../utils'; @@ -140,6 +140,7 @@ const SocialButtonBlock = (props: SocialButtonProps): JSX.Element => { elementId={descriptors.socialButtonsBlockButton.setId(id)} textElementDescriptor={descriptors.socialButtonsBlockButtonText} textElementId={descriptors.socialButtonsBlockButtonText.setId(id)} + textLocalizationKey={localizationKeys('socialButtonsBlockButton', { provider: id })} arrowElementDescriptor={descriptors.socialButtonsBlockButtonArrow} arrowElementId={descriptors.socialButtonsBlockButtonArrow.setId(id)} {...rest} diff --git a/packages/clerk-js/src/ui/elements/TimerButton.tsx b/packages/clerk-js/src/ui/elements/TimerButton.tsx index 681647e047d..77e4f779440 100644 --- a/packages/clerk-js/src/ui/elements/TimerButton.tsx +++ b/packages/clerk-js/src/ui/elements/TimerButton.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; -import { Button } from '../customizables'; +import { Button, useLocalizations } from '../customizables'; import { useSafeLayoutEffect } from '../hooks'; import { PropsOfComponent } from '../styledSystem'; @@ -11,7 +11,16 @@ type TimerButtonProps = PropsOfComponent & { }; export const TimerButton = (props: TimerButtonProps) => { - const { onClick: onClickProp, throttleTimeInSec = 30, startDisabled, children, showCounter = true, ...rest } = props; + const { t } = useLocalizations(); + const { + onClick: onClickProp, + throttleTimeInSec = 30, + startDisabled, + children, + localizationKey, + showCounter = true, + ...rest + } = props; const [remainingSeconds, setRemainingSeconds] = React.useState(0); const intervalIdRef = React.useRef(undefined); @@ -52,7 +61,7 @@ export const TimerButton = (props: TimerButtonProps) => { isDisabled={remainingSeconds > 0 || props.isDisabled} onClick={handleOnClick} > - {children} + {t(localizationKey) || children} {remainingSeconds && showCounter ? ` (${remainingSeconds})` : ''} ); diff --git a/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx b/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx index 4c19b7fcbbf..1e0dd0968f0 100644 --- a/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx +++ b/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { descriptors, Flex } from '../customizables'; -// import { useLoadingStatus } from '../hooks'; +import { Col, descriptors, LocalizationKey, localizationKeys } from '../customizables'; import { useLoadingStatus } from '../hooks'; import { handleError, sleep, useFormControl } from '../utils'; import { CardAlert } from './Alert'; @@ -14,10 +13,11 @@ import { Header } from './Header'; import { IdentityPreview } from './IdentityPreview'; export type VerificationCodeCardProps = { - cardTitle: string; - cardSubtitle: string; - formTitle: string; - formSubtitle: string; + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + resendButton: LocalizationKey; safeIdentifier?: string; profileImageUrl?: string; onCodeEntryFinishedAction: ( @@ -66,34 +66,35 @@ export const VerificationCodeCard = (props: VerificationCodeCardProps) => { {card.error} - {/**/} - {props.cardTitle} - {props.cardSubtitle} + + - {/*TODO: extract main in its own component */} - - + {props.onShowAlternativeMethodsClicked && ( - Use another method + )} diff --git a/packages/clerk-js/src/ui/elements/VerificationLinkCard.tsx b/packages/clerk-js/src/ui/elements/VerificationLinkCard.tsx index a9dbf775c68..841e885e31d 100644 --- a/packages/clerk-js/src/ui/elements/VerificationLinkCard.tsx +++ b/packages/clerk-js/src/ui/elements/VerificationLinkCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import { Col, descriptors, Flow, LocalizationKey, localizationKeys, Text } from '../customizables'; import { useRouter } from '../router'; -import { Col, descriptors, Flow, Text } from '../customizables'; import { CardAlert } from './Alert'; import { Card } from './Card'; import { useCardState } from './contexts'; @@ -12,10 +12,11 @@ import { TimerButton } from './TimerButton'; type VerificationLinkCardProps = { safeIdentifier: string; - cardTitle: string; - cardSubtitle: string; - formTitle: string; - formSubtitle: string; + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + resendButton: LocalizationKey; profileImageUrl?: string; onResendCodeClicked: React.MouseEventHandler; onShowAlternativeMethodsClicked?: React.MouseEventHandler; @@ -34,8 +35,8 @@ export const VerificationLinkCard = (props: VerificationLinkCardProps) => { {card.error} - {props.cardTitle} - {props.cardSubtitle} + + { {props.onShowAlternativeMethodsClicked && ( - Use another method + )} @@ -66,8 +71,9 @@ export const VerificationLinkCard = (props: VerificationLinkCardProps) => { }; type VerificationLinkProps = { - formTitle: string; - formSubtitle: string; + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + resendButton: LocalizationKey; onResendCodeClicked: React.MouseEventHandler; }; @@ -83,29 +89,26 @@ export const VerificationLink = (props: VerificationLinkProps) => { gap={1} > - {props.formTitle} - + /> - {props.formSubtitle} - + /> ({ marginTop: theme.space.$4 })} - > - Resend link - + /> ); }; diff --git a/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx b/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx index 006d5003587..b358ea9ef40 100644 --- a/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { useLocalizations } from '../../customizables'; import { useSafeState } from '../../hooks'; import { createContextAndHook } from '../../utils'; @@ -26,10 +27,14 @@ const CardStateProvider = (props: React.PropsWithChildren) => { const useCardState = () => { const { state, setState } = _useCardState(); + const { translateError } = useLocalizations(); return { setIdle: (metadata?: Metadata) => setState(s => ({ ...s, status: 'idle', metadata })), - setError: (metadata: Metadata) => setState(s => ({ ...s, error: metadata })), + setError: (metadata: Metadata) => + setState(s => { + return { ...s, error: translateError(metadata) }; + }), setLoading: (metadata?: Metadata) => setState(s => ({ ...s, status: 'loading', metadata })), loadingMetadata: state.status === 'loading' ? state.metadata : undefined, error: state.error ? state.error : undefined, diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index 2a39d6c4f94..931d701dc19 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -10,3 +10,4 @@ export * from './useLocalStorage'; export * from './useSearchInput'; export * from './useSafeState'; export * from './useScrollLock'; +export * from './useDeepEqualMemo'; diff --git a/packages/clerk-js/src/ui/utils/useDeepEqualMemo.ts b/packages/clerk-js/src/ui/hooks/useDeepEqualMemo.ts similarity index 100% rename from packages/clerk-js/src/ui/utils/useDeepEqualMemo.ts rename to packages/clerk-js/src/ui/hooks/useDeepEqualMemo.ts diff --git a/packages/clerk-js/src/ui/localization/defaultEnglishResource.ts b/packages/clerk-js/src/ui/localization/defaultEnglishResource.ts new file mode 100644 index 00000000000..d8fc5dbb847 --- /dev/null +++ b/packages/clerk-js/src/ui/localization/defaultEnglishResource.ts @@ -0,0 +1,250 @@ +import { LocalizationResource } from '@clerk/types'; + +const commonTexts = { + signIn: { + phoneCode: { + title: 'Check your phone', + subtitle: 'to continue to {{applicationName}}', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code sent to your phone number', + resendButton: 'Resend code', + }, + }, +} as const; + +export const defaultResource: LocalizationResource = { + socialButtonsBlockButton: 'Continue with {{provider|titleize}}', + dividerText: 'or', + formFieldLabel__emailAddress: 'Email address', + formFieldLabel__phoneNumber: 'Phone number', + formFieldLabel__username: 'Username', + formFieldLabel__emailAddress_phoneNumber: 'Email address or phone number', + formFieldLabel__emailAddress_username: 'Email address or username', + formFieldLabel__phoneNumber_username: 'phone number or username', + formFieldLabel__emailAddress_phoneNumber_username: 'Email address, phone number or username', + formFieldLabel__password: 'Password', + formFieldLabel__newPassword: 'New password', + formFieldLabel__confirmPassword: 'Confirm password', + formFieldLabel__firstName: 'First name', + formFieldLabel__lastName: 'Last name', + formFieldInputPlaceholder__emailAddress: '', + formFieldInputPlaceholder__phoneNumber: '', + formFieldInputPlaceholder__username: '', + formFieldInputPlaceholder__emailAddress_phoneNumber: '', + formFieldInputPlaceholder__emailAddress_username: '', + formFieldInputPlaceholder__phoneNumber_username: '', + formFieldInputPlaceholder__emailAddress_phoneNumber_username: '', + formFieldInputPlaceholder__password: '', + formFieldInputPlaceholder__firstName: '', + formFieldInputPlaceholder__lastName: '', + formFieldAction__forgotPassword: 'Forgot password', + formFieldHintText__optional: 'Optional', + formButtonPrimary: 'Continue', + signInEnterPasswordTitle: 'Enter your password', + backButton: 'Back', + footerActionLink__useAnotherMethod: 'Use another method', + badge__primary: 'Primary', + badge__thisDevice: 'This device', + badge__default: 'Default', + badge__unverified: 'Unverified', + badge__requiresAction: 'Requires action', + signUp: { + start: { + title: 'Create your account', + subtitle: 'to continue to {{applicationName}}', + actionText: 'Have an account?', + actionLink: 'Sign in', + }, + emailLink: { + title: 'Verify your email', + subtitle: 'to continue to {{applicationName}}', + formTitle: 'Verification link', + formSubtitle: 'Use the verification link sent to your email address', + resendButton: 'Resend link', + }, + emailCode: { + title: 'Verify your email', + subtitle: 'to continue to {{applicationName}}', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code sent to your email address', + resendButton: 'Resend code', + }, + phoneCode: { + title: 'Verify your phone', + subtitle: 'to continue to {{applicationName}}', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code sent to your phone number', + resendButton: 'Resend code', + }, + continue: { + title: 'Fill in missing fields', + subtitle: 'to continue to {{applicationName}}', + actionText: 'Have an account?', + actionLink: 'Sign in', + }, + }, + signIn: { + start: { + title: 'Sign in', + subtitle: 'to continue to {{applicationName}}', + actionText: 'No account?', + actionLink: 'Sign up', + }, + password: { + title: 'Enter your password', + subtitle: 'to continue to {{applicationName}}', + actionLink: 'Use another method', + }, + emailCode: { + title: 'Check your email', + subtitle: 'to continue to {{applicationName}}', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code sent to your email address', + resendButton: 'Resend code', + }, + emailLink: { + title: 'Check your email', + subtitle: 'to continue to {{applicationName}}', + formTitle: 'Verification link', + formSubtitle: 'Use the verification link sent your email', + resendButton: 'Resend link', + }, + phoneCode: { ...commonTexts.signIn.phoneCode }, + phoneCode2Fa: { ...commonTexts.signIn.phoneCode, subtitle: '' }, + totp2Fa: { + title: 'Two-step authentication', + subtitle: '', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code generated by your authenticator app', + resendButton: 'Resend code', + }, + alternativeMethods: { + title: 'Use another method', + actionLink: 'Get help', + blockButton__emailLink: 'Send link to {{identifier}}', + blockButton__emailCode: 'Send code to {{identifier}}', + blockButton__phoneCode: 'Send code to {{identifier}}', + blockButton__password: 'Sign in with your password', + getHelp: { + title: 'Get help', + content: `If you’re experiencing difficulty signing into your account, email us and we will work with you to restore access as soon as possible.`, + blockButton__emailSupport: 'Email support', + }, + }, + }, + userProfile: { + mobileButton__menu: 'Menu', + formButtonPrimary__continue: 'Continue', + formButtonPrimary__finish: 'Finish', + formButtonReset: 'Cancel', + start: { + headerTitle__account: 'Account', + headerTitle__security: 'Security', + headerSubtitle__account: 'Manage your account information', + headerSubtitle__security: 'Manage your security preferences', + profileSection: { + title: 'Profile', + }, + usernameSection: { + title: 'Username', + primaryButton__changeUsername: 'Change username', + primaryButton__setUsername: 'Set username', + }, + emailAddressesSection: { + title: 'Email addresses', + primaryButton: 'Add an email address', + }, + phoneNumbersSection: { + title: 'Phone numbers', + primaryButton: 'Add a phone number', + }, + connectedAccountsSection: { + title: 'Connected accounts', + primaryButton: 'Connect account', + }, + passwordSection: { + title: 'Password', + primaryButton__changePassword: 'Change password', + primaryButton__setPassword: 'Set password', + }, + mfaSection: { + title: 'Two-step verification', + primaryButton: 'Add two-step verification', + }, + activeDevicesSection: { + title: 'Active devices', + primaryButton: 'Active devices', + detailsTitle: 'Current device', + detailsSubtitle: 'This is the device you are currently using', + destructiveActionTitle: 'Sign out', + destructiveActionSubtitle: 'Sign out from your account on this device', + destructiveAction: 'Sign out of device', + }, + web3WalletsSection: { + title: 'Web3 wallets', + primaryButton: 'Web3 wallets', + }, + }, + profilePage: { + title: 'Update profile', + imageFormTitle: 'Profile image', + imageFormSubtitle: 'Upload image', + fileDropAreaTitle: 'Drag file here, or...', + fileDropAreaAction: 'Select file', + fileDropAreaHint: 'Upload a JPG, PNG, GIF, or WEBP image smaller than 10 MB', + successMessage: 'Your profile has been updated.', + }, + usernamePage: { + title: 'Update username', + successMessage: 'Your username has been updated.', + }, + emailAddressPage: { + title: 'Add email address', + emailCode: { + formHint: 'An email containing a verification code will be sent to this email address.', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code send to {{identifier}}', + resendButton: 'Resend link', + successMessage: 'The email {{identifier}} has been added to your account.', + }, + emailLink: { + formHint: 'An email containing a verification link will be sent to this email address.', + formTitle: 'Verification link', + formSubtitle: 'Click on the verification link in the email sent to {{identifier}}', + resendButton: 'Resend link', + successMessage: 'The email {{identifier}} has been added to your account.', + }, + }, + connectedAccountPage: { + title: 'Add connected account', + formHint: 'Select a provider to connect your account.', + formHint__noAccounts: 'There are no available external account providers.', + socialButtonsBlockButton: 'Connect {{provider|titleize}} account', + successMessage: 'The provider has been added to your account', + }, + passwordPage: { + title: 'Set password', + successMessage: 'Your password has been set.', + }, + mfaPage: { + title: 'Add two-step verification', + formHint: 'Select a method to add.', + successMessage: 'Your password has been set.', + }, + }, + userButton: { + action__manageAccount: 'Manage account', + action__signOut: 'Sign out', + action__signOutAll: 'Sign out of all accounts', + action__addAccount: 'Add account', + }, + unstable__errors: { + form_identifier_not_found: '', + form_password_pwned: '', + form_username_invalid_length: '', + form_param_format_invalid: '', + form_password_length_too_short: '', + form_param_nil: '', + form_code_incorrect: '', + }, +} as const; diff --git a/packages/clerk-js/src/ui/localization/index.ts b/packages/clerk-js/src/ui/localization/index.ts new file mode 100644 index 00000000000..7c3c6b8995e --- /dev/null +++ b/packages/clerk-js/src/ui/localization/index.ts @@ -0,0 +1,4 @@ +export * from './makeLocalizable'; +export * from './localizationKeys'; +export * from './defaultEnglishResource'; +export * from './parseLocalization'; diff --git a/packages/clerk-js/src/ui/localization/localizationKeys.ts b/packages/clerk-js/src/ui/localization/localizationKeys.ts new file mode 100644 index 00000000000..596926a2578 --- /dev/null +++ b/packages/clerk-js/src/ui/localization/localizationKeys.ts @@ -0,0 +1,80 @@ +import { PathValue, RecordToPath } from '@clerk/types'; + +import { defaultResource } from './defaultEnglishResource'; + +type Value = string | number | boolean | Date; +type Whitespace = ' ' | '\t' | '\n' | '\r'; + +type Trim = T extends `${Whitespace}${infer Rest}` + ? Trim + : T extends `${infer Rest}${Whitespace}` + ? Trim + : T extends string + ? T + : never; + +type RemovePipeUtils = Text extends `${infer Left}|titleize${infer Right}` + ? `${Left}${Right}` + : Text; + +type FindBlocks = Text extends `${string}{{${infer Right}` + ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] + ? [Block, ...FindBlocks] + : never + : []; + +type TupleFindBlocks = T extends readonly [infer First, ...infer Rest] + ? [...FindBlocks, ...TupleFindBlocks] + : []; + +type ReadBlock< + Block extends string, + Tail extends string, + Depth extends string, +> = Tail extends `${infer L1}}}${infer R1}` + ? L1 extends `${infer L2}{{${infer R2}` + ? ReadBlock<`${Block}${L2}{{`, `${R2}}}${R1}`, `${Depth}+`> + : Depth extends `+${infer Rest}` + ? ReadBlock<`${Block}${L1}}}`, R1, Rest> + : [`${Block}${L1}`, R1] + : []; + +/** Parse block, return variables with types and recursively find nested blocks within */ +type ParseBlock = Block extends `${infer Name},${infer Format},${infer Rest}` + ? { [K in Trim]: VariableType> } & TupleParseBlock>> + : Block extends `${infer Name},${infer Format}` + ? { [K in Trim]: VariableType> } + : { [K in Trim]: Value }; + +/** Parse block for each tuple entry */ +type TupleParseBlock = T extends readonly [infer First, ...infer Rest] + ? ParseBlock & TupleParseBlock + : unknown; + +type VariableType = T extends 'number' | 'plural' | 'selectordinal' + ? number + : T extends 'date' | 'time' + ? Date + : Value; + +export type GetICUArgs> = TupleParseBlock< + T extends readonly string[] ? TupleFindBlocks : FindBlocks +>; + +type DefaultLocalizationKey = RecordToPath; +type LocalizationKeyToValue

= PathValue; + +// @ts-ignore +type LocalizationKeyToParams

= GetICUArgs>; + +export type LocalizationKey = { + key: string; + params: Record | undefined; +}; + +export const localizationKeys = >( + key: Key, + params?: keyof Params extends never ? never : Params, +): LocalizationKey => { + return { key, params } as LocalizationKey; +}; diff --git a/packages/clerk-js/src/ui/localization/makeLocalizable.tsx b/packages/clerk-js/src/ui/localization/makeLocalizable.tsx new file mode 100644 index 00000000000..0ad56755cc5 --- /dev/null +++ b/packages/clerk-js/src/ui/localization/makeLocalizable.tsx @@ -0,0 +1,97 @@ +import { ClerkAPIError, LocalizationResource } from '@clerk/types'; +import React from 'react'; + +import { titleize } from '../shared'; +import { readObjectPath } from '../utils'; +import { LocalizationKey, localizationKeys } from './localizationKeys'; +import { useParsedLocalizationResource } from './parseLocalization'; + +type Localizable = T & { + localizationKey?: LocalizationKey | string; +}; + +type LocalizablePrimitive = React.FunctionComponent>; + +export const makeLocalizable = (Component: React.FunctionComponent

): LocalizablePrimitive

=> { + const localizableComponent = React.forwardRef((props: Localizable, ref) => { + const parsedResource = useParsedLocalizationResource(); + const { localizationKey, ...restProps } = props; + + if (!localizationKey) { + return ( + + ); + } + + if (typeof localizationKey === 'string') { + return ( + + {localizationKey} + + ); + } + + return ( + + {localizedStringFromKey(localizationKey, parsedResource) || restProps.children} + + ); + }); + + const displayName = Component.displayName || Component.name || 'Component'; + localizableComponent.displayName = `Localizable${displayName}`.replace('_', ''); + return localizableComponent as LocalizablePrimitive

; +}; + +export const useLocalizations = () => { + const parsedResource = useParsedLocalizationResource(); + + const t = (localizationKey: LocalizationKey | string | undefined) => { + if (!localizationKey || typeof localizationKey === 'string') { + return localizationKey || ''; + } + return localizedStringFromKey(localizationKey, parsedResource); + }; + + const translateError = (error: string | ClerkAPIError | undefined) => { + if (!error || typeof error === 'string') { + return t(error); + } + const { code, message } = error || {}; + if (!code) { + return ''; + } + return t(localizationKeys(`unstable__errors.${code}` as any)) || message; + }; + + return { t, translateError }; +}; + +const localizationKeyAttribute = (localizationKey: LocalizationKey) => { + return localizationKey.key; +}; + +const localizedStringFromKey = (localizationKey: LocalizationKey, resource: LocalizationResource): string => { + const key = localizationKey.key; + const params = localizationKey.params; + const base = readObjectPath(resource, key); + let localizedStr = (base || '') as string; + if (localizedStr) { + for (const paramKey in params) { + // TODO: Localizations v2: smarter pipe operator (|) for utils + localizedStr = localizedStr.replace(`{{${paramKey}|titleize}}`, titleize(params[paramKey])); + localizedStr = localizedStr.replace(`{{${paramKey}}}`, params[paramKey]); + } + } + return localizedStr || ''; +}; diff --git a/packages/clerk-js/src/ui/localization/parseLocalization.ts b/packages/clerk-js/src/ui/localization/parseLocalization.ts new file mode 100644 index 00000000000..2ecacdc2274 --- /dev/null +++ b/packages/clerk-js/src/ui/localization/parseLocalization.ts @@ -0,0 +1,29 @@ +import { DeepPartial, LocalizationResource } from '@clerk/types'; +import { dequal as deepEqual } from 'dequal'; + +import { useOptions } from '../contexts'; +import { fastDeepMergeAndReplace } from '../utils'; +import { defaultResource } from './defaultEnglishResource'; + +let cache: LocalizationResource | undefined; +let prev: DeepPartial | undefined; + +const parseLocalizationResource = ( + userDefined: DeepPartial, + base: LocalizationResource, +): LocalizationResource => { + if (!cache || (!!prev && prev !== userDefined && !deepEqual(userDefined, prev))) { + prev = userDefined; + const res = {} as LocalizationResource; + fastDeepMergeAndReplace(base, res); + fastDeepMergeAndReplace(userDefined, res); + cache = res; + return cache; + } + return cache; +}; + +export const useParsedLocalizationResource = () => { + const { localization } = useOptions(); + return parseLocalizationResource(localization || {}, defaultResource as any as LocalizationResource); +}; diff --git a/packages/clerk-js/src/ui/primitives/Alert.tsx b/packages/clerk-js/src/ui/primitives/Alert.tsx index dfb65796a83..1d900ff3467 100644 --- a/packages/clerk-js/src/ui/primitives/Alert.tsx +++ b/packages/clerk-js/src/ui/primitives/Alert.tsx @@ -12,6 +12,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ variants: {}, })); +// @ts-ignore export type AlertProps = React.PropsWithChildren>; export const Alert = (props: AlertProps): JSX.Element => { diff --git a/packages/clerk-js/src/ui/primitives/AlertIcon.tsx b/packages/clerk-js/src/ui/primitives/AlertIcon.tsx index 2cbc47df8f0..7575d35fb48 100644 --- a/packages/clerk-js/src/ui/primitives/AlertIcon.tsx +++ b/packages/clerk-js/src/ui/primitives/AlertIcon.tsx @@ -20,6 +20,8 @@ const { applyVariants, filterProps } = createVariants(theme => ({ })); type OwnProps = { variant: 'danger' | 'warning' }; + +// @ts-ignore export type AlertIconProps = OwnProps & StyleVariants; export const AlertIcon = (props: AlertIconProps): JSX.Element => { diff --git a/packages/clerk-js/src/ui/primitives/Badge.tsx b/packages/clerk-js/src/ui/primitives/Badge.tsx index 0d887d1f11b..e49085a5597 100644 --- a/packages/clerk-js/src/ui/primitives/Badge.tsx +++ b/packages/clerk-js/src/ui/primitives/Badge.tsx @@ -41,6 +41,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ }, })); +// @ts-ignore export type BadgeProps = PropsOfComponent & StyleVariants; export const Badge = (props: BadgeProps) => { diff --git a/packages/clerk-js/src/ui/primitives/Box.tsx b/packages/clerk-js/src/ui/primitives/Box.tsx index ef881bd47e4..0e04e20fd3c 100644 --- a/packages/clerk-js/src/ui/primitives/Box.tsx +++ b/packages/clerk-js/src/ui/primitives/Box.tsx @@ -10,6 +10,7 @@ const { applyVariants } = createVariants(() => ({ variants: {}, })); +// @ts-ignore export type BoxProps = StateProps & PrimitiveProps<'div'> & AsProp & StyleVariants; export const Box = React.forwardRef((props, ref) => { diff --git a/packages/clerk-js/src/ui/primitives/Flex.tsx b/packages/clerk-js/src/ui/primitives/Flex.tsx index 051167f253a..c8b2b4c5765 100644 --- a/packages/clerk-js/src/ui/primitives/Flex.tsx +++ b/packages/clerk-js/src/ui/primitives/Flex.tsx @@ -55,6 +55,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ }, })); +// @ts-ignore export type FlexProps = StateProps & BoxProps & StyleVariants; export const Flex = React.forwardRef((props, ref) => { diff --git a/packages/clerk-js/src/ui/primitives/Grid.tsx b/packages/clerk-js/src/ui/primitives/Grid.tsx index cc8f9a3c1d2..6dff0c88fb0 100644 --- a/packages/clerk-js/src/ui/primitives/Grid.tsx +++ b/packages/clerk-js/src/ui/primitives/Grid.tsx @@ -49,6 +49,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ }, })); +// @ts-ignore export type GridProps = StateProps & BoxProps & StyleVariants; export const Grid = React.forwardRef((props, ref) => { diff --git a/packages/clerk-js/src/ui/primitives/Heading.tsx b/packages/clerk-js/src/ui/primitives/Heading.tsx index 9b477865d7b..7fb2ce161de 100644 --- a/packages/clerk-js/src/ui/primitives/Heading.tsx +++ b/packages/clerk-js/src/ui/primitives/Heading.tsx @@ -22,6 +22,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ }, })); +// @ts-ignore export type HeadingProps = PrimitiveProps<'div'> & StyleVariants & { as?: 'h1' }; export const Heading = (props: HeadingProps) => { diff --git a/packages/clerk-js/src/ui/primitives/Icon.tsx b/packages/clerk-js/src/ui/primitives/Icon.tsx index 5bed77b496f..992f656a6e6 100644 --- a/packages/clerk-js/src/ui/primitives/Icon.tsx +++ b/packages/clerk-js/src/ui/primitives/Icon.tsx @@ -20,6 +20,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ }, })); +// @ts-ignore export type IconProps = StyleVariants & { icon: React.ComponentType; }; diff --git a/packages/clerk-js/src/ui/primitives/Input.tsx b/packages/clerk-js/src/ui/primitives/Input.tsx index bdd5de068a6..0e3c004744d 100644 --- a/packages/clerk-js/src/ui/primitives/Input.tsx +++ b/packages/clerk-js/src/ui/primitives/Input.tsx @@ -32,6 +32,7 @@ type OwnProps = { focusRing?: boolean; }; +// @ts-ignore export type InputProps = PrimitiveProps<'input'> & StyleVariants & OwnProps & RequiredProp; export const Input = React.forwardRef((props, ref) => { diff --git a/packages/clerk-js/src/ui/primitives/Link.tsx b/packages/clerk-js/src/ui/primitives/Link.tsx index bb32353884d..60aa123c8d4 100644 --- a/packages/clerk-js/src/ui/primitives/Link.tsx +++ b/packages/clerk-js/src/ui/primitives/Link.tsx @@ -42,6 +42,8 @@ const { applyVariants, filterProps } = createVariants(theme => ({ })); type OwnProps = { isExternal?: boolean; isDisabled?: boolean }; + +// @ts-ignore export type LinkProps = PrimitiveProps<'a'> & OwnProps & StyleVariants; export const Link = (props: LinkProps): JSX.Element => { diff --git a/packages/clerk-js/src/ui/primitives/Text.tsx b/packages/clerk-js/src/ui/primitives/Text.tsx index 4a416f7a157..43326963e85 100644 --- a/packages/clerk-js/src/ui/primitives/Text.tsx +++ b/packages/clerk-js/src/ui/primitives/Text.tsx @@ -38,6 +38,7 @@ const { applyVariants, filterProps } = createVariants(theme => { }; }); +// @ts-ignore export type TextProps = PrimitiveProps<'p'> & { isDisabled?: boolean } & StyleVariants & { as?: 'p' | 'div' | 'label' | 'code' | 'span' | 'li' | 'a'; }; diff --git a/packages/clerk-js/src/ui/styledSystem/breakpoints.tsx b/packages/clerk-js/src/ui/styledSystem/breakpoints.tsx index 542d7609849..23b536d202c 100644 --- a/packages/clerk-js/src/ui/styledSystem/breakpoints.tsx +++ b/packages/clerk-js/src/ui/styledSystem/breakpoints.tsx @@ -1,4 +1,4 @@ -import { fromEntries } from '../utils'; +import { fromEntries } from '../utils/fromEntries'; const breakpoints = Object.freeze({ xs: '21em', // 336px diff --git a/packages/clerk-js/src/ui/styledSystem/createCssVariables.ts b/packages/clerk-js/src/ui/styledSystem/createCssVariables.ts index c8514532a0b..aea114a1ec1 100644 --- a/packages/clerk-js/src/ui/styledSystem/createCssVariables.ts +++ b/packages/clerk-js/src/ui/styledSystem/createCssVariables.ts @@ -1,8 +1,4 @@ -// eslint-disable-next-line no-restricted-imports - -import { fromEntries } from '../utils'; - -const varHashId = Date.now(); +import { fromEntries } from '../utils/fromEntries'; export const createCssVariables = (...names: T): { [k in T[number]]: string } => { return fromEntries(names.map(name => [name, `var(--${name})`])); diff --git a/packages/clerk-js/src/ui/utils/errorHandler.ts b/packages/clerk-js/src/ui/utils/errorHandler.ts index 179c80557f3..ca0df16dffb 100644 --- a/packages/clerk-js/src/ui/utils/errorHandler.ts +++ b/packages/clerk-js/src/ui/utils/errorHandler.ts @@ -18,7 +18,10 @@ function setFieldErrors(fieldStates: Array>, errors: Cl const error = errors.find(err => { return err.meta!.paramName === field.id || snakeToCamel(err.meta!.paramName) === field.id; }); - field.setError(error?.message || undefined); + if (!error) { + return; + } + field.setError(error || undefined); }); } @@ -60,6 +63,7 @@ export function handleError( // Show field errors if applicable setFieldErrors(fieldStates, fieldErrors); + // TODO: Make global errors localizable // Show only the first global error until we have snack bar stacks if applicable if (typeof setGlobalError === 'function' && globalErrors[0]) { setGlobalError(globalErrors[0].longMessage || globalErrors[0].message); diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts index cee86ba8763..929a455c97a 100644 --- a/packages/clerk-js/src/ui/utils/index.ts +++ b/packages/clerk-js/src/ui/utils/index.ts @@ -1,12 +1,10 @@ +export * from './fromEntries'; export * from './containsAllOf'; export * from './createContextAndHook'; -export * from './useDeepEqualMemo'; export * from './createInfiniteAccessProxy'; export * from './fastDeepMerge'; export * from './colorTransformations'; export * from './colors'; -export * from './useFormControl'; -export * from './errorHandler'; export * from './factorSorting'; export * from './sleep'; export * from './isMobileDevice'; @@ -15,4 +13,6 @@ export * from './phoneUtils'; export * from './formatSafeIdentifier'; export * from './removeUndefinedProps'; export * from './getIdentifier'; -export * from './fromEntries'; +export * from './readObjectPath'; +export * from './useFormControl'; +export * from './errorHandler'; diff --git a/packages/clerk-js/src/ui/utils/readObjectPath.ts b/packages/clerk-js/src/ui/utils/readObjectPath.ts new file mode 100644 index 00000000000..02fe67baceb --- /dev/null +++ b/packages/clerk-js/src/ui/utils/readObjectPath.ts @@ -0,0 +1,11 @@ +export const readObjectPath = >(obj: O, path: string) => { + const props = (path || '').split('.'); + let cur = obj; + for (let i = 0; i < props.length; i++) { + cur = cur[props[i]]; + if (cur === undefined) { + return undefined; + } + } + return cur; +}; diff --git a/packages/clerk-js/src/ui/utils/useFormControl.ts b/packages/clerk-js/src/ui/utils/useFormControl.ts index 7c9b4358580..8aa371c42dd 100644 --- a/packages/clerk-js/src/ui/utils/useFormControl.ts +++ b/packages/clerk-js/src/ui/utils/useFormControl.ts @@ -1,9 +1,13 @@ +import { ClerkAPIError } from '@clerk/types'; import React, { HTMLInputTypeAttribute } from 'react'; +import { LocalizationKey, useLocalizations } from '../localization'; + type Options = { - label: string; type: HTMLInputTypeAttribute; isRequired?: boolean; + label: string | LocalizationKey; + placeholder?: string | LocalizationKey; }; type FieldStateProps = { @@ -15,7 +19,7 @@ type FieldStateProps = { } & Options; export type FormControlState = FieldStateProps & { - setError: (error: string | undefined) => void; + setError: (error: string | ClerkAPIError | undefined) => void; setValue: (val: string | undefined) => void; props: FieldStateProps; }; @@ -25,13 +29,14 @@ export const useFormControl = ( initialState: string, opts?: Options, ): FormControlState => { - opts = opts || { type: 'text', label: '', isRequired: false }; + opts = opts || { type: 'text', label: '', isRequired: false, placeholder: '' }; + const { translateError } = useLocalizations(); const [value, setValueInternal] = React.useState(initialState); const [errorText, setErrorText] = React.useState(undefined); const onChange: FormControlState['onChange'] = event => setValueInternal(event.target.value || ''); const setValue: FormControlState['setValue'] = val => setValueInternal(val || ''); - const setError: FormControlState['setError'] = error => setErrorText(error || undefined); + const setError: FormControlState['setError'] = error => setErrorText(translateError(error || undefined)); const props = { id, name: id, value, errorText, onChange, ...opts }; diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 17699c7a2f0..8ecdf6c932f 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -101,6 +101,10 @@ const useLoadedIsomorphicClerk = (options: NewIsomorphicClerkParams) => { isomorphicClerk.__unstable__updateProps({ appearance: options.options.appearance }); }, [options.options.appearance]); + React.useEffect(() => { + isomorphicClerk.__unstable__updateProps({ options: options.options }); + }, [options.options.localization]); + React.useEffect(() => { isomorphicClerk.addOnLoaded(() => setLoaded(true)); }, []); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 61f65c58108..67558707369 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1,6 +1,7 @@ import { Appearance, SignInTheme, SignUpTheme, UserButtonTheme, UserProfileTheme } from './appearance'; import { ClientResource } from './client'; import { DisplayThemeJSON } from './json'; +import { LocalizationResource } from './localization'; import { OrganizationResource } from './organization'; import { OrganizationInvitationResource } from './organizationInvitation'; import { MembershipRole, OrganizationMembershipResource } from './organizationMembership'; @@ -304,6 +305,7 @@ export type ClerkThemeOptions = DeepSnakeToCamel>; export interface ClerkOptions { appearance?: Appearance; + localization?: LocalizationResource; navigate?: (to: string) => Promise | unknown; polling?: boolean; selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 569e59f82c3..3651aa8d6b5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -35,3 +35,4 @@ export * from './identifiers'; export * from './factors'; export * from './attributes'; export * from './appearance'; +export * from './localization'; diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts new file mode 100644 index 00000000000..b2a2f9951ae --- /dev/null +++ b/packages/types/src/localization.ts @@ -0,0 +1,262 @@ +import { DeepPartial } from './utils'; + +export type LocalizationValue = string; + +/** + * A type containing all the possible localization keys the prebuilt Clerk components support. + * Users aiming to customise a few strings can also peak at the `data-localization-key` attribute by inspecting + * the DOM and updating the corresponding key. + * Users aiming to completely localize the components by providing a complete translation can use + * the default english resource object from {@link https://github.com/clerkinc/javascript our open source repo} + * as a starting point. + */ +export type LocalizationResource = DeepPartial<_LocalizationResource>; + +type _LocalizationResource = { + socialButtonsBlockButton: LocalizationValue; + dividerText: LocalizationValue; + formFieldLabel__emailAddress: LocalizationValue; + formFieldLabel__phoneNumber: LocalizationValue; + formFieldLabel__username: LocalizationValue; + formFieldLabel__emailAddress_phoneNumber: LocalizationValue; + formFieldLabel__emailAddress_username: LocalizationValue; + formFieldLabel__phoneNumber_username: LocalizationValue; + formFieldLabel__emailAddress_phoneNumber_username: LocalizationValue; + formFieldLabel__password: LocalizationValue; + formFieldLabel__newPassword: LocalizationValue; + formFieldLabel__confirmPassword: LocalizationValue; + formFieldLabel__firstName: LocalizationValue; + formFieldLabel__lastName: LocalizationValue; + formFieldInputPlaceholder__emailAddress: LocalizationValue; + formFieldInputPlaceholder__phoneNumber: LocalizationValue; + formFieldInputPlaceholder__username: LocalizationValue; + formFieldInputPlaceholder__emailAddress_phoneNumber: LocalizationValue; + formFieldInputPlaceholder__emailAddress_username: LocalizationValue; + formFieldInputPlaceholder__phoneNumber_username: LocalizationValue; + formFieldInputPlaceholder__emailAddress_phoneNumber_username: LocalizationValue; + formFieldInputPlaceholder__password: LocalizationValue; + formFieldInputPlaceholder__firstName: LocalizationValue; + formFieldInputPlaceholder__lastName: LocalizationValue; + formFieldAction__forgotPassword: LocalizationValue; + formFieldHintText__optional: LocalizationValue; + formButtonPrimary: LocalizationValue; + signInEnterPasswordTitle: LocalizationValue; + backButton: LocalizationValue; + footerActionLink__useAnotherMethod: LocalizationValue; + badge__primary: LocalizationValue; + badge__thisDevice: LocalizationValue; + badge__default: LocalizationValue; + badge__unverified: LocalizationValue; + badge__requiresAction: LocalizationValue; + signUp: { + start: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionText: LocalizationValue; + actionLink: LocalizationValue; + }; + emailLink: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + emailCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + phoneCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + continue: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionText: LocalizationValue; + actionLink: LocalizationValue; + }; + }; + signIn: { + start: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionText: LocalizationValue; + actionLink: LocalizationValue; + }; + password: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionLink: LocalizationValue; + }; + emailCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + emailLink: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + phoneCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + phoneCode2Fa: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + totp2Fa: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + alternativeMethods: { + title: LocalizationValue; + actionLink: LocalizationValue; + blockButton__emailLink: LocalizationValue; + blockButton__emailCode: LocalizationValue; + blockButton__phoneCode: LocalizationValue; + blockButton__password: LocalizationValue; + getHelp: { + title: LocalizationValue; + content: `If you’re experiencing difficulty signing into your account, email us and we will work with you to restore access as soon as possible.`; + blockButton__emailSupport: LocalizationValue; + }; + }; + }; + userProfile: { + mobileButton__menu: LocalizationValue; + formButtonPrimary__continue: LocalizationValue; + formButtonPrimary__finish: LocalizationValue; + formButtonReset: LocalizationValue; + start: { + headerTitle__account: LocalizationValue; + headerTitle__security: LocalizationValue; + headerSubtitle__account: LocalizationValue; + headerSubtitle__security: LocalizationValue; + profileSection: { + title: LocalizationValue; + }; + usernameSection: { + title: LocalizationValue; + primaryButton__changeUsername: LocalizationValue; + primaryButton__setUsername: LocalizationValue; + }; + emailAddressesSection: { + title: LocalizationValue; + primaryButton: LocalizationValue; + }; + phoneNumbersSection: { + title: LocalizationValue; + primaryButton: LocalizationValue; + }; + connectedAccountsSection: { + title: LocalizationValue; + primaryButton: LocalizationValue; + }; + passwordSection: { + title: LocalizationValue; + primaryButton__changePassword: LocalizationValue; + primaryButton__setPassword: LocalizationValue; + }; + mfaSection: { + title: LocalizationValue; + primaryButton: LocalizationValue; + }; + activeDevicesSection: { + title: LocalizationValue; + primaryButton: LocalizationValue; + detailsTitle: LocalizationValue; + detailsSubtitle: LocalizationValue; + destructiveActionTitle: LocalizationValue; + destructiveActionSubtitle: LocalizationValue; + destructiveAction: LocalizationValue; + }; + web3WalletsSection: { + title: LocalizationValue; + primaryButton: LocalizationValue; + }; + }; + profilePage: { + title: LocalizationValue; + imageFormTitle: LocalizationValue; + imageFormSubtitle: LocalizationValue; + fileDropAreaTitle: LocalizationValue; + fileDropAreaAction: LocalizationValue; + fileDropAreaHint: LocalizationValue; + successMessage: LocalizationValue; + }; + usernamePage: { + title: LocalizationValue; + successMessage: LocalizationValue; + }; + emailAddressPage: { + title: LocalizationValue; + emailCode: { + formHint: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + successMessage: LocalizationValue; + }; + emailLink: { + formHint: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + successMessage: LocalizationValue; + }; + }; + connectedAccountPage: { + title: LocalizationValue; + formHint: LocalizationValue; + formHint__noAccounts: LocalizationValue; + socialButtonsBlockButton: LocalizationValue; + successMessage: LocalizationValue; + }; + passwordPage: { + title: LocalizationValue; + successMessage: LocalizationValue; + }; + mfaPage: { + title: LocalizationValue; + formHint: LocalizationValue; + successMessage: LocalizationValue; + }; + }; + userButton: { + action__manageAccount: LocalizationValue; + action__signOut: LocalizationValue; + action__signOutAll: LocalizationValue; + action__addAccount: LocalizationValue; + }; + unstable__errors: { + form_identifier_not_found: LocalizationValue; + form_password_pwned: LocalizationValue; + form_username_invalid_length: LocalizationValue; + form_param_format_invalid: LocalizationValue; + form_password_length_too_short: LocalizationValue; + form_param_nil: LocalizationValue; + form_code_incorrect: LocalizationValue; + }; +}; diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts index ce12215835f..eb54b093d36 100644 --- a/packages/types/src/utils.ts +++ b/packages/types/src/utils.ts @@ -29,3 +29,43 @@ export type CamelToSnake = T extends `${infer C0}${infer R}` export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; }; + +/** + * Internal type used by RecordToPath + */ +type PathImpl = Key extends string + ? T[Key] extends Record + ? + | `${Key}.${PathImpl> & string}` + | `${Key}.${Exclude & string}` + : never + : never; + +/** + * Internal type used by RecordToPath + */ +type PathImpl2 = PathImpl | keyof T; + +/** + * Used to construct a type union containing all the keys (even if nested) of an object defined as const + * const obj = { a: { b: '' }, c: '' } as const; + * type Paths = RecordToPath + * Paths contains: 'a' | 'a.b' | 'c' + */ +export type RecordToPath = PathImpl2 extends string | keyof T ? PathImpl2 : keyof T; + +/** + * Used to read the value of a string path inside an object defined as const + * const obj = { a: { b: 'hello' }} as const; + * type Value = PathValue + * Value is now a union set containing a single type: 'hello' + */ +export type PathValue> = P extends `${infer Key}.${infer Rest}` + ? Key extends keyof T + ? Rest extends RecordToPath + ? PathValue + : never + : never + : P extends keyof T + ? T[P] + : never;