diff --git a/.changeset/cold-bottles-watch.md b/.changeset/cold-bottles-watch.md new file mode 100644 index 00000000000..a525e856df1 --- /dev/null +++ b/.changeset/cold-bottles-watch.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Propagate locale from ClerkProvider to PaymentElement diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index e68823116c3..75d2a2b28c7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2420,6 +2420,7 @@ export class Clerk implements ClerkInterface { ..._props, options: this.#initOptions({ ...this.#options, ..._props.options }), }; + return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props)); }; diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/commerce.test.tsx new file mode 100644 index 00000000000..29cc56c5ac4 --- /dev/null +++ b/packages/shared/src/react/__tests__/commerce.test.tsx @@ -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 }) => ( +
+ {children} +
+ ), + PaymentElement: ({ fallback }: { fallback?: React.ReactNode }) =>
{fallback}
, + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + }; + + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + + 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( + + <__experimental_PaymentElementProvider checkout={mockCheckout}> + <__experimental_PaymentElement fallback={
Loading...
} /> + +
, + ); + + const elements = screen.getByTestId('stripe-elements'); + expect(elements.getAttribute('data-locale')).toBe(expected); + + unmount(); + }); + }); +}); diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx index 336f7449508..f1d55aaa463 100644 --- a/packages/shared/src/react/commerce.tsx +++ b/packages/shared/src/react/commerce.tsx @@ -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; +}; + const usePaymentSourceUtils = (forResource: ForPayerType = 'user') => { const { organization } = useOrganization(); const { user } = useUser(); @@ -206,6 +223,7 @@ const PaymentElementProvider = ({ children, ...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'], }} > {props.children}