Skip to content

Commit 3e0ef92

Browse files
authored
feat(clerk-js): Pass locale to Stripe elements (#6885)
1 parent fae192f commit 3e0ef92

File tree

4 files changed

+277
-1
lines changed

4 files changed

+277
-1
lines changed

.changeset/cold-bottles-watch.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
---
5+
6+
Propagate locale from ClerkProvider to PaymentElement

packages/clerk-js/src/core/clerk.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2420,6 +2420,7 @@ export class Clerk implements ClerkInterface {
24202420
..._props,
24212421
options: this.#initOptions({ ...this.#options, ..._props.options }),
24222422
};
2423+
24232424
return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props));
24242425
};
24252426

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { render, screen } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { __experimental_PaymentElement, __experimental_PaymentElementProvider } from '../commerce';
6+
import { OptionsContext } from '../contexts';
7+
8+
// Mock the Stripe components
9+
vi.mock('../stripe-react', () => ({
10+
Elements: ({ children, options }: { children: React.ReactNode; options: any }) => (
11+
<div
12+
data-testid='stripe-elements'
13+
data-locale={options.locale}
14+
>
15+
{children}
16+
</div>
17+
),
18+
PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) => <div>{fallback}</div>,
19+
useElements: () => null,
20+
useStripe: () => null,
21+
}));
22+
23+
// Mock the hooks
24+
const mockGetOption = vi.fn();
25+
vi.mock('../hooks/useClerk', () => ({
26+
useClerk: () => ({
27+
__internal_loadStripeJs: vi.fn().mockResolvedValue(() => Promise.resolve({})),
28+
__internal_getOption: mockGetOption,
29+
__unstable__environment: {
30+
commerceSettings: {
31+
billing: {
32+
stripePublishableKey: 'pk_test_123',
33+
},
34+
},
35+
displayConfig: {
36+
userProfileUrl: 'https://example.com/profile',
37+
organizationProfileUrl: 'https://example.com/org-profile',
38+
},
39+
},
40+
}),
41+
}));
42+
43+
vi.mock('../hooks/useUser', () => ({
44+
useUser: () => ({
45+
user: {
46+
id: 'user_123',
47+
initializePaymentSource: vi.fn().mockResolvedValue({
48+
externalGatewayId: 'acct_123',
49+
externalClientSecret: 'seti_123',
50+
paymentMethodOrder: ['card'],
51+
}),
52+
},
53+
}),
54+
}));
55+
56+
vi.mock('../hooks/useOrganization', () => ({
57+
useOrganization: () => ({
58+
organization: null,
59+
}),
60+
}));
61+
62+
vi.mock('swr', () => ({
63+
__esModule: true,
64+
default: () => ({ data: { loadStripe: vi.fn().mockResolvedValue({}) } }),
65+
}));
66+
67+
vi.mock('swr/mutation', () => ({
68+
__esModule: true,
69+
default: () => ({
70+
data: {
71+
externalGatewayId: 'acct_123',
72+
externalClientSecret: 'seti_123',
73+
paymentMethodOrder: ['card'],
74+
},
75+
trigger: vi.fn().mockResolvedValue({
76+
externalGatewayId: 'acct_123',
77+
externalClientSecret: 'seti_123',
78+
paymentMethodOrder: ['card'],
79+
}),
80+
}),
81+
}));
82+
83+
describe('PaymentElement Localization', () => {
84+
const mockCheckout = {
85+
id: 'checkout_123',
86+
needsPaymentMethod: true,
87+
plan: {
88+
id: 'plan_123',
89+
name: 'Test Plan',
90+
description: 'Test plan description',
91+
fee: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
92+
annualFee: { amount: 10000, amountFormatted: '$100.00', currency: 'usd', currencySymbol: '$' },
93+
annualMonthlyFee: { amount: 833, amountFormatted: '$8.33', currency: 'usd', currencySymbol: '$' },
94+
currency: 'usd',
95+
interval: 'month' as const,
96+
intervalCount: 1,
97+
maxAllowedInstances: 1,
98+
trialDays: 0,
99+
isAddon: false,
100+
isPopular: false,
101+
isPerSeat: false,
102+
isUsageBased: false,
103+
isFree: false,
104+
isLegacy: false,
105+
isDefault: false,
106+
isRecurring: true,
107+
hasBaseFee: true,
108+
forPayerType: 'user' as const,
109+
publiclyVisible: true,
110+
slug: 'test-plan',
111+
avatarUrl: '',
112+
freeTrialDays: 0,
113+
freeTrialEnabled: false,
114+
pathRoot: '/',
115+
reload: vi.fn(),
116+
features: [],
117+
limits: {},
118+
metadata: {},
119+
},
120+
totals: {
121+
subtotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
122+
grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
123+
taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
124+
totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' },
125+
credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
126+
pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' },
127+
},
128+
status: 'needs_confirmation' as const,
129+
error: null,
130+
fetchStatus: 'idle' as const,
131+
confirm: vi.fn(),
132+
start: vi.fn(),
133+
clear: vi.fn(),
134+
finalize: vi.fn(),
135+
getState: vi.fn(),
136+
isConfirming: false,
137+
isStarting: false,
138+
planPeriod: 'month' as const,
139+
externalClientSecret: 'seti_123',
140+
externalGatewayId: 'acct_123',
141+
isImmediatePlanChange: false,
142+
paymentMethodOrder: ['card'],
143+
freeTrialEndsAt: null,
144+
payer: {
145+
id: 'payer_123',
146+
createdAt: new Date('2023-01-01'),
147+
updatedAt: new Date('2023-01-01'),
148+
imageUrl: null,
149+
userId: 'user_123',
150+
email: 'test@example.com',
151+
firstName: 'Test',
152+
lastName: 'User',
153+
organizationId: undefined,
154+
organizationName: undefined,
155+
pathRoot: '/',
156+
reload: vi.fn(),
157+
},
158+
};
159+
160+
const renderWithLocale = (locale: string) => {
161+
// Mock the __internal_getOption to return the expected localization
162+
mockGetOption.mockImplementation(key => {
163+
if (key === 'localization') {
164+
return { locale };
165+
}
166+
return undefined;
167+
});
168+
169+
const options = {
170+
localization: { locale },
171+
};
172+
173+
return render(
174+
<OptionsContext.Provider value={options}>
175+
<__experimental_PaymentElementProvider checkout={mockCheckout}>
176+
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
177+
</__experimental_PaymentElementProvider>
178+
</OptionsContext.Provider>,
179+
);
180+
};
181+
182+
it('should pass the correct locale to Stripe Elements', () => {
183+
renderWithLocale('es');
184+
185+
const elements = screen.getByTestId('stripe-elements');
186+
expect(elements.getAttribute('data-locale')).toBe('es');
187+
});
188+
189+
it('should default to "en" when no locale is provided', () => {
190+
// Mock the __internal_getOption to return undefined for localization
191+
mockGetOption.mockImplementation(key => {
192+
if (key === 'localization') {
193+
return undefined;
194+
}
195+
return undefined;
196+
});
197+
198+
const options = {};
199+
200+
render(
201+
<OptionsContext.Provider value={options}>
202+
<__experimental_PaymentElementProvider checkout={mockCheckout}>
203+
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
204+
</__experimental_PaymentElementProvider>
205+
</OptionsContext.Provider>,
206+
);
207+
208+
const elements = screen.getByTestId('stripe-elements');
209+
expect(elements.getAttribute('data-locale')).toBe('en');
210+
});
211+
212+
it('should normalize full locale strings to 2-letter codes for Stripe', () => {
213+
const testCases = [
214+
{ input: 'en', expected: 'en' },
215+
{ input: 'en-US', expected: 'en' },
216+
{ input: 'fr-FR', expected: 'fr' },
217+
{ input: 'es-ES', expected: 'es' },
218+
{ input: 'de-DE', expected: 'de' },
219+
{ input: 'it-IT', expected: 'it' },
220+
{ input: 'pt-BR', expected: 'pt' },
221+
];
222+
223+
testCases.forEach(({ input, expected }) => {
224+
// Mock the __internal_getOption to return the expected localization
225+
mockGetOption.mockImplementation(key => {
226+
if (key === 'localization') {
227+
return { locale: input };
228+
}
229+
return undefined;
230+
});
231+
232+
const options = {
233+
localization: { locale: input },
234+
};
235+
236+
const { unmount } = render(
237+
<OptionsContext.Provider value={options}>
238+
<__experimental_PaymentElementProvider checkout={mockCheckout}>
239+
<__experimental_PaymentElement fallback={<div>Loading...</div>} />
240+
</__experimental_PaymentElementProvider>
241+
</OptionsContext.Provider>,
242+
);
243+
244+
const elements = screen.getByTestId('stripe-elements');
245+
expect(elements.getAttribute('data-locale')).toBe(expected);
246+
247+
unmount();
248+
});
249+
});
250+
});

