-
Notifications
You must be signed in to change notification settings - Fork 437
feat(clerk-js): Pass locale to Stripe elements #6885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a9a2589
cca53a0
9f50354
a7f50d0
dcdb9d8
1322865
fa215ee
e33b61f
154ed01
b87f162
7917403
0333fce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@clerk/clerk-js': patch | ||
| '@clerk/shared': patch | ||
| --- | ||
|
|
||
| Propagate locale from ClerkProvider to PaymentElement |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import React from 'react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../commerce'; | ||
| import { OptionsContext } from '../contexts'; | ||
|
|
||
| // Mock the Stripe components | ||
| vi.mock('../stripe-react', () => ({ | ||
| Elements: ({ children, options }: { children: React.ReactNode; options: any }) => ( | ||
| <div | ||
| data-testid='stripe-elements' | ||
| data-locale={options.locale} | ||
| > | ||
| {children} | ||
| </div> | ||
| ), | ||
| PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) => <div>{fallback}</div>, | ||
| useElements: () => null, | ||
| useStripe: () => null, | ||
| })); | ||
|
|
||
| // Mock the hooks | ||
| const mockGetOption = vi.fn(); | ||
| vi.mock('../hooks/useClerk', () => ({ | ||
| useClerk: () => ({ | ||
| __internal_loadStripeJs: vi.fn().mockResolvedValue(() => Promise.resolve({})), | ||
| __internal_getOption: mockGetOption, | ||
| __unstable__environment: { | ||
| commerceSettings: { | ||
| billing: { | ||
| stripePublishableKey: 'pk_test_123', | ||
| }, | ||
| }, | ||
| displayConfig: { | ||
| userProfileUrl: 'https://example.com/profile', | ||
| organizationProfileUrl: 'https://example.com/org-profile', | ||
| }, | ||
| }, | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('../hooks/useUser', () => ({ | ||
| useUser: () => ({ | ||
| user: { | ||
| id: 'user_123', | ||
| initializePaymentSource: vi.fn().mockResolvedValue({ | ||
| externalGatewayId: 'acct_123', | ||
| externalClientSecret: 'seti_123', | ||
| paymentMethodOrder: ['card'], | ||
| }), | ||
| }, | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('../hooks/useOrganization', () => ({ | ||
| useOrganization: () => ({ | ||
| organization: null, | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('swr', () => ({ | ||
| __esModule: true, | ||
| default: () => ({ data: { loadStripe: vi.fn().mockResolvedValue({}) } }), | ||
| })); | ||
|
|
||
| vi.mock('swr/mutation', () => ({ | ||
| __esModule: true, | ||
| default: () => ({ | ||
| data: { | ||
| externalGatewayId: 'acct_123', | ||
| externalClientSecret: 'seti_123', | ||
| paymentMethodOrder: ['card'], | ||
| }, | ||
| trigger: vi.fn().mockResolvedValue({ | ||
| externalGatewayId: 'acct_123', | ||
| externalClientSecret: 'seti_123', | ||
| paymentMethodOrder: ['card'], | ||
| }), | ||
| }), | ||
| })); | ||
|
|
||
| describe('PaymentElement Localization', () => { | ||
| const mockCheckout = { | ||
| id: 'checkout_123', | ||
| needsPaymentMethod: true, | ||
| plan: { | ||
| id: 'plan_123', | ||
| name: 'Test Plan', | ||
| description: 'Test plan description', | ||
| fee: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| annualFee: { amount: 10000, amountFormatted: '$100.00', currency: 'usd', currencySymbol: '$' }, | ||
| annualMonthlyFee: { amount: 833, amountFormatted: '$8.33', currency: 'usd', currencySymbol: '$' }, | ||
| currency: 'usd', | ||
| interval: 'month' as const, | ||
| intervalCount: 1, | ||
| maxAllowedInstances: 1, | ||
| trialDays: 0, | ||
| isAddon: false, | ||
| isPopular: false, | ||
| isPerSeat: false, | ||
| isUsageBased: false, | ||
| isFree: false, | ||
| isLegacy: false, | ||
| isDefault: false, | ||
| isRecurring: true, | ||
| hasBaseFee: true, | ||
| forPayerType: 'user' as const, | ||
| publiclyVisible: true, | ||
| slug: 'test-plan', | ||
| avatarUrl: '', | ||
| freeTrialDays: 0, | ||
| freeTrialEnabled: false, | ||
| pathRoot: '/', | ||
| reload: vi.fn(), | ||
| features: [], | ||
| limits: {}, | ||
| metadata: {}, | ||
| }, | ||
| totals: { | ||
| subtotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, | ||
| totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, | ||
| credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, | ||
| pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, | ||
| }, | ||
| status: 'needs_confirmation' as const, | ||
| error: null, | ||
| fetchStatus: 'idle' as const, | ||
| confirm: vi.fn(), | ||
| start: vi.fn(), | ||
| clear: vi.fn(), | ||
| finalize: vi.fn(), | ||
| getState: vi.fn(), | ||
| isConfirming: false, | ||
| isStarting: false, | ||
| planPeriod: 'month' as const, | ||
| externalClientSecret: 'seti_123', | ||
| externalGatewayId: 'acct_123', | ||
| isImmediatePlanChange: false, | ||
| paymentMethodOrder: ['card'], | ||
| freeTrialEndsAt: null, | ||
| payer: { | ||
| id: 'payer_123', | ||
| createdAt: new Date('2023-01-01'), | ||
| updatedAt: new Date('2023-01-01'), | ||
| imageUrl: null, | ||
| userId: 'user_123', | ||
| email: 'test@example.com', | ||
| firstName: 'Test', | ||
| lastName: 'User', | ||
| organizationId: undefined, | ||
| organizationName: undefined, | ||
| pathRoot: '/', | ||
| reload: vi.fn(), | ||
| }, | ||
| }; | ||
|
|
||
| const renderWithLocale = (locale: string) => { | ||
| // Mock the __internal_getOption to return the expected localization | ||
| mockGetOption.mockImplementation(key => { | ||
| if (key === 'localization') { | ||
| return { locale }; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| const options = { | ||
| localization: { locale }, | ||
| }; | ||
|
|
||
| return render( | ||
| <OptionsContext.Provider value={options}> | ||
| <__experimental_PaymentElementProvider checkout={mockCheckout}> | ||
| <__experimental_PaymentElement fallback={<div>Loading...</div>} /> | ||
| </__experimental_PaymentElementProvider> | ||
| </OptionsContext.Provider>, | ||
| ); | ||
| }; | ||
|
|
||
| it('should pass the correct locale to Stripe Elements', () => { | ||
| renderWithLocale('es'); | ||
|
|
||
| const elements = screen.getByTestId('stripe-elements'); | ||
| expect(elements.getAttribute('data-locale')).toBe('es'); | ||
| }); | ||
|
|
||
| it('should default to "en" when no locale is provided', () => { | ||
| // Mock the __internal_getOption to return undefined for localization | ||
| mockGetOption.mockImplementation(key => { | ||
| if (key === 'localization') { | ||
| return undefined; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| const options = {}; | ||
|
|
||
| render( | ||
| <OptionsContext.Provider value={options}> | ||
| <__experimental_PaymentElementProvider checkout={mockCheckout}> | ||
| <__experimental_PaymentElement fallback={<div>Loading...</div>} /> | ||
| </__experimental_PaymentElementProvider> | ||
| </OptionsContext.Provider>, | ||
| ); | ||
|
|
||
| const elements = screen.getByTestId('stripe-elements'); | ||
| expect(elements.getAttribute('data-locale')).toBe('en'); | ||
| }); | ||
|
|
||
| it('should normalize full locale strings to 2-letter codes for Stripe', () => { | ||
| const testCases = [ | ||
| { input: 'en', expected: 'en' }, | ||
| { input: 'en-US', expected: 'en' }, | ||
| { input: 'fr-FR', expected: 'fr' }, | ||
| { input: 'es-ES', expected: 'es' }, | ||
| { input: 'de-DE', expected: 'de' }, | ||
| { input: 'it-IT', expected: 'it' }, | ||
| { input: 'pt-BR', expected: 'pt' }, | ||
| ]; | ||
|
|
||
| testCases.forEach(({ input, expected }) => { | ||
| // Mock the __internal_getOption to return the expected localization | ||
| mockGetOption.mockImplementation(key => { | ||
| if (key === 'localization') { | ||
| return { locale: input }; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| const options = { | ||
| localization: { locale: input }, | ||
| }; | ||
|
|
||
| const { unmount } = render( | ||
| <OptionsContext.Provider value={options}> | ||
| <__experimental_PaymentElementProvider checkout={mockCheckout}> | ||
| <__experimental_PaymentElement fallback={<div>Loading...</div>} /> | ||
| </__experimental_PaymentElementProvider> | ||
| </OptionsContext.Provider>, | ||
| ); | ||
|
|
||
| const elements = screen.getByTestId('stripe-elements'); | ||
| expect(elements.getAttribute('data-locale')).toBe(expected); | ||
|
|
||
| unmount(); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| /* eslint-disable @typescript-eslint/consistent-type-imports */ | ||
| import type { Stripe, StripeElements } from '@stripe/stripe-js'; | ||
| import type { Stripe, StripeElements, StripeElementsOptions } from '@stripe/stripe-js'; | ||
| import React, { type PropsWithChildren, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; | ||
| import useSWR from 'swr'; | ||
| import useSWRMutation from 'swr/mutation'; | ||
|
|
@@ -62,6 +62,23 @@ const useInternalEnvironment = () => { | |
| return clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; | ||
| }; | ||
|
|
||
| const useLocalization = () => { | ||
| const clerk = useClerk(); | ||
|
|
||
| let locale = 'en'; | ||
| try { | ||
| const localization = clerk.__internal_getOption('localization'); | ||
| locale = localization?.locale || 'en'; | ||
| } catch { | ||
| // ignore errors | ||
| } | ||
|
|
||
| // Normalize locale to 2-letter language code for Stripe compatibility | ||
| const normalizedLocale = locale.split('-')[0]; | ||
|
|
||
| return normalizedLocale; | ||
| }; | ||
|
Comment on lines
+65
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainNormalization drops Stripe-supported regional locales, degrading translation quality. The current implementation unconditionally normalizes all locales to 2-letter language codes (line 77), but Stripe Elements supports specific regional variants that provide better translations:
Collapsing Additionally, line 72-74 silently swallows all errors without logging, making debugging difficult. Based on learnings from the past review comment, update the normalization logic: const useLocalization = () => {
const clerk = useClerk();
let locale = 'en';
try {
const localization = clerk.__internal_getOption('localization');
locale = localization?.locale || 'en';
- } catch {
+ } catch (error) {
// ignore errors
+ console.warn('Failed to retrieve localization option:', error);
}
- // Normalize locale to 2-letter language code for Stripe compatibility
- const normalizedLocale = locale.split('-')[0];
+ // Stripe supports specific regional/script variants; preserve them if valid
+ const stripeLocales = new Set([
+ 'auto', 'ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'en-GB',
+ 'es', 'es-419', 'et', 'fi', 'fil', 'fr', 'fr-CA', 'he', 'hr',
+ 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'ms', 'mt', 'nb',
+ 'nl', 'no', 'pl', 'pt', 'pt-BR', 'ro', 'ru', 'sk', 'sl', 'sv',
+ 'th', 'tr', 'vi', 'zh', 'zh-Hans', 'zh-Hant', 'zh-Hant-HK', 'zh-Hant-TW'
+ ]);
+
+ // Validate locale is a string before processing
+ if (typeof locale !== 'string') {
+ return 'en';
+ }
+
+ // Return as-is if supported; otherwise try base language; finally default to 'en'
+ if (stripeLocales.has(locale)) {
+ return locale;
+ }
+
+ const baseLanguage = locale.split('-')[0];
+ return stripeLocales.has(baseLanguage) ? baseLanguage : 'en';
-
- return normalizedLocale;
};Verify the current Stripe-supported locale list: 🌐 Web query: 💡 Result: Here are the locales Stripe Elements supports (locale codes) as listed in Stripe’s docs (2025): bg-BG, zh-Hans, zh-Hant-HK, zh-Hant-TW, hr-HR, cs-CZ, da-DK, nl-NL, en-AU, en-IN, en-IE, en-NZ, en-SG, en-GB, en-US, et-EE, fil-PH, fi-FI, fr-CA, fr-FR, de-DE, el-GR, hu-HU, id-ID, it-IT, ja-JP, ko-KR, lv-LV, lt-LT, ms-MY, mt-MT, nb-NO, pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, es-AR, es-BR, es-419, es-MX, es-ES, sv-SE, th-TH, tr-TR, vi-VN. [1][2] Sources:
Corrected Stripe-supported locale list required; proposed solution includes unsupported locales. The review comment correctly identifies that the current normalization degrades UX by collapsing regional locales, but the proposed fix contains inaccuracies: The actual Stripe Elements 2025 supported locales use full locale codes with regions: bg-BG, zh-Hans, zh-Hant-HK, zh-Hant-TW, hr-HR, cs-CZ, da-DK, nl-NL, en-AU, en-IN, en-IE, en-NZ, en-SG, en-GB, en-US, et-EE, fil-PH, fi-FI, fr-CA, fr-FR, de-DE, el-GR, hu-HU, id-ID, it-IT, ja-JP, ko-KR, lv-LV, lt-LT, ms-MY, mt-MT, nb-NO, pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, es-AR, es-BR, es-419, es-MX, es-ES, sv-SE, th-TH, tr-TR, vi-VN. The proposed solution includes unsupported locales ('auto', 'ar', 'he', 'no', 'ru') and base language codes ('en', 'es', 'de') that Stripe doesn't accept as fallbacks. The corrected diff should use only validated Stripe locales and improve the fallback strategy. |
||
|
|
||
| const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { | ||
| const { organization } = useOrganization(); | ||
| const { user } = useUser(); | ||
|
|
@@ -206,6 +223,7 @@ const PaymentElementProvider = ({ children, ...props }: PropsWithChildren<Paymen | |
|
|
||
| const PaymentElementInternalRoot = (props: PropsWithChildren) => { | ||
| const { stripe, externalClientSecret, stripeAppearance } = usePaymentElementContext(); | ||
| const locale = useLocalization(); | ||
|
|
||
| if (stripe && externalClientSecret) { | ||
| return ( | ||
|
|
@@ -219,6 +237,7 @@ const PaymentElementInternalRoot = (props: PropsWithChildren) => { | |
| appearance: { | ||
| variables: stripeAppearance, | ||
| }, | ||
| locale: locale as StripeElementsOptions['locale'], | ||
| }} | ||
| > | ||
| <ValidateStripeUtils>{props.children}</ValidateStripeUtils> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
Normalization may drop supported regional locales (risk of worse UX)
The test enforces unconditional 2-letter normalization (e.g.,
pt-BR→pt). Stripe Elements accepts several region codes (e.g.,pt-BR,fr-CA,en-GB,es-419,zh-TW). Collapsing these to base language can degrade translations.Recommendation:
en.Update tests to reflect preservation where applicable.
To confirm Stripe’s current supported locales list:
🌐 Web query:
💡 Result:
Short answer: Yes — Stripe Elements accepts specific locale codes (including region variants). The Stripe docs list the supported locales and include en-GB, fr-CA, es-419 and pt-BR. For Chinese they use region/script variants (zh-Hans for Simplified, zh-Hant-TW for Traditional Taiwan, zh-Hant-HK for Hong Kong). If you pass a locale not directly supported Stripe will pick a reasonable fallback (e.g., fr-BE → fr-FR). [1][2]
Sources:
Preserve Stripe-supported locale variants in normalization/tests
The current logic collapses every locale (e.g.,
pt-BR,en-GB) to its two-letter base, but Stripe Elements accepts region/script variants (en-GB, fr-CA, es-419, pt-BR, zh-Hans, zh-Hant-TW, zh-Hant-HK) [1][2]. Update the normalization (and corresponding tests) to:enas a last resortExample test update:
📝 Committable suggestion
🤖 Prompt for AI Agents