diff --git a/__tests__/unit/cat/nudge-language-ignores-currency.test.ts b/__tests__/unit/cat/nudge-language-ignores-currency.test.ts new file mode 100644 index 000000000..03a6bd5ca --- /dev/null +++ b/__tests__/unit/cat/nudge-language-ignores-currency.test.ts @@ -0,0 +1,40 @@ +/** + * Nudge language used to be inferred from currency: no `profile.language` plus + * a CHF preference meant German. CHF is this platform's DEFAULT fiat, so that + * describes every account that never chose anything — and the founder, whose + * interface is English, opened his dashboard to a column of German nudge cards + * ("Lass „bitcoin" für dich arbeiten", "Biete „Handgemachte Keramiktasse" als + * Produkt an"). + * + * A default is not a preference, and a currency is not a language — Switzerland + * has four national languages. This is the same category error `@/utils/locale` + * was written to end one layer down. + */ + +import { resolveNudgeLanguage, NUDGE_COPY } from '@/services/cat/nudge-copy'; + +describe('nudge language', () => { + it('does not read a language out of the currency', () => { + for (const currency of ['CHF', 'EUR', 'USD', 'chf', null, undefined]) { + expect(resolveNudgeLanguage({ language: null, currency })).toBe('en'); + } + }); + + it('defaults to the language the interface actually speaks', () => { + expect(resolveNudgeLanguage(null)).toBe('en'); + expect(resolveNudgeLanguage(undefined)).toBe('en'); + expect(resolveNudgeLanguage({})).toBe('en'); + expect(resolveNudgeLanguage({ language: '', currency: 'CHF' })).toBe('en'); + }); + + it('still honours an explicit choice, whatever the currency says', () => { + expect(resolveNudgeLanguage({ language: 'de', currency: 'USD' })).toBe('de'); + expect(resolveNudgeLanguage({ language: 'de-CH', currency: 'USD' })).toBe('de'); + expect(resolveNudgeLanguage({ language: 'en', currency: 'CHF' })).toBe('en'); + }); + + it('keeps a copy table for every language it can return', () => { + expect(Object.keys(NUDGE_COPY).sort()).toEqual(['de', 'en']); + expect(NUDGE_COPY.en.languageName).toBe('English'); + }); +}); diff --git a/src/services/cat/nudge-copy.ts b/src/services/cat/nudge-copy.ts index 9523e0b4a..173d15139 100644 --- a/src/services/cat/nudge-copy.ts +++ b/src/services/cat/nudge-copy.ts @@ -2,9 +2,9 @@ * Nudge copy SSOT — every user-facing nudge string, per language. * * One language per user: the language is resolved ONCE from the user's profile - * (profile.language, falling back to a preferred-currency heuristic) and every - * nudge — deterministic templates AND LLM-written reasons — uses it. Mixed - * English/German cards on one dashboard were a founder-verified trust bug. + * (profile.language, and nothing else) and every nudge — deterministic + * templates AND LLM-written reasons — uses it. Mixed English/German cards on + * one dashboard were a founder-verified trust bug. */ import { ENTITY_REGISTRY, type EntityType } from '@/config/entity-registry'; @@ -13,23 +13,35 @@ export type NudgeLanguage = 'en' | 'de'; /** * Resolve the ONE language all nudges for this user are written in. - * profile.language is authoritative; with no explicit language, an explicit - * CHF currency preference (Swiss-focused platform) implies German. Default English. + * + * `profile.language` — an explicit choice — is the only input. Currency is NOT + * a language signal, and reading it as one is why a founder with an English + * interface got a dashboard of German nudge cards: CHF is this platform's + * DEFAULT fiat (see config/currencies), so "prefers CHF" is what every account + * that never chose anything looks like. A default is not a preference. And + * Switzerland has four national languages, so even a deliberate CHF would not + * imply German. + * + * It is the same category error `@/utils/locale` was written to end one layer + * down — there it was "the language of the interface is not the language of + * your operating system"; here it is "…is not the currency you get paid in". + * + * Note the practical consequence: no surface in this app writes + * `profile.language` (the sole write is a mapper default of 'en'), and the + * interface ships `` with no translations. So 'de' is + * currently unreachable, and NUDGE_COPY.de waits for real i18n rather than + * being selected by a heuristic nobody opted into. That is deliberate — German + * nudge cards inside an English interface are the mixed-language dashboard + * this file's header calls a trust bug, just at a larger grain. */ export function resolveNudgeLanguage( + // `currency` stays in the accepted shape because callers pass a whole + // profile — and because a regression test has to be able to hand this + // function the exact CHF-shaped profile that used to come back German. profile: { language?: string | null; currency?: string | null } | null | undefined ): NudgeLanguage { const lang = (profile?.language ?? '').toLowerCase(); - if (lang.startsWith('de')) { - return 'de'; - } - if (lang.startsWith('en')) { - return 'en'; - } - if ((profile?.currency ?? '').toUpperCase() === 'CHF') { - return 'de'; - } - return 'en'; + return lang.startsWith('de') ? 'de' : 'en'; } interface CopyBlock {