packages/shared/src/react/commerce.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/consistent-type-imports */
2-
import type { Stripe, StripeElements } from '@stripe/stripe-js';
2+
import type { Stripe, StripeElements, StripeElementsOptions } from '@stripe/stripe-js';
33
import React, { type PropsWithChildren, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
44
import useSWR from 'swr';
55
import useSWRMutation from 'swr/mutation';
@@ -62,6 +62,23 @@ const useInternalEnvironment = () => {
6262
return clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined;
6363
};
6464

65+
const useLocalization = () => {
66+
const clerk = useClerk();
67+
68+
let locale = 'en';
69+
try {
70+
const localization = clerk.__internal_getOption('localization');
71+
locale = localization?.locale || 'en';
72+
} catch {
73+
// ignore errors
74+
}
75+
76+
// Normalize locale to 2-letter language code for Stripe compatibility
77+
const normalizedLocale = locale.split('-')[0];
78+
79+
return normalizedLocale;
80+
};
81+
6582
const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => {
6683
const { organization } = useOrganization();
6784
const { user } = useUser();
@@ -206,6 +223,7 @@ const PaymentElementProvider = ({ children, ...props }: PropsWithChildren<Paymen
206223

207224
const PaymentElementInternalRoot = (props: PropsWithChildren) => {
208225
const { stripe, externalClientSecret, stripeAppearance } = usePaymentElementContext();
226+
const locale = useLocalization();
209227

210228
if (stripe && externalClientSecret) {
211229
return (
@@ -219,6 +237,7 @@ const PaymentElementInternalRoot = (props: PropsWithChildren) => {
219237
appearance: {
220238
variables: stripeAppearance,
221239
},
240+
locale: locale as StripeElementsOptions['locale'],
222241
}}
223242
>
224243
<ValidateStripeUtils>{props.children}</ValidateStripeUtils>

0 commit comments

Comments
 (0)