diff --git a/packages/i18n/README.md b/packages/i18n/README.md index e1ae6084a8f..2b29713684e 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -20,7 +20,7 @@ const $fmt = formatter($locale); // reactive Intl wrapper `createI18n($locale, options)` returns a namespace factory. `base` is the source-locale definition; message types are inferred from it. Non-base locales are fetched lazily via `get`; until the data lands, messages fall back to `base` per key. ```ts -import { createI18n, params, count, messageFormat } from '@clerk/i18n'; +import { createI18n, params, count, currency, messageFormat } from '@clerk/i18n'; const i18n = createI18n($locale, { get: locale => fetch(`/locales/${locale}.json`).then(r => r.json()), @@ -30,12 +30,14 @@ const $messages = i18n('cart', { title: 'Your cart', greeting: params('Hi {name}'), // (args: { name: string | number }) => string items: count({ one: '{count} item', other: '{count} items' }), // (n: number) => string + price: currency(), // (amount: number, currencyCode: string, opts?) => string notice: messageFormat('Read the {#a}terms{/a}'), // (handlers?) => string }); const m = $messages.get(); m.greeting({ name: 'Sam' }); // "Hi Sam" m.items(3); // "3 items" +m.price(1000, 'USD'); // "$10.00" m.notice({ a: t => `${t}` }); // "Read the terms" ``` diff --git a/packages/i18n/src/create-i18n/index.test.ts b/packages/i18n/src/create-i18n/index.test.ts index 272085ef86d..c18d46dfc8c 100644 --- a/packages/i18n/src/create-i18n/index.test.ts +++ b/packages/i18n/src/create-i18n/index.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { atom } from '../atom'; import { count } from '../count'; +import { currency } from '../currency'; import { defineLocalization } from '../define-localization'; import { params } from '../params'; import { createI18n } from './index'; @@ -64,6 +65,18 @@ describe('createI18n', () => { expect($msg.get().items(5)).toBe('5 items'); }); + it('formats currency with the active locale', () => { + const $locale = atom('en-US'); + const i18n = createI18n($locale, { get: vi.fn() }); + const $msg = i18n('billing', { $: currency() }); + + expect($msg.get().$(1000, 'USD')).toBe('$10.00'); + expect($msg.get().$(1000, 'USD', { style: 'short' })).toBe('$10'); + + $locale.set('fr-FR'); + expect($msg.get().$(100000, 'USD')).toBe('1 000,00 $US'); + }); + // Snapshot identity must be stable across reads when nothing changed, otherwise // `useSyncExternalStore` (which reads `get()` before subscribing) loops forever. it('returns a referentially stable snapshot until an input changes', () => { diff --git a/packages/i18n/src/create-i18n/index.ts b/packages/i18n/src/create-i18n/index.ts index 89a0b3bda4c..e8d6dd8e995 100644 --- a/packages/i18n/src/create-i18n/index.ts +++ b/packages/i18n/src/create-i18n/index.ts @@ -1,5 +1,6 @@ import { atom, computed, task } from 'nanostores'; +import { createCurrencyFormatter } from '../currency'; import type { AnyMarker, Messages, PluralForms, ReadableStore, ResolvedOverrides } from '../types'; /** The override values for one namespace, keyed by message key. */ @@ -133,6 +134,10 @@ export function createI18n($locale: ReadableStore, options: CreateI18nOp }; } + if (marker?._type === 'currency') { + return createCurrencyFormatter(locale); + } + if (marker?._type === 'count-params') { const rules = new Intl.PluralRules(locale); const forms: PluralForms = @@ -160,7 +165,13 @@ export function createI18n($locale: ReadableStore, options: CreateI18nOp ): Messages { const out: Record = {}; for (const key in base) { - out[key] = buildEntry(locale, base[key], overridesForNamespace?.[key]); + const baseVal = base[key]; + const override = overridesForNamespace?.[key]; + if (typeof baseVal === 'object' && baseVal !== null && !asMarker(baseVal)) { + out[key] = buildMessages(locale, baseVal as Record, override as Overrides | undefined); + } else { + out[key] = buildEntry(locale, baseVal, override); + } } return out as Messages; } diff --git a/packages/i18n/src/currency/index.ts b/packages/i18n/src/currency/index.ts new file mode 100644 index 00000000000..df475c0fecd --- /dev/null +++ b/packages/i18n/src/currency/index.ts @@ -0,0 +1,36 @@ +import type { CurrencyFormatOptions } from '../formatter'; +import type { CurrencyMarker } from '../types'; + +export type CurrencyFormatFn = (amount: number, currencyCode: string, opts?: CurrencyFormatOptions) => string; + +export function createCurrencyFormatter(locale: string): CurrencyFormatFn { + const nf: Record = {}; + + return (amount, currencyCode, opts = {}) => { + try { + const currency = currencyCode !== '' ? currencyCode : 'USD'; + const baseKey = JSON.stringify({ style: 'currency', currency }); + nf[baseKey] ??= new Intl.NumberFormat(locale, { style: 'currency', currency }); + const { maximumFractionDigits } = nf[baseKey].resolvedOptions(); + const major = amount / 10 ** (maximumFractionDigits ?? 2); + + if (opts.style === 'short') { + const shortKey = JSON.stringify({ style: 'currency', currency, trailingZeroDisplay: 'stripIfInteger' }); + nf[shortKey] ??= new Intl.NumberFormat(locale, { + style: 'currency', + currency, + trailingZeroDisplay: 'stripIfInteger', + }); + return nf[shortKey].format(major); + } + + return nf[baseKey].format(major); + } catch { + return `${currencyCode} ${(amount / 100).toFixed(2)}`; + } + }; +} + +export function currency(): CurrencyMarker { + return { _type: 'currency' }; +} diff --git a/packages/i18n/src/formatter/index.ts b/packages/i18n/src/formatter/index.ts index a9af9833b65..071009ce8d7 100644 --- a/packages/i18n/src/formatter/index.ts +++ b/packages/i18n/src/formatter/index.ts @@ -1,5 +1,6 @@ import { computed } from 'nanostores'; +import { createCurrencyFormatter } from '../currency'; import type { ReadableStore } from '../types'; export interface CurrencyFormatOptions { @@ -27,6 +28,7 @@ export function formatter($locale: ReadableStore): ReadableStore = {}; const nf: Record = {}; const rtf: Record = {}; + const currency = createCurrencyFormatter(locale); return { time(date, opts = {}) { @@ -44,29 +46,7 @@ export function formatter($locale: ReadableStore): ReadableStore { plain: 'Hello', greet: params('Hi {name}'), items: count({ one: '{count} item', other: '{count} items' }), + price: currency(), rich: messageFormat('{#b}x{/b}'), }).get(); expectTypeOf(m.plain).toEqualTypeOf(); expectTypeOf(m.greet).parameter(0).toEqualTypeOf<{ name: string | number }>(); expectTypeOf(m.items).toEqualTypeOf<(n: number) => string>(); + expectTypeOf(m.price).toEqualTypeOf(); expectTypeOf(m.rich).toEqualTypeOf(); }); @@ -50,6 +54,12 @@ describe('OverrideValue', () => { }); }); +describe('currency formatting typing', () => { + it('exposes options for higher-level currency helpers', () => { + expectTypeOf().toMatchTypeOf<{ style?: 'short' }>(); + }); +}); + describe('defineLocalization typing', () => { type Reg = { signIn: { title: string; greet: ParamsMarker<'Hi {name}'>; items: CountMarker } }; diff --git a/packages/i18n/src/messages-to-json/index.test.ts b/packages/i18n/src/messages-to-json/index.test.ts index 9b9cd23c34a..143e7f9ec80 100644 --- a/packages/i18n/src/messages-to-json/index.test.ts +++ b/packages/i18n/src/messages-to-json/index.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { atom } from '../atom'; import { count } from '../count'; import { createI18n } from '../create-i18n'; +import { currency } from '../currency'; import { messageFormat } from '../message-format'; import { params } from '../params'; import { messagesToJSON } from './index'; @@ -15,6 +16,7 @@ describe('messagesToJSON', () => { greet: params('Hi {name}'), items: count({ one: '{count} item', other: '{count} items' }), page: params<{ category: string }>(count({ one: 'One in {category}', other: '{count} in {category}' })), + $: currency(), notice: messageFormat('Read the {#a}terms{/a}'), nested: { title: 'T' }, }); diff --git a/packages/i18n/src/messages-to-json/index.ts b/packages/i18n/src/messages-to-json/index.ts index a753cec7d1d..923e65dd053 100644 --- a/packages/i18n/src/messages-to-json/index.ts +++ b/packages/i18n/src/messages-to-json/index.ts @@ -10,16 +10,24 @@ function isMarker(value: unknown): value is AnyMarker { return typeof value === 'object' && value !== null && '_type' in value; } +const SKIP = Symbol('skip'); + /** Serialize a single `base` value back to its raw JSON form. */ -function serialize(value: unknown): unknown { +function serialize(value: unknown) { if (isMarker(value)) { + if (value._type === 'currency') { + return SKIP; + } // params / messageFormat carry a template string; count / count-params carry forms. return value._type === 'params' || value._type === 'transform' ? value.template : value.forms; } if (value && typeof value === 'object') { const out = Object.create(null) as Record; for (const key of Object.keys(value as Record)) { - out[key] = serialize((value as Record)[key]); + const serialized = serialize((value as Record)[key]); + if (serialized !== SKIP) { + out[key] = serialized; + } } return out; } @@ -42,7 +50,10 @@ export function messagesToJSON(...messages: SourceStore[]): Record; for (const key of Object.keys(base)) { - ns[key] = serialize(base[key]); + const serialized = serialize(base[key]); + if (serialized !== SKIP) { + ns[key] = serialized; + } } } return out; diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts index b7e5e17a5b6..51b24ca9032 100644 --- a/packages/i18n/src/types.ts +++ b/packages/i18n/src/types.ts @@ -9,6 +9,8 @@ import type { ReadableAtom, WritableAtom } from 'nanostores'; +import type { CurrencyFormatFn } from './currency'; + // --------------------------------------------------------------------------- // Stores // @@ -52,6 +54,10 @@ export interface CountMarker { readonly forms: PluralForms; } +export interface CurrencyMarker { + readonly _type: 'currency'; +} + /** * A pluralized message that also takes named params: `params(count(...))`. The * resolved message is `(n, args) => string` — `n` selects the plural form, then @@ -71,7 +77,7 @@ export interface TransformMarker { } /** Any message marker. Used for runtime narrowing in `create-i18n`. */ -export type AnyMarker = ParamsMarker | CountMarker | CountParamsMarker | TransformMarker; +export type AnyMarker = ParamsMarker | CountMarker | CurrencyMarker | CountParamsMarker | TransformMarker; // --------------------------------------------------------------------------- // `messageFormat` transform types @@ -120,11 +126,13 @@ export type MessageType = ? (n: number, args: P) => string : V extends CountMarker ? (n: number) => string - : V extends TransformMarker - ? R - : V extends string - ? string - : V; + : V extends CurrencyMarker + ? CurrencyFormatFn + : V extends TransformMarker + ? R + : V extends string + ? string + : V; /** Map a whole `base` definition object to its resolved messages object. */ export type Messages = { [K in keyof B]: MessageType }; @@ -146,13 +154,15 @@ export type OverrideValue = V extends ParamsMarker ? string : V extends CountMarker | CountParamsMarker ? Partial - : V extends TransformMarker - ? string - : V extends string + : V extends CurrencyMarker + ? never + : V extends TransformMarker ? string - : V extends Record - ? { [K in keyof V]?: OverrideValue } - : OverrideInput; + : V extends string + ? string + : V extends Record + ? { [K in keyof V]?: OverrideValue } + : OverrideInput; /** A map of namespace -> `base` definition, used to type overrides precisely. */ export type Registry = Record>; diff --git a/packages/swingset/src/components/StoryEmbed.tsx b/packages/swingset/src/components/StoryEmbed.tsx index 1457332727c..d3e91240d0b 100644 --- a/packages/swingset/src/components/StoryEmbed.tsx +++ b/packages/swingset/src/components/StoryEmbed.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { MosaicProvider } from '@clerk/ui/mosaic/providers/mosaic-provider'; import { Layers2Icon } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx index ac202903489..841e3990fc6 100644 --- a/packages/swingset/src/components/StoryPreview.tsx +++ b/packages/swingset/src/components/StoryPreview.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { MosaicProvider } from '@clerk/ui/mosaic/providers/mosaic-provider'; import { RotateCcwIcon, SlidersHorizontalIcon } from 'lucide-react'; import type React from 'react'; import { useEffect, useState } from 'react'; @@ -61,8 +61,8 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
{mounted && ( diff --git a/packages/ui/package.json b/packages/ui/package.json index b885f3a8ff2..dd48cf45413 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -94,6 +94,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@clerk/i18n": "workspace:^", "@clerk/localizations": "workspace:^", "@clerk/shared": "workspace:^", "@emotion/cache": "11.11.0", diff --git a/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx b/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx index 1e8958c7718..f87fcc147fe 100644 --- a/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx +++ b/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import type { MosaicAppearance } from '../appearance'; import { parseMosaicAppearance, useMosaicAppearance } from '../appearance'; -import { MosaicProvider, useMosaicTheme } from '../MosaicProvider'; +import { MosaicProvider, useMosaicTheme } from '../providers/mosaic-provider'; const appearance: MosaicAppearance = { elements: { @@ -42,7 +42,12 @@ describe('parseMosaicAppearance', () => { describe('MosaicProvider appearance context', () => { it('exposes [global, scoped] layers via useMosaicAppearance', () => { const { result } = renderHook(() => useMosaicAppearance(), { - wrapper: ({ children }) => React.createElement(MosaicProvider, { appearance, scope: 'signIn' }, children), + wrapper: ({ children }) => + React.createElement( + MosaicProvider, + { appearance: { elements: appearance.elements, scope: 'signIn' } }, + children, + ), }); expect(result.current).toEqual([{ button: { color: 'green' } }, { button: { color: 'red' } }]); }); diff --git a/packages/ui/src/mosaic/__tests__/i18n-server.test.ts b/packages/ui/src/mosaic/__tests__/i18n-server.test.ts new file mode 100644 index 00000000000..e2c11aa2e1a --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/i18n-server.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { getLocalization } from '../i18n/server'; + +describe('getLocalization', () => { + it('returns the default locale with no messages when acceptLanguage is null', async () => { + const result = await getLocalization(null); + expect(result).toEqual({ locale: 'en', initialMessages: {} }); + }); + + it('matches the default locale via base tag (en-US → en)', async () => { + const result = await getLocalization('en-US'); + expect(result).toEqual({ locale: 'en', initialMessages: {} }); + }); + + it('picks the highest-priority match (en-US,fr → en, not fr)', async () => { + const result = await getLocalization('en-US,fr;q=0.9'); + expect(result.locale).toBe('en'); + }); + + it('loads french messages when locale is fr', async () => { + const result = await getLocalization('fr'); + expect(result.locale).toBe('fr'); + expect(result.initialMessages).toHaveProperty('fr'); + expect(result.initialMessages.fr).toBeTruthy(); + }); + + it('matches a supported locale via base tag (fr-FR → fr)', async () => { + const result = await getLocalization('fr-FR'); + expect(result.locale).toBe('fr'); + }); + + it('falls back to the default locale for an unsupported language', async () => { + const result = await getLocalization('de'); + expect(result).toEqual({ locale: 'en', initialMessages: {} }); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/localization-provider.test.tsx b/packages/ui/src/mosaic/__tests__/localization-provider.test.tsx new file mode 100644 index 00000000000..07fa72e9840 --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/localization-provider.test.tsx @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import frMessages from '../locales/fr.json'; +import { MosaicProvider } from '../providers/mosaic-provider'; +import { formatBillingAmount, useMessages } from '../providers/localization-provider'; + +const base = { title: 'Organization Profile', tab: { general: 'General', members: 'Members' } }; + +describe('LocalizationProvider overrides', () => { + it('override wins over French translation when both locale and overrides are set', () => { + const { result } = renderHook(() => useMessages('organizationProfile', base), { + wrapper: ({ children }) => + React.createElement( + MosaicProvider, + { + localization: { + locale: 'fr', + initialMessages: { fr: frMessages }, + overrides: { 'organizationProfile.title': 'test' }, + }, + }, + children, + ), + }); + expect(result.current.title).toBe('test'); + // Non-overridden key still resolves from the French bundle + expect((result.current.tab as Record).general).toBe('Général'); + }); +}); + +describe('LocalizationProvider currency formatting', () => { + const amount = { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }; + + it('adapts Billing money amounts to a resolved currency message', () => { + const formatCurrency = vi.fn(() => '$10'); + + expect(formatBillingAmount(formatCurrency, amount, { style: 'short' })).toBe('$10'); + expect(formatCurrency).toHaveBeenCalledWith(1000, 'USD', { style: 'short' }); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts b/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts index f50b76e7046..24b8a5084f5 100644 --- a/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts +++ b/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts @@ -3,13 +3,13 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import type { MosaicAppearance } from '../appearance'; -import { MosaicProvider } from '../MosaicProvider'; +import { MosaicProvider } from '../providers/mosaic-provider'; import { defineSlotRecipe, useRecipe } from '../slot-recipe'; import { slot, useSlot } from '../useSlot'; function wrapper(appearance?: MosaicAppearance, scope?: string) { return function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(MosaicProvider, { appearance, scope }, children); + return React.createElement(MosaicProvider, { appearance: { ...appearance, scope } }, children); }; } diff --git a/packages/ui/src/mosaic/aio/organization-profile.messages.ts b/packages/ui/src/mosaic/aio/organization-profile.messages.ts new file mode 100644 index 00000000000..41796315da5 --- /dev/null +++ b/packages/ui/src/mosaic/aio/organization-profile.messages.ts @@ -0,0 +1,8 @@ +export const orgProfileBase = { + title: 'Organization Profile', + tab: { + general: 'General', + members: 'Members', + }, + membersContent: 'Members content', +}; diff --git a/packages/ui/src/mosaic/aio/organization-profile.tsx b/packages/ui/src/mosaic/aio/organization-profile.tsx index 743da8386b5..43b277a6c03 100644 --- a/packages/ui/src/mosaic/aio/organization-profile.tsx +++ b/packages/ui/src/mosaic/aio/organization-profile.tsx @@ -1,8 +1,11 @@ import { Box } from '../components/box'; import { Tabs } from '../components/tabs'; import { OrganizationProfileGeneral } from '../panels/organization-profile-general'; +import { useMessages } from '../providers/localization-provider'; +import { orgProfileBase } from './organization-profile.messages'; export function OrganizationProfile() { + const m = useMessages('organizationProfile', orgProfileBase); return ( ({ @@ -17,12 +20,12 @@ export function OrganizationProfile() { marginBlockEnd: t.spacing(8), })} > - Organization Profile + {m.title} - General - Members + {m.tab.general} + {m.tab.members} @@ -36,7 +39,7 @@ export function OrganizationProfile() { textAlign: 'center', })} > - Members content + {m.membersContent} diff --git a/packages/ui/src/mosaic/components/__tests__/button.test.tsx b/packages/ui/src/mosaic/components/__tests__/button.test.tsx index beada857bc8..9860e2fa8fc 100644 --- a/packages/ui/src/mosaic/components/__tests__/button.test.tsx +++ b/packages/ui/src/mosaic/components/__tests__/button.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import type { MosaicAppearance } from '../../appearance'; -import { MosaicProvider } from '../../MosaicProvider'; +import { MosaicProvider } from '../../providers/mosaic-provider'; import { Button } from '../button'; /** Concatenates every inserted Emotion `