diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index bac10783b6e..9c817defffe 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -46,6 +46,7 @@ import { generateSignatureWithMetamask, generateSignatureWithOKXWallet, getBaseIdentifier, + getBrowserLocale, getClerkQueryParam, getCoinbaseWalletIdentifier, getMetamaskIdentifier, @@ -95,6 +96,7 @@ export class SignUp extends BaseResource implements SignUpResource { createdUserId: string | null = null; abandonAt: number | null = null; legalAcceptedAt: number | null = null; + locale: string | null = null; /** * The current status of the sign-up process. @@ -154,6 +156,14 @@ export class SignUp extends BaseResource implements SignUpResource { let finalParams = { ...params }; + // Inject browser locale if not already provided + if (!finalParams.locale) { + const browserLocale = getBrowserLocale(); + if (browserLocale) { + finalParams.locale = browserLocale; + } + } + if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) { const captchaChallenge = new CaptchaChallenge(SignUp.clerk); const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signup' }); @@ -477,6 +487,7 @@ export class SignUp extends BaseResource implements SignUpResource { this.abandonAt = data.abandon_at; this.web3wallet = data.web3_wallet; this.legalAcceptedAt = data.legal_accepted_at; + this.locale = data.locale; } eventBus.emit('resource:update', { resource: this }); @@ -505,6 +516,7 @@ export class SignUp extends BaseResource implements SignUpResource { abandon_at: this.abandonAt, web3_wallet: this.web3wallet, legal_accepted_at: this.legalAcceptedAt, + locale: this.locale, external_account: this.externalAccount, external_account_strategy: this.externalAccount?.strategy, }; @@ -620,6 +632,10 @@ class SignUpFuture implements SignUpFutureResource { return this.resource.legalAcceptedAt; } + get locale() { + return this.resource.locale; + } + get unverifiedFields() { return this.resource.unverifiedFields; } @@ -677,7 +693,15 @@ class SignUpFuture implements SignUpFutureResource { async create(params: SignUpFutureCreateParams): Promise<{ error: unknown }> { return runAsyncResourceTask(this.resource, async () => { - await this._create(params); + // Inject browser locale if not already provided + const createParams = { ...params }; + if (!createParams.locale) { + const browserLocale = getBrowserLocale(); + if (browserLocale) { + createParams.locale = browserLocale; + } + } + await this._create(createParams); }); } diff --git a/packages/clerk-js/src/test/core-fixtures.ts b/packages/clerk-js/src/test/core-fixtures.ts index f7d1587fc97..4f7b5ad90d8 100644 --- a/packages/clerk-js/src/test/core-fixtures.ts +++ b/packages/clerk-js/src/test/core-fixtures.ts @@ -231,6 +231,8 @@ export const createSignUp = (signUpParams: Partial = {}) => { first_name: signUpParams.first_name, has_password: signUpParams.has_password, last_name: signUpParams.last_name, + legal_accepted_at: signUpParams.legal_accepted_at, + locale: signUpParams.locale, missing_fields: signUpParams.missing_fields, object: 'sign_up', optional_fields: signUpParams.optional_fields, diff --git a/packages/clerk-js/src/utils/__tests__/locale.test.ts b/packages/clerk-js/src/utils/__tests__/locale.test.ts new file mode 100644 index 00000000000..e486aa3f854 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/locale.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getBrowserLocale } from '../locale'; + +describe('getBrowserLocale()', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns the browser locale when available', () => { + vi.stubGlobal('navigator', { language: 'es-ES' }); + + expect(getBrowserLocale()).toBe('es-ES'); + }); + + it('returns null as default when navigator.language is not available', () => { + vi.stubGlobal('navigator', { language: undefined }); + + expect(getBrowserLocale()).toBeNull(); + }); + + it('returns null as default when navigator.language is empty string', () => { + vi.stubGlobal('navigator', { language: '' }); + + expect(getBrowserLocale()).toBeNull(); + }); + + it('returns null as default when navigator object is not defined', () => { + vi.stubGlobal('navigator', undefined); + + expect(getBrowserLocale()).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 3fe9714465a..7f12982f36e 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -23,6 +23,7 @@ export * from './props'; export * from './queryStateParams'; export * from './querystring'; export * from './runtime'; +export * from './locale'; export * from './url'; export * from './web3'; export * from './windowNavigate'; diff --git a/packages/clerk-js/src/utils/locale.ts b/packages/clerk-js/src/utils/locale.ts new file mode 100644 index 00000000000..63be37cd018 --- /dev/null +++ b/packages/clerk-js/src/utils/locale.ts @@ -0,0 +1,25 @@ +import { inBrowser } from '@clerk/shared/browser'; + +const DEFAULT_LOCALE = null; + +/** + * Detects the user's preferred locale from the browser. + * Falls back to null if locale cannot be determined. + * + * @returns The browser's reported locale string (typically BCP 47 format like 'en-US', 'es-ES') or null if locale cannot be determined. + */ +export function getBrowserLocale(): string | null { + if (!inBrowser()) { + return DEFAULT_LOCALE; + } + + // Get locale from the browser + const locale = navigator?.language; + + // Validate that we got a non-empty string + if (!locale || typeof locale !== 'string' || locale.trim() === '') { + return DEFAULT_LOCALE; + } + + return locale; +} diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 995c78dc647..ab65bcf8912 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -194,6 +194,9 @@ export class StateProxy implements State { get legalAcceptedAt() { return gateProperty(target, 'legalAcceptedAt', null); }, + get locale() { + return gateProperty(target, 'locale', null); + }, get status() { return gateProperty(target, 'status', 'missing_requirements'); }, diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 9ab16c1540c..32638694c30 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -136,6 +136,7 @@ export interface SignUpJSON extends ClerkResourceJSON { created_user_id: string | null; abandon_at: number | null; legal_accepted_at: number | null; + locale: string | null; verifications: SignUpVerificationsJSON | null; } diff --git a/packages/types/src/signUp.ts b/packages/types/src/signUp.ts index 63976a78fa1..f6c9e9bd48b 100644 --- a/packages/types/src/signUp.ts +++ b/packages/types/src/signUp.ts @@ -60,6 +60,7 @@ export interface SignUpResource extends ClerkResource { createdUserId: string | null; abandonAt: number | null; legalAcceptedAt: number | null; + locale: string | null; create: (params: SignUpCreateParams) => Promise; diff --git a/packages/types/src/signUpCommon.ts b/packages/types/src/signUpCommon.ts index 573b38d6341..62b81715a2a 100644 --- a/packages/types/src/signUpCommon.ts +++ b/packages/types/src/signUpCommon.ts @@ -100,6 +100,7 @@ export type SignUpCreateParams = Partial< oidcPrompt: string; oidcLoginHint: string; channel: PhoneCodeChannel; + locale?: string; } & Omit>, 'legalAccepted'> >; diff --git a/packages/types/src/signUpFuture.ts b/packages/types/src/signUpFuture.ts index 660d5622f5f..a9c180245b5 100644 --- a/packages/types/src/signUpFuture.ts +++ b/packages/types/src/signUpFuture.ts @@ -8,6 +8,7 @@ interface SignUpFutureAdditionalParams { lastName?: string; unsafeMetadata?: SignUpUnsafeMetadata; legalAccepted?: boolean; + locale?: string; } export interface SignUpFutureCreateParams extends SignUpFutureAdditionalParams { @@ -136,6 +137,8 @@ export interface SignUpFutureResource { readonly legalAcceptedAt: number | null; + readonly locale: string | null; + create: (params: SignUpFutureCreateParams) => Promise<{ error: unknown }>; update: (params: SignUpFutureUpdateParams) => Promise<{ error: unknown }>; diff --git a/playground/app-router/.gitignore b/playground/app-router/.gitignore index 8f322f0d8f4..927a115dba8 100644 --- a/playground/app-router/.gitignore +++ b/playground/app-router/.gitignore @@ -25,7 +25,7 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local +.env* # vercel .vercel @@ -33,3 +33,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# clerk configuration (can include secrets) +/.clerk/ diff --git a/playground/app-router/README.md b/playground/app-router/README.md index cde9c0cde80..293d5ade579 100644 --- a/playground/app-router/README.md +++ b/playground/app-router/README.md @@ -12,11 +12,11 @@ yarn dev pnpm dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:4011](http://localhost:4011) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. -[http://localhost:3000/api/hello](http://localhost:3000/api/hello) is an endpoint that uses [Route Handlers](https://nextjs.org/docs/routing/route-handlers). This endpoint can be edited in `app/api/hello/route.ts`. +[http://localhost:4011/api/hello](http://localhost:4011/api/hello) is an endpoint that uses [Route Handlers](https://nextjs.org/docs/routing/route-handlers). This endpoint can be edited in `app/api/hello/route.ts`. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.