diff --git a/.claude/rules/frontend/build-system.md b/.claude/rules/frontend/build-system.md new file mode 100644 index 0000000000..ed0b1238f4 --- /dev/null +++ b/.claude/rules/frontend/build-system.md @@ -0,0 +1,86 @@ +--- +paths: application/shared-webapp/*/rslib.config.ts,application/*/WebApp/rsbuild.config.ts,application/*/BackOffice/rsbuild.config.ts,application/shared-webapp/build/plugin/*.ts,application/package.json +description: How the frontend build system is wired (rslib libraries, rsbuild apps, module federation) and which build-tool upgrades are intentionally deferred +--- + +# Frontend Build System + +The shared libraries under `application/shared-webapp/` (`@repo/ui`, `@repo/infrastructure`, `@repo/utils`, +`@repo/build`) are built with **rslib** (ESM, bundleless) and consumed from their `dist` output. The three +SPAs (`main/WebApp`, `account/WebApp`, `account/BackOffice`) are built with **rsbuild**, and **module +federation lives only in the WebApp rsbuild builds** — rslib builds plain libraries, it does not produce +federation remotes. Understand these constraints before changing any `rslib.config.ts`, an app's +`rsbuild.config.ts`, or the toolchain versions in `application/package.json`. + +## Implementation + +1. **Build shared libraries with rslib, consume from `dist`.** Each library has an `rslib.config.ts` + (`bundle: false`, `format: "esm"`, `dts: true`). Their `package.json` `exports` map subpaths into + `dist`; there is no `source` export condition and the WebApps do not use `pluginSourceBuild`. Turbo's + `^build` ordering builds libraries before the apps. + +2. **Run the Lingui macro transform inside any rslib library that uses macros.** Because a library is + pre-built (not transformed by the consuming app), its `rslib.config.ts` must include `LinguiPlugin()` + (which injects `@lingui/swc-plugin`) in `plugins`, or `` / `` t` ` `` macros ship untransformed + and throw `Trans is not defined` / `t is not defined` at runtime. Today only `@repo/ui` uses macros, so + only its config needs it — add `LinguiPlugin()` to any other library that introduces Lingui macros. + `@lingui/swc-plugin` runs against rspack's embedded SWC (now in both the WebApp rsbuild builds and this + rslib build), and the SWC plugin API is not semver-stable — it's pinned to an exact version, so re-check + compatibility (plugins.swc.rs) whenever `@rspack/binding` or `@lingui/swc-plugin` is bumped, or the build + fails with `LayoutError` / "failed to run Wasm plugin transform". + +3. **Give rslib the asset extension for images imported by library components.** Raster/vector assets a + library component imports (e.g. `@repo/ui` `Logo.tsx` importing `../images/*.png`) must have their + extension in the library's rslib `source.entry.index` glob (`./**/*.png`, `./**/*.webp`, `./**/*.svg`) + so rslib emits an asset module and rewrites the import correctly. Package-path imports from apps + (`@repo/ui/images/x.webp`) resolve through the `./*.webp` / `./*.png` `exports`; rslib emits the raw + assets there unhashed via `output.distPath.image: "images"` + `output.filename.image: "[name][ext]"` (no + hand-rolled copy step). Node-side build code must be ESM-safe (`import.meta.dirname`, + not `__dirname`; read JSON with `fs`, not `require()`). + +4. **Keep `react`, `react-dom`, `@lingui/core`, `@lingui/react` as module-federation `shared` singletons** + (`application/shared-webapp/build/plugin/ModuleFederationPlugin.ts`). The translation system depends on a + single shared Lingui `i18n` across remotes — see [translations](/.claude/rules/frontend/translations.md). + +5. **TypeScript 7 is intentionally deferred — stay on the 6.x line.** TS 7's native compiler removed the + classic programmatic API (its `package.json` exposes no main export, only `./unstable/*`), so + `openapi-typescript` (the `swagger` codegen) and rslib's `dts` generator both fail to load it, and it + cannot be the root `typescript`. Only the `tsc` CLI works. Revisit when those tools support the native + compiler (≈ TS 7.1). If a TS 7 typecheck-only gate is wanted before then, run it in parallel via `tsgo` + from `@typescript/native-preview` (a separate binary, no `typescript` peer conflict) — do **not** bump + the root `typescript` to 7. + +6. **The Rust React Compiler is deferred to a follow-up PR.** It is the official React Compiler ported to + Rust and integrated via SWC, shipped in **Rspack/Rsbuild 2.1**; this repo is on 2.0.x. To adopt: bump + `@rsbuild/core` and `@rspack/binding` to 2.1, enable it on `@rsbuild/plugin-react` (or via + `builtin:swc-loader` `jsc.transform.reactCompiler`), and validate the component set. The federation + singletons in step 4 are already the prerequisite. + +7. **After changing rules under `.claude/`, run `dotnet run --project developer-cli -- sync-ai-rules --quiet`.** + +## Examples + +### Example 1 - Lingui macros in an rslib library config + +```ts +// ✅ DO: a library that uses /t must run the Lingui SWC plugin during its own build +import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { entry: { index: ["./**/*.tsx", "./**/*.ts", "./**/*.svg", "./**/*.css", "./**/*.png", "!rslib.config.ts", "!node_modules/**", "!dist/**"] } }, + lib: [{ bundle: false, dts: true, format: "esm" }], + output: { target: "web" }, + plugins: [pluginReact(), LinguiPlugin()] +}); + +// ❌ DON'T: omit LinguiPlugin() — macros ship untransformed → "Trans is not defined" at runtime +``` + +### Example 2 - Enabling the React Compiler later (Rsbuild/Rspack 2.1) + +```ts +// After bumping @rsbuild/core and @rspack/binding to 2.1: +pluginReact({ reactCompiler: true }); +``` diff --git a/.claude/rules/frontend/translations.md b/.claude/rules/frontend/translations.md index 68cd420887..7df5cdf42e 100644 --- a/.claude/rules/frontend/translations.md +++ b/.claude/rules/frontend/translations.md @@ -17,16 +17,17 @@ Guidelines for implementing translations and internationalization in the fronten 6. **Catalogs are split between the shared component library and each self-contained system**: - Markers in `application/shared-webapp/ui/**` extract to `application/shared-webapp/ui/translations/locale/{locale}.po` — one shared catalog used by every app - Markers in `application//WebApp/**` extract to that system's `shared/translations/locale/{locale}.po` - - At runtime, `createFederatedTranslation` merges them: shared underneath, system on top, federated remote (e.g. account loaded into main) on top of that + - At runtime each system self-registers its catalog into one shared Lingui `i18n` (`Translation.create` for a host SPA; `withSystemTranslations` for a federated module) and they merge: shared UI underneath, host system on top, federated remote (e.g. account loaded into main) on top of that. `@lingui/core`/`@lingui/react` are module-federation singletons so every remote reads the same merged dictionary - Translate each shared string **once** in the shared catalog, not in every system's catalog - Prefer Lingui macros inside `shared-webapp/ui` components over threading translatable text through props -- only accept a text prop when a consumer genuinely needs to override the default (e.g., context-specific wording) + - **Disambiguate colliding strings with `context`**: the merged dictionary keys by message ID (a hash of source text **+ `context`**), so identical bare source strings share one translation. When the same word must translate differently by area (e.g. "Account" in the auth flow vs. in the product), add `context` -- either per marker (`Account`, `` t({ message: "Account", context: "auth" }) ``) or once for a block/file with a Lingui **Context Directive** (`// lingui-set context="auth"` … `// lingui-reset`; supported by `@lingui/swc-plugin`). `context` feeds the ID hash so both translations coexist in the one dictionary. Don't blanket-namespace a whole system with `context` -- that defeats sharing of genuinely-common strings; use it only where meaning actually diverges 7. Translation workflow: - After adding/changing `` or t\`\` markers, the `*.po` files are auto-regenerated on build - Don't manually add or remove `msgid` entries -- only translate `msgstr` values - After auto-generation, translate all new/updated entries for all supported languages 8. **Use correct language-specific characters** in translations -- e.g., Danish requires æøå/ÆØÅ, not ae/oe/aa substitutes. Never approximate with ASCII equivalents 9. Don't translate fully dynamic content such as variable values or dynamic text -9. **Domain terminology consistency**: +10. **Domain terminology consistency**: - Use consistent terminology throughout the application - Before translating, check existing `*.po` files for established domain terms - If "Tenant" is used, always use "Tenant" (not "Customer", "Client", "Organization", etc.) diff --git a/.claude/skills/enhanced-message-context/SKILL.md b/.claude/skills/enhanced-message-context/SKILL.md new file mode 100644 index 0000000000..32d2d26495 --- /dev/null +++ b/.claude/skills/enhanced-message-context/SKILL.md @@ -0,0 +1,229 @@ +--- +name: enhanced-message-context +description: Provide additional context for messages based on the codebase and the context of the message to improve the quality of the translations. +--- + +# Enhanced Message Context + +When implementing Lingui i18n, add translator comments to messages so translators have context to provide the best translation. Even when the message text is self-explanatory, it is important to know where and how it appears in the UI to choose the correct tone, length, and wording. + +## When to Add Comments + +Add a `comment` field when the message: + +- **Is ambiguous**: Short words/phrases that can be different parts of speech + - "Back" (noun or verb?), "Delete" (button or confirmation?), "Close" (verb or adjective?) +- **Lacks UI context**: Labels isolated from their surroundings + - Table column headers, tooltip content, standalone button labels, menu items +- **Has domain-specific meaning**: Terms with different meanings across contexts + - "Post" (verb or noun?), "Tag" (noun or verb?), "Follow" (social media or instruction?) +- **Depends on grammatical gender**: The translation depends on what the message refers to + - "Selected" (masculine/feminine/neutral depends on what is selected) +- **Uses unclear variables**: Placeholder names don't reveal what they contain + - `{count}` (count of what?), `{name}` (user name, file name, project name?) +- **Could benefit from UI context even if clear**: Where the text appears (button, dialog, banner, form field) affects tone and length - add a brief location or purpose comment when it helps. + +## Writing Effective Comments + +A good translator comment includes: + +1. **Location**: Where in the UI the message appears + - "Button in the top navigation bar" + - "Tooltip for the save icon" + - "Column header in the users table" + +2. **Action/Purpose**: What happens or what it means + - "Navigates back to the previous page" + - "Deletes the selected item permanently" + - "Shows the number of unread notifications" + +3. **Disambiguation**: Clarify part of speech or meaning + - "Used as a verb, not a noun" + - "Refers to email addresses, not postal addresses" + - "Singular form, user will see 'item' or 'items' based on count" + +## API Reference + +Lingui provides three ways to add translator comments: + +### 1. JS Macro (`t`) + +For JavaScript code outside JSX: + +```js +import { t } from "@lingui/core/macro"; + +// With comment +const backLabel = t({ + comment: "Button in the navigation bar that returns to the previous page", + message: "Back", +}); + +// With comment and variable +const uploadSuccess = t({ + comment: "Success message showing the name of the file that was uploaded", + message: `File ${fileName} uploaded successfully`, +}); +``` + +### 2. React Macro (`Trans`) + +For JSX elements: + +```jsx +import { Trans } from "@lingui/react/macro"; + +// With comment +Delete + +// With comment in a component + +``` + +### 3. Deferred/Lazy Messages (`defineMessage` / `msg`) + +For messages defined separately from their usage: + +```js +import { defineMessage } from "@lingui/core/macro"; + +const messages = { + deleteButton: defineMessage({ + comment: "Button that permanently removes the item from the database", + message: "Delete", + }), + + statusLabel: defineMessage({ + comment: "Shows whether the service is currently operational. Values: 'Active', 'Inactive', 'Pending'", + message: "Status: {status}", + }), +}; +``` + +## Examples + +### Example 1: Ambiguous Short Word + +**Before** (no context): + +```jsx + +``` + +**After** (with context): + +```jsx + +``` + +### Example 2: UI Label Without Context + +**Before** (no context): + +```jsx +const columns = [ + { key: "name", label: t`Name` }, + { key: "status", label: t`Status` }, +]; +``` + +**After** (with context): + +```jsx +const columns = [ + { + key: "name", + label: t({ + comment: "Column header in the projects table showing project name", + message: "Name" + }) + }, + { + key: "status", + label: t({ + comment: "Column header showing project status: Active, Inactive, or Archived", + message: "Status" + }) + }, + { + key: "created", + label: t({ + comment: "Column header showing the date when the project was created", + message: "Created" + }) + }, +]; +``` + +### Example 3: Domain-Specific Term + +**Before** (ambiguous): + +```jsx + +``` + +**After** (clarified as verb): + +```jsx + +``` + +### Example 4: Variable Without Clear Meaning + +**Before** (unclear what count represents): + +```js +const message = t`${count} items selected`; +``` + +**After** (clarified): + +```js +const message = t({ + comment: "Shows the number of email messages currently selected in the inbox", + message: `${count} items selected`, +}); +``` + +### Example 5: Self-Explanatory Message (Comment Still Optional but Helpful) + +```jsx +// Message is clear on its own; adding a comment with location still helps translators + + Your password must contain at least 8 characters, including one uppercase letter and one number. + +``` + +## Workflow + +When implementing or reviewing Lingui messages: + +1. **Read the message**: Look at the string itself +2. **Check context**: Consider where and how it's used in the code +3. **Ask**: "Could a translator misinterpret this without seeing the UI?" +4. **If yes**: Add a `comment` field with location, purpose, and any disambiguation +5. **If no**: Skip the comment and keep the code clean + +## Notes + +- Comments are extracted into message catalogs for translators +- Comments are stripped from production builds (zero runtime cost) +- Comments appear in translation management systems (TMS) +- Use consistent terminology across all comments in your project diff --git a/.claude/skills/lingui-best-practices/SKILL.md b/.claude/skills/lingui-best-practices/SKILL.md new file mode 100644 index 0000000000..7cbbe5a0ff --- /dev/null +++ b/.claude/skills/lingui-best-practices/SKILL.md @@ -0,0 +1,390 @@ +--- +name: lingui-best-practices +description: Implement internationalization with Lingui in React and JavaScript applications. Use when adding i18n, translating UI, working with Trans/useLingui/Plural, extracting messages, compiling catalogs, or when the user mentions Lingui, internationalization, i18n, translations, locales, message extraction, ICU MessageFormat, or working with .po files. +--- + +# Lingui Best Practices + +Lingui is a powerful internationalization (i18n) framework for JavaScript. This skill covers best practices for implementing i18n in React and vanilla JavaScript applications. + +## Quick Start Workflow + +The standard Lingui workflow consists of these steps: + +1. Wrap your app in `I18nProvider` +2. Mark messages for translation using macros (`Trans`, `t`, etc.) +3. Extract messages: `lingui extract` +4. Translate the catalogs +5. Compile catalogs: `lingui compile` +6. Load and activate locale in your app + +## Core Packages + +Import from these packages: + +```jsx +// React macros (recommended) +import { Trans, Plural, Select, useLingui } from "@lingui/react/macro"; + +// Core macros for vanilla JS +import { t, msg, plural, select } from "@lingui/core/macro"; + +// Runtime (rarely used directly) +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +``` + +## Setup I18nProvider + +Wrap your application with `I18nProvider`: + +```jsx +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { messages } from "./locales/en/messages"; + +i18n.load("en", messages); +i18n.activate("en"); + +function App() { + return ( + + {/* Your app */} + + ); +} +``` + +## Translating UI Text + +### Use Trans for JSX Content + +The `Trans` macro is the primary way to translate JSX: + +```jsx +import { Trans } from "@lingui/react/macro"; + +// Simple text +Hello World + +// With variables +Hello {userName} + +// With components (rich text) + + Read the documentation for more info. + + +// Extracted as: "Read the <0>documentation for more info." +``` + +**When to use**: For any translatable text in JSX elements. + +### Use useLingui for Non-JSX + +For strings outside JSX (attributes, alerts, function calls): + +```jsx +import { useLingui } from "@lingui/react/macro"; + +function MyComponent() { + const { t } = useLingui(); + + const handleClick = () => { + alert(t`Action completed!`); + }; + + return ( +
+ {t`Image + +
+ ); +} +``` + +**When to use**: Element attributes, alerts, function parameters, any non-JSX string. + +### Use msg for Lazy Translations + +When you need to define messages at module level or in arrays/objects: + +```jsx +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; + +// Module-level constants +const STATUSES = { + active: msg`Active`, + inactive: msg`Inactive`, + pending: msg`Pending`, +}; + +function StatusList() { + const { _ } = useLingui(); + + return Object.entries(STATUSES).map(([key, message]) => ( +
{_(message)}
+ )); +} +``` + +**When to use**: Module-level constants, arrays of messages, conditional message selection. + +## Pluralization + +Use the `Plural` macro for quantity-dependent messages: + +```jsx +import { Plural } from "@lingui/react/macro"; + + +``` + +The `#` placeholder is replaced with the actual value. + +### Exact Matches + +Use `_N` syntax for exact number matches (takes precedence over plural forms): + +```jsx + +``` + +### With Variables and Components + +Combine with `Trans` for complex messages: + +```jsx + + You have # messages, {userName} +
+ } +/> +``` + +## Formatting Dates and Numbers + +Use `i18n.date()` and `i18n.number()` for locale-aware formatting: + +```jsx +import { useLingui } from "@lingui/react/macro"; + +function MyComponent() { + const { i18n } = useLingui(); + const lastLogin = new Date(); + + return ( + + Last login: {i18n.date(lastLogin)} + + ); +} +``` + +These use the browser's `Intl` API for proper locale formatting. + +## Message IDs and Context + +### Explicit IDs + +Provide a custom ID for stable message keys: + +```jsx +Welcome to our app +``` + +### Context for Disambiguation + +When the same text has different meanings, use `context`: + +```jsx +right +right +``` + +These create separate catalog entries. + +### Comments for Translators + +Add context for translators: + +```jsx +Hello World +``` + +## Configuration + +Basic `lingui.config.js`: + +```js +import { defineConfig } from "@lingui/cli"; + +export default defineConfig({ + sourceLocale: "en", + locales: ["en", "es", "fr", "de"], + catalogs: [ + { + path: "/src/locales/{locale}/messages", + include: ["src"], + exclude: ["**/node_modules/**"], + }, + ], +}); +``` + +For detailed configuration patterns, see [configuration.md](references/configuration.md). + +## Best Practices + +### Always Use Macros + +Prefer macros over runtime components. Macros are compiled at build time, reducing bundle size: + +```jsx +// ✅ Good - uses macro +import { Trans } from "@lingui/react/macro"; + +// ❌ Avoid - runtime only +import { Trans } from "@lingui/react"; +``` + +### Keep Messages Simple + +Avoid complex expressions in messages - they'll be replaced with placeholders: + +```jsx +// ❌ Bad - loses context +Hello {user.name.toUpperCase()} +// Extracted as: "Hello {0}" + +// ✅ Good - clear variable name +const userName = user.name.toUpperCase(); +Hello {userName} +// Extracted as: "Hello {userName}" +``` + +### Use Trans for JSX, t for Strings + +Choose the right tool: + +```jsx +// ✅ For JSX content +

Welcome

+ +// ✅ For string values +const { t } = useLingui(); +{t`Profile +``` + +### Don't Use Macros at Module Level + +Macros need component context - use `msg` instead: + +```jsx +// ❌ Bad - won't work +import { t } from "@lingui/core/macro"; +const LABELS = [t`Red`, t`Green`, t`Blue`]; + +// ✅ Good - use msg for lazy translation +import { msg } from "@lingui/core/macro"; +const LABELS = [msg`Red`, msg`Green`, msg`Blue`]; +``` + +### Use the ESLint Plugin + +Install and configure `eslint-plugin-lingui` to catch common mistakes automatically: + +```bash +npm install --save-dev eslint-plugin-lingui +``` + +```js +// eslint.config.js +import pluginLingui from "eslint-plugin-lingui"; + +export default [ + pluginLingui.configs["flat/recommended"], +]; +``` + +## Common Patterns + +### Dynamic Locale Switching + +```jsx +import { i18n } from "@lingui/core"; + +async function changeLocale(locale) { + const { messages } = await import(`./locales/${locale}/messages`); + i18n.load(locale, messages); + i18n.activate(locale); +} +``` + +### Loading Catalogs Dynamically + +```jsx +import { useEffect } from "react"; +import { i18n } from "@lingui/core"; + +function loadCatalog(locale) { + return import(`./locales/${locale}/messages`); +} + +function App() { + useEffect(() => { + loadCatalog("en").then(catalog => { + i18n.load("en", catalog.messages); + i18n.activate("en"); + }); + }, []); + + return {/* ... */}; +} +``` + +### Memoization with useLingui + +When using memoization, use the `t` function from the macro version: + +```jsx +import { useLingui } from "@lingui/react/macro"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { t } = useLingui(); // Macro version - reference changes with locale + + // ✅ Safe - t reference updates with locale + const message = useMemo(() => t(welcomeMessage), [t]); + + return
{message}
; +} +``` + +## Troubleshooting + +If you encounter issues: + +1. **Messages not extracted**: Check `include` patterns in `lingui.config.js` +2. **Translations not applied**: Ensure catalogs are compiled with `lingui compile` +3. **Runtime errors**: Verify `I18nProvider` wraps your app +4. **Type errors**: Run `lingui compile --typescript` for TypeScript projects + +For detailed common mistakes and pitfalls, see [common-mistakes.md](references/common-mistakes.md). diff --git a/.claude/skills/lingui-best-practices/references/common-mistakes.md b/.claude/skills/lingui-best-practices/references/common-mistakes.md new file mode 100644 index 0000000000..a5964cc0a0 --- /dev/null +++ b/.claude/skills/lingui-best-practices/references/common-mistakes.md @@ -0,0 +1,418 @@ +# Common Mistakes and Pitfalls + +This document covers common mistakes when using Lingui and how to avoid them. + +## Module-Level Macro Usage + +### The Problem + +Using macros like `t` at module level won't react to locale changes: + +```jsx +// ❌ WRONG - This won't update when locale changes +import { t } from "@lingui/core/macro"; + +const COLORS = [ + t`Red`, + t`Green`, + t`Blue` +]; + +function ColorList() { + return COLORS.map(color =>
{color}
); +} +``` + +**Why it fails**: The `t` macro requires access to the active i18n context. At module level, the context isn't available and messages are evaluated only once. + +### The Solution + +Use the `msg` macro for lazy translations: + +```jsx +// ✅ CORRECT - Use msg for module-level messages +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; + +const COLORS = [ + msg`Red`, + msg`Green`, + msg`Blue` +]; + +function ColorList() { + const { _ } = useLingui(); + return COLORS.map(color =>
{_(color)}
); +} +``` + +**Key points**: +- `msg` creates a message descriptor, not the final string +- Use `_()` or `i18n._()` to translate the descriptor at render time +- Messages will update when locale changes + +### Alternative Pattern + +If you need the messages in a React component: + +```jsx +// ✅ Also correct - define in component +import { useLingui } from "@lingui/react/macro"; + +function ColorList() { + const { t } = useLingui(); + + const colors = [t`Red`, t`Green`, t`Blue`]; + + return colors.map(color =>
{color}
); +} +``` + +## Plural Form Misunderstandings + +### Zero Form Doesn't Exist in English + +A common mistake is expecting English to have a `zero` plural form: + +```jsx +// ❌ WRONG - English doesn't have 'zero' form + +``` + +**Result**: When `count = 0`, this displays `"0 messages"`, not `"No messages"`. + +**Why**: English plural rules only have two forms: +- `one`: Used when value is exactly 1 +- `other`: Used for everything else (0, 2, 3, ...) + +### The Solution: Use Exact Matches + +Use the `_N` syntax for exact number matches: + +```jsx +// ✅ CORRECT - Use _0 for exact match + +``` + +**How it works**: +- `_0` matches exactly 0 (takes precedence over plural forms) +- `one` matches exactly 1 (plural form) +- `other` matches everything else (plural form) + +### Exact Matches vs Plural Forms + +You can use exact matches for any number: + +```jsx + +``` + +**Rule**: Exact matches (`_N`) always take precedence over plural forms (`one`, `few`, `many`, `other`). + +### Decimal Numbers + +Be aware that decimal numbers (even `1.0`) use the `other` form: + +```jsx + +// Shows: "1.0 items" (not "1.0 item") +``` + +## Memoization Pitfalls + +### The i18n Reference Problem + +A common mistake when using React hooks: + +```jsx +// ❌ WRONG - i18n reference is stable +import { useLingui } from "@lingui/react"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { i18n } = useLingui(); + + const welcome = useMemo( + () => i18n._(welcomeMessage), + [i18n] // i18n reference doesn't change! + ); + + return
{welcome}
; +} +``` + +**Problem**: The `i18n` object reference is stable across re-renders. When locale changes, the component won't re-translate because the dependency (`i18n`) hasn't changed. + +### Solution 1: Use the Macro Version + +The easiest solution is to use the macro version of `useLingui`: + +```jsx +// ✅ CORRECT - Macro version provides 't' that changes +import { useLingui } from "@lingui/react/macro"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { t } = useLingui(); + + const welcome = useMemo( + () => t(welcomeMessage), + [t] // t reference changes with locale + ); + + return
{welcome}
; +} +``` + +### Solution 2: Use the Underscore Function + +The runtime `useLingui` provides a `_` function that changes with locale: + +```jsx +// ✅ Also correct - underscore function changes +import { useLingui } from "@lingui/react"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +// Define message descriptor outside component +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { _ } = useLingui(); + + const welcome = useMemo( + () => _(welcomeMessage), + [_] // _ reference changes with locale + ); + + return
{welcome}
; +} +``` + +### When Memoization Matters + +You typically need to consider this when: +- Using `useMemo` or `useCallback` with translations +- Creating refs or derived state from translated messages +- Passing translations to third-party components + +## Complex Expressions in Messages + +### The Problem + +Using complex expressions directly in messages loses context: + +```jsx +// ❌ BAD - Expression becomes a placeholder +Hello {user.name.toUpperCase()} +// Extracted as: "Hello {0}" + +Total: {items.reduce((sum, item) => sum + item.price, 0)} +// Extracted as: "Total: {0}" +``` + +**Problem**: Translators see `{0}` instead of meaningful variable names, losing important context. + +### The Solution + +Extract the value to a named variable first: + +```jsx +// ✅ GOOD - Variable has meaningful name +const userName = user.name.toUpperCase(); +Hello {userName} +// Extracted as: "Hello {userName}" + +const totalPrice = items.reduce((sum, item) => sum + item.price, 0); +Total: {totalPrice} +// Extracted as: "Total: {totalPrice}" +``` + +### Method Calls and Properties + +Even simple method calls should be extracted: + +```jsx +// ❌ BAD +Welcome {currentUser.getName()} + +// ✅ GOOD +const userName = currentUser.getName(); +Welcome {userName} +``` + +### ESLint Rule + +Enable the Lingui ESLint plugin to catch these automatically: + +```js +// eslint.config.js (flat config) +import pluginLingui from "eslint-plugin-lingui"; + +export default [ + pluginLingui.configs["flat/recommended"], +]; + +// Or configure individual rules: +// "lingui/no-expression-in-message": "error" +``` + +## Missing I18nProvider + +### The Problem + +Components using Lingui hooks or components without `I18nProvider`: + +```jsx +// ❌ WRONG - No I18nProvider +import { Trans } from "@lingui/react/macro"; + +function App() { + return ( +
+ Hello +
+ ); +} +``` + +**Error**: You'll get a runtime error: "Cannot read property 'locale' of undefined" or similar. + +### The Solution + +Always wrap your app with `I18nProvider`: + +```jsx +// ✅ CORRECT +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; + +i18n.load("en", messages); +i18n.activate("en"); + +function App() { + return ( + +
+ Hello +
+
+ ); +} +``` + +### Where to Place It + +Place `I18nProvider` as high as possible in your component tree: + +```jsx +// App entry point +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); +``` + +## Incorrect Import Paths (v5+) + +### The Problem + +Using old import paths from Lingui v4: + +```jsx +// ❌ WRONG in v5 +import { t, Trans } from "@lingui/macro"; +``` + +### The Solution + +Use split imports in v5: + +```jsx +// ✅ CORRECT in v5 +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +``` + +**Macro imports**: +- `@lingui/core/macro` - Core macros (t, msg, plural, select, etc.) +- `@lingui/react/macro` - React macros (Trans, Plural, Select, useLingui) + +**Runtime imports**: +- `@lingui/core` - Core runtime (i18n object, setupI18n) +- `@lingui/react` - React runtime (I18nProvider, Trans component, useLingui) + +## React Native: Missing Text Component + +### The Problem + +In React Native, translations render as strings by default, causing errors: + +```jsx +// ❌ WRONG in React Native + + Hello + +// Error: Text strings must be rendered within a component +``` + +### The Solution + +Configure `defaultComponent` in `I18nProvider`: + +```jsx +import { Text } from "react-native"; + + + + +``` + +Or use the `component` prop on individual components: + +```jsx +import { Text } from "react-native"; + +Hello +``` + +## Summary + +**Top mistakes to avoid**: + +1. ❌ Using `t` macro at module level → Use `msg` instead +2. ❌ Expecting English `zero` form → Use `_0` for exact matches +3. ❌ Memoizing with `i18n` reference → Use `t` from macro `useLingui` +4. ❌ Complex expressions in messages → Extract to named variables +5. ❌ Missing `I18nProvider` → Wrap your app +6. ❌ Loading uncompiled catalogs → Run `lingui compile` +7. ❌ Wrong imports in v5 → Use `@lingui/core/macro` and `@lingui/react/macro` +8. ❌ Forgetting extraction → Run `lingui extract` regularly + +Following these practices will help you avoid the most common Lingui pitfalls. diff --git a/.claude/skills/lingui-best-practices/references/configuration.md b/.claude/skills/lingui-best-practices/references/configuration.md new file mode 100644 index 0000000000..2e39bc55f7 --- /dev/null +++ b/.claude/skills/lingui-best-practices/references/configuration.md @@ -0,0 +1,173 @@ +# Configuration Patterns + +Essential patterns for `lingui.config.js` (or `.ts`). + +## Basic Configuration + +The minimal configuration: + +```js +import { defineConfig } from "@lingui/cli"; + +export default defineConfig({ + sourceLocale: "en", + locales: ["en", "es", "fr", "de"], + catalogs: [ + { + path: "/src/locales/{locale}/messages", + include: ["src"], + exclude: ["**/node_modules/**"], + }, + ], +}); +``` + +**Key fields**: +- `sourceLocale` - Your source code's language (usually "en") +- `locales` - Array of BCP-47 locale codes +- `catalogs` - Where to find/store message catalogs + +## Common Catalog Patterns + +### Pattern 1: Single Directory (Recommended for most projects) + +```js +catalogs: [ + { + path: "src/locales/{locale}/messages", + include: ["src"], + }, +] +``` + +**Structure**: +``` +src/locales/ +├── en/messages.po +├── es/messages.po +└── fr/messages.po +``` + +### Pattern 2: Separate Catalogs per Feature + +For large projects where you want to split translations: + +```js +catalogs: [ + { + path: "src/{name}/locales/{locale}", + include: ["src/{name}/"], + }, +] +``` + +**Structure**: +``` +src/ +├── auth/locales/en.po +├── dashboard/locales/en.po +└── settings/locales/en.po +``` + +Use `catalogsMergePath` to compile into single files: + +```js +catalogsMergePath: "src/locales/{locale}" +``` + +## Essential Configuration Options + +### Fallback Locales + +Handle missing translations by falling back to other locales: + +```js +fallbackLocales: { + "en-US": "en", // en-US → en + "es-MX": "es", // es-MX → es + default: "en" // Everything else → en +} +``` + +### Compilation Format + +Control the output format of compiled catalogs: + +```js +compileNamespace: "es" // ES6 modules (default: "cjs") +``` + +**Common options**: +- `cjs` - CommonJS: `module.exports = {messages: {...}}` +- `es` - ES6: `export const messages = {...}` +- `ts` - TypeScript with type definitions + +### TypeScript Support + +For TypeScript projects: + +```js +compileNamespace: "ts" +``` + +Then compile with: +```bash +lingui compile --typescript +``` + +### Parser Options + +If using TypeScript experimental decorators or Flow: + +```js +extractorParserOptions: { + tsExperimentalDecorators: true, +} +``` + +## Message Ordering + +Control catalog message order: + +```js +orderBy: "messageId" // Options: "message", "messageId", "origin" +``` + +## Common Issues & Solutions + +### Issue: Messages Not Extracted + +**Problem**: CLI doesn't find your messages. + +**Solution**: Check your `include` patterns are correct: + +```js +catalogs: [ + { + path: "src/locales/{locale}/messages", + include: ["src/**/*.{js,jsx,ts,tsx}"], // Be specific + exclude: ["**/*.test.*", "**/*.spec.*"], + }, +] +``` + +### Issue: Can't Import Compiled Catalogs + +**Problem**: Import path doesn't match compiled location. + +**Solution**: Ensure `path` matches your import: + +```js +// Config +path: "src/locales/{locale}/messages" + +// Import must match +import { messages } from "./locales/en/messages"; +``` + +## Best Practices + +1. **Use `` token** for portable paths +2. **Always exclude test files** in `exclude` patterns +3. **Set `sourceLocale`** to your development language +4. **Add `.gitignore` for compiled files**: `*.js` in locale directories diff --git a/.claude/skills/swc-plugin-compatibility/SKILL.md b/.claude/skills/swc-plugin-compatibility/SKILL.md new file mode 100644 index 0000000000..ea5150208f --- /dev/null +++ b/.claude/skills/swc-plugin-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: swc-plugin-compatibility +description: Diagnose and fix Lingui SWC plugin compatibility errors with Next.js, Rspack, or other SWC runtimes. Use when seeing errors like "failed to invoke plugin", "failed to run Wasm plugin transform", "out of bounds memory access", or "LayoutError" during builds with @lingui/swc-plugin. +--- + +# SWC Plugin Compatibility + +If you see errors like these during your build: + +``` +failed to invoke plugin on 'Some("/app/src/file.ts")' +failed to run Wasm plugin transform +RuntimeError: out of bounds memory access +LayoutError called Result::unwrap() +``` + +**This is NOT a bug.** You're using an incompatible version of `@lingui/swc-plugin` with your SWC runtime. + +## Why This Happens + +SWC plugin support is experimental. The plugin API does not follow semantic versioning. + +SWC uses Rkyv to transfer the AST between the core and plugins. Both must agree on the exact memory layout of the AST. If the layout changes (e.g., new ECMAScript features), older plugins cannot read the data correctly. + +This layout cannot be negotiated at runtime - it must match at compile time. + +## How to Fix + +### Step 1: Check the Compatibility Table + +Go to the [compatibility table](https://github.com/lingui/swc-plugin?tab=readme-ov-file#compatibility) and find the plugin version that matches your runtime. + +### Step 2: Use the Plugin Compatibility Site + +For precise matching, use https://plugins.swc.rs/: + +1. Select your runtime (e.g., `next`) +2. Select your runtime version (e.g., `next@15.0.1`) +3. Find a compatible `@lingui/swc-plugin` version + +### Step 3: Pin Your Versions + +```json +{ + "devDependencies": { + "@lingui/swc-plugin": "5.10.0" + } +} +``` + +Use an **exact version** (no `^` or `~`) to prevent accidental upgrades. + +## Version Compatibility Quick Reference + +| Plugin Version | @lingui/core | Notes | +|----------------|--------------|-------| +| `5.*` | `@lingui/core@5.*` | Current | +| `4.*` | `@lingui/core@4.*` | Legacy | + +**Important**: `@lingui/swc-plugin` does not need to match other `@lingui/*` package versions exactly. It follows its own versioning scheme. + +## Rules to Avoid Build Breakage + +1. **Pin an exact plugin version** compatible with your runtime +2. **Don't auto-bump `@lingui/swc-plugin`** - check release notes first +3. **Don't auto-bump your runtime** (Next.js, Rspack, etc.) - runtimes may bump `swc-core` in minor/patch releases +4. **Check compatibility after any upgrade** that touches SWC or the plugin + +## Understanding Runtimes + +By "runtime" we mean the tool executing SWC: Next.js, Rspack, or `@swc/core`. + +Some runtimes (like Next.js) embed SWC directly and don't use `@swc/core` from npm. This means: + +- You cannot control `swc-core` version via `package.json` +- Plugin compatibility depends on the runtime's embedded SWC version + +## Example: Next.js Configuration + +```js +// next.config.js +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + swcPlugins: [ + ['@lingui/swc-plugin', { + // Plugin options + }], + ], + }, +}; + +module.exports = nextConfig; +``` + +## Example: .swcrc Configuration + +```json +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "experimental": { + "plugins": [ + ["@lingui/swc-plugin", {}] + ] + } + } +} +``` + +## What If No Compatible Version Exists? + +If your runtime uses a newer `swc-core` that no plugin version supports yet: + +1. Check for recent plugin releases +2. Open an issue or PR at https://github.com/lingui/swc-plugin diff --git a/.gitignore b/.gitignore index 8c301e3688..88abc7f585 100644 --- a/.gitignore +++ b/.gitignore @@ -426,6 +426,7 @@ playwright-report/ # .NET Aspire **/.aspire/settings.json +aspire.config.json # PlatformPlatform agent workspace .workspace diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index 49fa5c1811..4932c4f1ec 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -222,6 +222,15 @@ } ] }, + "account-hmr-ws": { + "ClusterId": "account-static", + "Match": { + "Path": "/account/rsbuild-hmr" + }, + "Metadata": { + "HostnameKey": "App" + } + }, "main-static": { "ClusterId": "main-static", "Match": { @@ -240,6 +249,15 @@ "HostnameKey": "App" } }, + "main-hmr-ws": { + "ClusterId": "main-static", + "Match": { + "Path": "/rsbuild-hmr" + }, + "Metadata": { + "HostnameKey": "App" + } + }, "main": { "ClusterId": "main-api", "Match": { diff --git a/application/account/BackOffice/main.tsx b/application/account/BackOffice/main.tsx index 3e2df29c71..44d0b6dcf2 100644 --- a/application/account/BackOffice/main.tsx +++ b/application/account/BackOffice/main.tsx @@ -2,7 +2,8 @@ import "@repo/ui/theme.css"; import "@repo/ui/tailwind.css"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/globalErrorHandlers"; -import { createFederatedTranslation } from "@repo/infrastructure/translations/createFederatedTranslation"; +import { RepoUiTranslation } from "@repo/infrastructure/translations/RepoUiTranslation"; +import { Translation } from "@repo/infrastructure/translations/Translation"; import { Toaster } from "@repo/ui/components/Sonner"; import { RouterProvider } from "@tanstack/react-router"; import React from "react"; @@ -10,7 +11,7 @@ import reactDom from "react-dom/client"; import { router } from "@/shared/lib/router/router"; -const { TranslationProvider } = await createFederatedTranslation( +const { TranslationProvider } = await Translation.create( (locale) => import(`@/shared/translations/locale/${locale}.ts`) ); @@ -25,10 +26,12 @@ setupGlobalErrorHandlers(); reactDom.createRoot(rootElement).render( - - - - + + + + + + ); diff --git a/application/account/BackOffice/routes/-components/LoginFooterControls.tsx b/application/account/BackOffice/routes/-components/LoginFooterControls.tsx index 755fa00ed3..d0d0ccb836 100644 --- a/application/account/BackOffice/routes/-components/LoginFooterControls.tsx +++ b/application/account/BackOffice/routes/-components/LoginFooterControls.tsx @@ -1,8 +1,7 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; import { @@ -83,9 +82,7 @@ function LocaleDropdown() { const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx b/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx index 191361bf9d..277c8a14b5 100644 --- a/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx +++ b/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx @@ -17,7 +17,7 @@ export function EmailsPreview() { // The iframe locale follows the SPA's active locale (set by the avatar-menu locale switcher) so // designers don't have to toggle locales twice. Falls back to en-US if the user's chosen locale // doesn't have a rendered email artifact yet — the email build only emits the locales listed in - // application/shared-webapp/infrastructure/translations/i18n.config.json that have .po catalogs. + // application/shared-webapp/infrastructure/translations/i18n.config.ts that have .po catalogs. const locale: PreviewLocale = (SUPPORTED_PREVIEW_LOCALES as readonly string[]).includes(i18n.locale) ? (i18n.locale as PreviewLocale) : FALLBACK_PREVIEW_LOCALE; diff --git a/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx b/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx index 266089e776..fb1097bc60 100644 --- a/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx +++ b/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx @@ -1,8 +1,7 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Avatar, AvatarFallback } from "@repo/ui/components/Avatar"; import { @@ -97,9 +96,7 @@ export function PreviewAvatarMenu() { const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx b/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx index d5f327005c..0fed7b1315 100644 --- a/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx +++ b/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/components/Popover"; @@ -80,9 +79,7 @@ export function PreviewLanguageFlyout() { const currentLocale = i18n.locale as Locale; const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; return ( diff --git a/application/account/BackOffice/rsbuild.config.ts b/application/account/BackOffice/rsbuild.config.ts index 6d43539739..9c0473cddf 100644 --- a/application/account/BackOffice/rsbuild.config.ts +++ b/application/account/BackOffice/rsbuild.config.ts @@ -7,7 +7,6 @@ import { RunTimeEnvironmentPlugin } from "@repo/build/plugin/RunTimeEnvironmentP import { TailwindPlugin } from "@repo/build/plugin/TailwindPlugin"; import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; @@ -38,9 +37,6 @@ export default defineConfig({ pluginReact(), pluginTypeCheck(), pluginSvgr(), - pluginSourceBuild({ - sourceField: "source" - }), FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv), LinguiPlugin(), diff --git a/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx index 9f49834320..b1727d6149 100644 --- a/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx @@ -1,8 +1,7 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Avatar, AvatarFallback } from "@repo/ui/components/Avatar"; import { Badge } from "@repo/ui/components/Badge"; @@ -80,9 +79,7 @@ export function BackOfficeAvatarMenu() { const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/account/WebApp/emails/lingui.config.ts b/application/account/WebApp/emails/lingui.config.ts index 10bdfd4b9d..6e388b979c 100644 --- a/application/account/WebApp/emails/lingui.config.ts +++ b/application/account/WebApp/emails/lingui.config.ts @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf"; import { formatter } from "@lingui/format-po"; -import i18nConfig from "../../../shared-webapp/infrastructure/translations/i18n.config.json"; +import i18nConfig from "../../../shared-webapp/infrastructure/translations/i18n.config"; const config: LinguiConfig = { locales: Object.keys(i18nConfig), diff --git a/application/account/WebApp/federated-modules/AccountApp.tsx b/application/account/WebApp/federated-modules/AccountApp.tsx index 055854f3ed..7ecd2c755d 100644 --- a/application/account/WebApp/federated-modules/AccountApp.tsx +++ b/application/account/WebApp/federated-modules/AccountApp.tsx @@ -5,6 +5,8 @@ import { shouldBlockNavigation } from "@repo/ui/hooks/federatedNavigationGuard"; import { createRouter, type NavigateOptions, RouterProvider } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import { UnsavedChangesDialog } from "../shared/components/UnsavedChangesDialog"; import { MainNavigationContext } from "../shared/hooks/useMainNavigation"; import { routeTree } from "../shared/lib/router/routeTree.generated"; @@ -14,7 +16,7 @@ export interface AccountAppProps { onNavigateToMain: (path: string) => void; } -export default function AccountApp({ initialPath, onNavigateToMain }: Readonly) { +function AccountApp({ initialPath, onNavigateToMain }: Readonly) { // Store initialPath in a ref to prevent router recreation when main's router re-renders // due to URL changes from window.history.pushState() calls within this component const initialPathRef = useRef(initialPath); @@ -88,3 +90,5 @@ export default function AccountApp({ initialPath, onNavigateToMain }: Readonly ); } + +export default withAccountTranslations(AccountApp); diff --git a/application/account/WebApp/federated-modules/banners/Banners.tsx b/application/account/WebApp/federated-modules/banners/Banners.tsx index 5a9775028d..1baaa06471 100644 --- a/application/account/WebApp/federated-modules/banners/Banners.tsx +++ b/application/account/WebApp/federated-modules/banners/Banners.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; import { createPortal } from "react-dom"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import ExpiringCardBanner from "./ExpiringCardBanner"; import InvitationBanner from "./InvitationBanner"; import PaymentFailedBanner from "./PaymentFailedBanner"; import "@repo/ui/tailwind.css"; -export default function Banners() { +function Banners() { const [target] = useState(() => document.getElementById("banner-root")); if (!target) { @@ -22,3 +24,5 @@ export default function Banners() { target ); } + +export default withAccountTranslations(Banners); diff --git a/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx b/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx index 8447297031..1ba5ad9342 100644 --- a/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx +++ b/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx @@ -3,6 +3,8 @@ import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@repo/ui/components/Dialog"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + export type AuthSyncModalType = "tenant-switch" | "logged-in" | "logged-out"; export interface AuthSyncModalProps { @@ -12,7 +14,7 @@ export interface AuthSyncModalProps { onPrimaryAction: () => void; } -export default function AuthSyncModal({ isOpen, type, newTenantName, onPrimaryAction }: AuthSyncModalProps) { +function AuthSyncModal({ isOpen, type, newTenantName, onPrimaryAction }: AuthSyncModalProps) { const getModalContent = () => { switch (type) { case "tenant-switch": @@ -97,3 +99,5 @@ export default function AuthSyncModal({ isOpen, type, newTenantName, onPrimaryAc ); } + +export default withAccountTranslations(AuthSyncModal); diff --git a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx index 9e4390e217..c24f5503ba 100644 --- a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx +++ b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx @@ -1,8 +1,9 @@ import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; import { @@ -13,9 +14,7 @@ import { } from "@repo/ui/components/DropdownMenu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { CheckIcon, GlobeIcon } from "lucide-react"; -import { use, useEffect, useState } from "react"; - -const PREFERRED_LOCALE_KEY = "preferred-locale"; +import { use } from "react"; const locales = Object.entries(localeMap).map(([id, info]) => ({ id: id as Locale, @@ -43,38 +42,23 @@ export default function LocaleSwitcher({ variant?: "icon" | "mobile-menu"; onAction?: () => void; } = {}) { - const [currentLocale, setCurrentLocale] = useState("en-US"); + // Derive the active locale from the shared i18n singleton so the checkmark stays correct no matter which + // path changed the locale (this switcher, the preferences screen, an auth refresh); no local mirror. + const currentLocale = useLingui().i18n.locale as Locale; const isAuthenticated = useIsAuthenticated(); const { setLocale } = use(translationContext); - useEffect(() => { - // Get current locale from document or localStorage - const htmlLang = document.documentElement.lang as Locale; - const savedLocale = localStorage.getItem(PREFERRED_LOCALE_KEY) as Locale; - - if (savedLocale && locales.some((l) => l.id === savedLocale)) { - setCurrentLocale(savedLocale); - } else if (htmlLang && locales.some((l) => l.id === htmlLang)) { - setCurrentLocale(htmlLang); - } - }, []); - const handleLocaleChange = async (locale: Locale) => { if (locale !== currentLocale) { // Call onAction if provided (for closing mobile menu) onAction?.(); - // Save to localStorage - localStorage.setItem(PREFERRED_LOCALE_KEY, locale); - // Only update backend if user is authenticated if (isAuthenticated) { await updateLocaleOnBackend(locale); } - document.documentElement.lang = locale; await setLocale(locale); - setCurrentLocale(locale); } }; diff --git a/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx b/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx index e41e4bb813..965cb48a46 100644 --- a/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx @@ -11,6 +11,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { Home, LogOut, ShieldX } from "lucide-react"; import { useContext, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -85,7 +87,7 @@ function AccessDeniedNavigation() { ); } -export default function AccessDeniedPage() { +function AccessDeniedPage() { return (
@@ -123,3 +125,5 @@ export default function AccessDeniedPage() {
); } + +export default withAccountTranslations(AccessDeniedPage); diff --git a/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx b/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx index e0aa023872..875ee1c5d8 100644 --- a/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx @@ -15,6 +15,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { AlertTriangle, Home, LogOut, RefreshCw } from "lucide-react"; import { useContext, useEffect, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -91,7 +93,7 @@ function ErrorNavigation() { ); } -export default function ErrorPage(props: Readonly) { +function ErrorPage(props: Readonly) { // Handle not found errors with dedicated page if (isNotFoundError(props.error)) { return ; @@ -192,3 +194,5 @@ function GeneralErrorPage({ error }: Readonly) { ); } + +export default withAccountTranslations(ErrorPage); diff --git a/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx b/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx index 4b89b2b094..4fff00c11e 100644 --- a/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx @@ -11,6 +11,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { FileQuestion, Home, LogOut } from "lucide-react"; import { useContext, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -85,7 +87,7 @@ function NotFoundNavigation() { ); } -export default function NotFoundPage() { +function NotFoundPage() { return (
@@ -123,3 +125,5 @@ export default function NotFoundPage() {
); } + +export default withAccountTranslations(NotFoundPage); diff --git a/application/account/WebApp/federated-modules/public/PublicFooter.tsx b/application/account/WebApp/federated-modules/public/PublicFooter.tsx index f1a6700e91..72467540b9 100644 --- a/application/account/WebApp/federated-modules/public/PublicFooter.tsx +++ b/application/account/WebApp/federated-modules/public/PublicFooter.tsx @@ -9,6 +9,8 @@ import { Logo } from "@repo/ui/components/Logo"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { MailIcon } from "lucide-react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + // Brand icons removed from lucide-react v1 for trademark reasons; inlined here as SVGs. function GithubIcon({ className }: { readonly className?: string }) { return ( @@ -81,7 +83,7 @@ function SocialLinkButton({ href, ariaLabel, tooltip, children }: SocialLinkButt ); } -export default function PublicFooter() { +function PublicFooter() { const currentYear = new Date().getFullYear(); const { i18n } = useLingui(); const tagline = webTaglines[i18n.locale]; @@ -177,3 +179,5 @@ export default function PublicFooter() { ); } + +export default withAccountTranslations(PublicFooter); diff --git a/application/account/WebApp/federated-modules/public/PublicNavigation.tsx b/application/account/WebApp/federated-modules/public/PublicNavigation.tsx index 8c61d1e5c9..917b83d25c 100644 --- a/application/account/WebApp/federated-modules/public/PublicNavigation.tsx +++ b/application/account/WebApp/federated-modules/public/PublicNavigation.tsx @@ -10,8 +10,9 @@ import { Suspense } from "react"; import LocaleSwitcher from "@/federated-modules/common/LocaleSwitcher"; import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; import UserMenu from "@/federated-modules/userMenu/UserMenu"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; -export default function PublicNavigation() { +function PublicNavigation() { const isAuthenticated = useIsAuthenticated(); return ( @@ -46,3 +47,5 @@ export default function PublicNavigation() { ); } + +export default withAccountTranslations(PublicNavigation); diff --git a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx index e592017d7a..0309fa1455 100644 --- a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -1,8 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { trackInteraction, useTrackOpen } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; -import { authSyncService, type TenantSwitchedMessage } from "@repo/infrastructure/auth/AuthSyncService"; -import { loggedInPath } from "@repo/infrastructure/auth/constants"; import { useUserInfo } from "@repo/infrastructure/auth/hooks"; import { productName } from "@repo/infrastructure/branding"; import { Button } from "@repo/ui/components/Button"; @@ -20,88 +18,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { LayoutDashboardIcon, MessageCircleQuestion } from "lucide-react"; import { useContext, useEffect, useState } from "react"; -import { SupportDialog } from "../common/SupportDialog"; -import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; -import { fetchTenants, switchTenantApi, type TenantInfo } from "../common/tenantUtils"; -import { MobileMenuContent } from "./MobileMenuContent"; -import { TenantSwitcherDrawer } from "./TenantSwitcherDrawer"; - -export function MobileMenuDialogs() { - const userInfo = useUserInfo(); - const [isTenantSwitcherOpen, setIsTenantSwitcherOpen] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); - const [isSupportDialogOpen, setIsSupportDialogOpen] = useState(false); - const [tenants, setTenants] = useState([]); - - const currentTenantId = userInfo?.tenantId; - - useEffect(() => { - const handleOpenSupport = () => setIsSupportDialogOpen(true); - const handleOpenTenantSwitcher = (event: CustomEvent<{ tenants: TenantInfo[] }>) => { - setTenants(event.detail.tenants); - setIsTenantSwitcherOpen(true); - }; - - window.addEventListener("open-support-dialog", handleOpenSupport); - window.addEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); - return () => { - window.removeEventListener("open-support-dialog", handleOpenSupport); - window.removeEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); - }; - }, []); - - const handleTenantSwitch = async (tenant: TenantInfo) => { - if (tenant.tenantId === currentTenantId || tenant.isNew) { - return; - } - - trackInteraction("Switch account", "interaction"); - setIsSwitching(true); - setIsTenantSwitcherOpen(false); +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; - try { - localStorage.setItem("preferred-tenant", tenant.tenantId); - if (tenant.tenantName) { - localStorage.setItem(`tenant-name-${tenant.tenantId}`, tenant.tenantName); - } - - await switchTenantApi(tenant.tenantId); - - if (userInfo?.tenantId && userInfo?.id) { - const message: Omit = { - type: "TENANT_SWITCHED", - newTenantId: tenant.tenantId, - previousTenantId: userInfo.tenantId, - tenantName: tenant.tenantName || t`Unnamed account`, - userId: userInfo.id - }; - authSyncService.broadcast(message); - } - - if (window.location.pathname === "/") { - window.location.href = loggedInPath; - } else { - window.location.reload(); - } - } catch { - setIsSwitching(false); - } - }; - - return ( - <> - - {isSwitching && } - - - ); -} +import { fetchTenants, type TenantInfo } from "../common/tenantUtils"; +import { MobileMenuContent } from "./MobileMenuContent"; +import { MobileMenuDialogs } from "./MobileMenuDialogs"; export interface MobileMenuProps { onNavigate?: (path: string) => void; @@ -110,7 +31,7 @@ export interface MobileMenuProps { // Rich mobile navigation surface rendered inside the Sidebar's mobile Sheet. Shows tenant info, // user actions, tenant switcher, navigation links, and a support button. Federated so both the // Main and Account apps can reuse it inside their mobile sidebars. -export default function MobileMenu({ onNavigate }: Readonly) { +function MobileMenu({ onNavigate }: Readonly) { const userInfo = useUserInfo(); const overlayCtx = useContext(overlayContext); const [tenants, setTenants] = useState([]); @@ -221,10 +142,11 @@ export default function MobileMenu({ onNavigate }: Readonly) { - {/* Mounts the SupportDialog + TenantSwitcherDrawer event listeners. On desktop these are - mounted inside UserMenu (in the sidebar header), but the header isn't rendered in the - mobile overlay, so we mount them here too. */} + {/* Mounts the SupportDialog + TenantSwitcherDrawer event listeners. On desktop these mount + inside UserMenu's sidebar header, which the mobile overlay omits, so we mount them here too. */} ); } + +export default withAccountTranslations(MobileMenu); diff --git a/application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx b/application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx new file mode 100644 index 0000000000..bda16f5cfe --- /dev/null +++ b/application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx @@ -0,0 +1,91 @@ +import { t } from "@lingui/core/macro"; +import { trackInteraction } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; +import { authSyncService, type TenantSwitchedMessage } from "@repo/infrastructure/auth/AuthSyncService"; +import { loggedInPath } from "@repo/infrastructure/auth/constants"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { useEffect, useState } from "react"; + +import { SupportDialog } from "../common/SupportDialog"; +import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; +import { switchTenantApi, type TenantInfo } from "../common/tenantUtils"; +import { TenantSwitcherDrawer } from "./TenantSwitcherDrawer"; + +// Mounts the SupportDialog + TenantSwitcherDrawer and their global event listeners. Rendered by both the +// mobile menu (MobileMenu) and, on desktop, the sidebar header (UserMenu) — the two surfaces where a user +// can open the support dialog or switch tenants. +export function MobileMenuDialogs() { + const userInfo = useUserInfo(); + const [isTenantSwitcherOpen, setIsTenantSwitcherOpen] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [isSupportDialogOpen, setIsSupportDialogOpen] = useState(false); + const [tenants, setTenants] = useState([]); + + const currentTenantId = userInfo?.tenantId; + + useEffect(() => { + const handleOpenSupport = () => setIsSupportDialogOpen(true); + const handleOpenTenantSwitcher = (event: CustomEvent<{ tenants: TenantInfo[] }>) => { + setTenants(event.detail.tenants); + setIsTenantSwitcherOpen(true); + }; + + window.addEventListener("open-support-dialog", handleOpenSupport); + window.addEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); + return () => { + window.removeEventListener("open-support-dialog", handleOpenSupport); + window.removeEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); + }; + }, []); + + const handleTenantSwitch = async (tenant: TenantInfo) => { + if (tenant.tenantId === currentTenantId || tenant.isNew) { + return; + } + + trackInteraction("Switch account", "interaction"); + setIsSwitching(true); + setIsTenantSwitcherOpen(false); + + try { + localStorage.setItem("preferred-tenant", tenant.tenantId); + if (tenant.tenantName) { + localStorage.setItem(`tenant-name-${tenant.tenantId}`, tenant.tenantName); + } + + await switchTenantApi(tenant.tenantId); + + if (userInfo?.tenantId && userInfo?.id) { + const message: Omit = { + type: "TENANT_SWITCHED", + newTenantId: tenant.tenantId, + previousTenantId: userInfo.tenantId, + tenantName: tenant.tenantName || t`Unnamed account`, + userId: userInfo.id + }; + authSyncService.broadcast(message); + } + + if (window.location.pathname === "/") { + window.location.href = loggedInPath; + } else { + window.location.reload(); + } + } catch { + setIsSwitching(false); + } + }; + + return ( + <> + + {isSwitching && } + + + ); +} diff --git a/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx b/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx index b18f6949e8..a163b40740 100644 --- a/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx +++ b/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx @@ -10,6 +10,8 @@ import { Logo } from "@repo/ui/components/Logo"; import { AlertTriangleIcon, LogOut } from "lucide-react"; import { useContext, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -75,7 +77,7 @@ function SuspendedNavigation() { ); } -export default function SuspendedPage() { +function SuspendedPage() { const userInfo = useUserInfo(); const isOwner = userInfo?.role === "Owner"; @@ -121,3 +123,5 @@ export default function SuspendedPage() { ); } + +export default withAccountTranslations(SuspendedPage); diff --git a/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx b/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx index fe0141b950..95f15e21c9 100644 --- a/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx +++ b/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx @@ -9,7 +9,7 @@ interface TenantStateGuardProps { pathname: string; } -export default function TenantStateGuard({ children, pathname }: Readonly) { +function TenantStateGuard({ children, pathname }: Readonly) { const { data: tenant } = api.useQuery("get", "/api/account/tenants/current"); const isBillingPage = pathname.startsWith("/account/billing"); @@ -20,3 +20,8 @@ export default function TenantStateGuard({ children, pathname }: Readonly). Wrapping it would gate host content behind the account remote's +// catalog load. The one account UI it can render, , self-wraps its own translations. +export default TenantStateGuard; diff --git a/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx b/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx index 8ef9ed82cc..164ea4e2d5 100644 --- a/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx +++ b/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx @@ -16,11 +16,12 @@ import { ChevronsUpDownIcon } from "lucide-react"; import { useContext, useEffect, useState } from "react"; import { MainNavigationContext } from "@/shared/hooks/useMainNavigation"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; import { SupportDialog } from "../common/SupportDialog"; import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; import { logoutApi } from "../common/tenantUtils"; -import { MobileMenuDialogs } from "../sideMenu/MobileMenu"; +import { MobileMenuDialogs } from "../sideMenu/MobileMenuDialogs"; import { UserMenuDropdownContent } from "./UserMenuDropdownContent"; import { useSidebarWidth } from "./useSidebarWidth"; import { useUserMenuTenants } from "./useUserMenuTenants"; @@ -29,7 +30,7 @@ interface UserMenuProps { isCollapsed?: boolean; } -export default function UserMenu({ isCollapsed: isCollapsedProp }: Readonly) { +function UserMenu({ isCollapsed: isCollapsedProp }: Readonly) { const userInfo = useUserInfo(); const isCollapsedContext = useContext(collapsedContext); const isCollapsed = isCollapsedProp ?? isCollapsedContext; @@ -169,3 +170,5 @@ export default function UserMenu({ isCollapsed: isCollapsedProp }: Readonly ); } + +export default withAccountTranslations(UserMenu); diff --git a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx index afedb5f6d9..71fae9b08f 100644 --- a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx +++ b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx @@ -1,7 +1,7 @@ import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; import { trackInteraction } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { MoonStarIcon, SunMoonIcon } from "lucide-react"; import { useTheme } from "next-themes"; @@ -34,7 +34,9 @@ export const ThemeMode = { export function usePreferences() { const { theme, setTheme, resolvedTheme } = useTheme(); - const [currentLocale, setCurrentLocale] = useState("en-US"); + // Active locale comes from the shared i18n singleton so the radio stays correct regardless of how the + // locale changed (this screen, the language switcher, an auth refresh); no local mirror to keep in sync. + const currentLocale = useLingui().i18n.locale as Locale; const [currentZoomLevel, setCurrentZoomLevel] = useState(defaultZoomValue); const { setLocale } = use(translationContext); @@ -54,15 +56,6 @@ export function usePreferences() { }); useEffect(() => { - const htmlLang = document.documentElement.lang as Locale; - const savedLocale = localStorage.getItem(preferredLocaleKey) as Locale; - - if (savedLocale && locales.some((l) => l.id === savedLocale)) { - setCurrentLocale(savedLocale); - } else if (htmlLang && locales.some((l) => l.id === htmlLang)) { - setCurrentLocale(htmlLang); - } - const savedZoomLevel = localStorage.getItem(zoomLevelStorageKey); if (savedZoomLevel && validZoomValues.includes(savedZoomLevel)) { setCurrentZoomLevel(savedZoomLevel); @@ -76,14 +69,11 @@ export function usePreferences() { const localeLabel = locales.find((l) => l.id === locale)?.label ?? locale; trackInteraction("User preferences", "interaction", `Change language to "${localeLabel}"`); - localStorage.setItem(preferredLocaleKey, locale); changeLocaleMutation.mutate( { body: { locale } }, { onSuccess: async () => { - document.documentElement.lang = locale; await setLocale(locale); - setCurrentLocale(locale); } } ); diff --git a/application/account/WebApp/rsbuild.config.ts b/application/account/WebApp/rsbuild.config.ts index 0f4e495693..b4450365f3 100644 --- a/application/account/WebApp/rsbuild.config.ts +++ b/application/account/WebApp/rsbuild.config.ts @@ -7,7 +7,6 @@ import { RunTimeEnvironmentPlugin } from "@repo/build/plugin/RunTimeEnvironmentP import { TailwindPlugin } from "@repo/build/plugin/TailwindPlugin"; import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; @@ -45,13 +44,10 @@ export default defineConfig({ pluginReact(), pluginTypeCheck(), pluginSvgr(), - pluginSourceBuild({ - sourceField: "source" - }), FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv, { federationOnly: true }), LinguiPlugin(), - DevelopmentServerPlugin({ port: accountStaticPort, liveReload: false }), + DevelopmentServerPlugin({ port: accountStaticPort, liveReload: false, hmrPath: "/account/rsbuild-hmr" }), ModuleFederationPlugin({ exposes: { "./AccessDeniedPage": "./federated-modules/errorPages/AccessDeniedPage.tsx", @@ -65,9 +61,7 @@ export default defineConfig({ "./PublicNavigation": "./federated-modules/public/PublicNavigation.tsx", "./SuspendedPage": "./federated-modules/subscription/SuspendedPage.tsx", "./TenantStateGuard": "./federated-modules/subscription/TenantStateGuard.tsx", - "./UserMenu": "./federated-modules/userMenu/UserMenu.tsx", - "./translations/da-DK": "./shared/translations/locale/da-DK.ts", - "./translations/en-US": "./shared/translations/locale/en-US.ts" + "./UserMenu": "./federated-modules/userMenu/UserMenu.tsx" } }) ] diff --git a/application/account/WebApp/shared/translations/withAccountTranslations.tsx b/application/account/WebApp/shared/translations/withAccountTranslations.tsx new file mode 100644 index 0000000000..42d18516ce --- /dev/null +++ b/application/account/WebApp/shared/translations/withAccountTranslations.tsx @@ -0,0 +1,9 @@ +import { type Locale, withSystemTranslations } from "@repo/infrastructure/translations/Translation"; + +// Account's federated modules render inside other systems (e.g. main), where account has no bootstrap of +// its own. Wrapping each exposed module self-registers account's catalog into the shared translation +// dictionary and suspends until it is merged, so account strings are translated on first paint. +export const withAccountTranslations = withSystemTranslations( + "account", + (locale: Locale) => import(`@/shared/translations/locale/${locale}.ts`) +); diff --git a/application/main/WebApp/bootstrap.tsx b/application/main/WebApp/bootstrap.tsx index 3e2df29c71..44d0b6dcf2 100644 --- a/application/main/WebApp/bootstrap.tsx +++ b/application/main/WebApp/bootstrap.tsx @@ -2,7 +2,8 @@ import "@repo/ui/theme.css"; import "@repo/ui/tailwind.css"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/globalErrorHandlers"; -import { createFederatedTranslation } from "@repo/infrastructure/translations/createFederatedTranslation"; +import { RepoUiTranslation } from "@repo/infrastructure/translations/RepoUiTranslation"; +import { Translation } from "@repo/infrastructure/translations/Translation"; import { Toaster } from "@repo/ui/components/Sonner"; import { RouterProvider } from "@tanstack/react-router"; import React from "react"; @@ -10,7 +11,7 @@ import reactDom from "react-dom/client"; import { router } from "@/shared/lib/router/router"; -const { TranslationProvider } = await createFederatedTranslation( +const { TranslationProvider } = await Translation.create( (locale) => import(`@/shared/translations/locale/${locale}.ts`) ); @@ -25,10 +26,12 @@ setupGlobalErrorHandlers(); reactDom.createRoot(rootElement).render( - - - - + + + + + + ); diff --git a/application/main/WebApp/rsbuild.config.ts b/application/main/WebApp/rsbuild.config.ts index c103def91f..e979d0335c 100644 --- a/application/main/WebApp/rsbuild.config.ts +++ b/application/main/WebApp/rsbuild.config.ts @@ -8,7 +8,6 @@ import { RunTimeEnvironmentPlugin } from "@repo/build/plugin/RunTimeEnvironmentP import { TailwindPlugin } from "@repo/build/plugin/TailwindPlugin"; import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; @@ -40,9 +39,6 @@ export default defineConfig({ pluginReact(), pluginTypeCheck(), pluginSvgr(), - pluginSourceBuild({ - sourceField: "source" - }), FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv), LinguiPlugin(), diff --git a/application/package-lock.json b/application/package-lock.json index 391ea2dc0c..469b9d5da4 100644 --- a/application/package-lock.json +++ b/application/package-lock.json @@ -52,9 +52,9 @@ "@playwright/test": "1.61.0", "@rsbuild/core": "2.0.15", "@rsbuild/plugin-react": "2.1.0", - "@rsbuild/plugin-source-build": "1.0.6", "@rsbuild/plugin-svgr": "2.0.4", "@rsbuild/plugin-type-check": "1.4.0", + "@rslib/core": "0.22.1", "@rspack/binding": "2.0.8", "@tailwindcss/postcss": "4.3.1", "@tanstack/router-devtools": "1.167.0", @@ -138,6 +138,180 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ast-grep/napi": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi/-/napi-0.37.0.tgz", + "integrity": "sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ast-grep/napi-darwin-arm64": "0.37.0", + "@ast-grep/napi-darwin-x64": "0.37.0", + "@ast-grep/napi-linux-arm64-gnu": "0.37.0", + "@ast-grep/napi-linux-arm64-musl": "0.37.0", + "@ast-grep/napi-linux-x64-gnu": "0.37.0", + "@ast-grep/napi-linux-x64-musl": "0.37.0", + "@ast-grep/napi-win32-arm64-msvc": "0.37.0", + "@ast-grep/napi-win32-ia32-msvc": "0.37.0", + "@ast-grep/napi-win32-x64-msvc": "0.37.0" + } + }, + "node_modules/@ast-grep/napi-darwin-arm64": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-arm64/-/napi-darwin-arm64-0.37.0.tgz", + "integrity": "sha512-QAiIiaAbLvMEg/yBbyKn+p1gX2/FuaC0SMf7D7capm/oG4xGMzdeaQIcSosF4TCxxV+hIH4Bz9e4/u7w6Bnk3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-darwin-x64": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-x64/-/napi-darwin-x64-0.37.0.tgz", + "integrity": "sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-arm64-gnu": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-gnu/-/napi-linux-arm64-gnu-0.37.0.tgz", + "integrity": "sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-arm64-musl": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.37.0.tgz", + "integrity": "sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-x64-gnu": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.37.0.tgz", + "integrity": "sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-x64-musl": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.37.0.tgz", + "integrity": "sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-win32-arm64-msvc": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-arm64-msvc/-/napi-win32-arm64-msvc-0.37.0.tgz", + "integrity": "sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-win32-ia32-msvc": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-ia32-msvc/-/napi-win32-ia32-msvc-0.37.0.tgz", + "integrity": "sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-win32-x64-msvc": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-x64-msvc/-/napi-win32-x64-msvc-0.37.0.tgz", + "integrity": "sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1927,44 +2101,6 @@ ], "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.56.0", "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.56.0.tgz", @@ -3489,26 +3625,6 @@ } } }, - "node_modules/@rsbuild/plugin-source-build": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@rsbuild/plugin-source-build/-/plugin-source-build-1.0.6.tgz", - "integrity": "sha512-F7m5qE/VzrUTq1ZsEmithuc+yxA52Bw4+6gkxprg+RpKqgBTLuv2sSQmpaJSYZrcLlkdCWLE4bwLY0zBT6MRAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.3.3", - "json5": "^2.2.3", - "yaml": "^2.9.0" - }, - "peerDependencies": { - "@rsbuild/core": "^1.0.0 || ^2.0.0-0" - }, - "peerDependenciesMeta": { - "@rsbuild/core": { - "optional": true - } - } - }, "node_modules/@rsbuild/plugin-svgr": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@rsbuild/plugin-svgr/-/plugin-svgr-2.0.4.tgz", @@ -3557,6 +3673,35 @@ } } }, + "node_modules/@rslib/core": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@rslib/core/-/core-0.22.1.tgz", + "integrity": "sha512-RaqTITHFkpMDJG9fmD7Hu6FLE64hwctCo46asHOD2DipzQJWawg6K0pFGimTAyutYEZysIUfYgCwSYkbctDudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rsbuild/core": "~2.0.12", + "rsbuild-plugin-dts": "0.22.1" + }, + "bin": { + "rslib": "bin/rslib.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@rspack/binding": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz", @@ -5980,23 +6125,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -6013,16 +6141,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -6909,16 +7027,6 @@ "tslib": "2" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -7738,27 +7846,6 @@ "node": ">=14" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -8642,17 +8729,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -8673,28 +8749,34 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/rsbuild-plugin-dts": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/rsbuild-plugin-dts/-/rsbuild-plugin-dts-0.22.1.tgz", + "integrity": "sha512-6vBfXgwK4j6GkjSRH13tuceBavc6qwSE5IVSoVCZ/wRuM0ZL7dIjdk+yGSWKJt5lNMfc4XwQoTErGIC9zhgztQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" + "license": "MIT", + "dependencies": { + "@ast-grep/napi": "0.37.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7", + "@rsbuild/core": "^1.0.0 || ^2.0.0-0", + "@typescript/native-preview": "^7.0.0-0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "@typescript/native-preview": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "typescript": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" } }, "node_modules/sax": { @@ -9979,22 +10061,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", diff --git a/application/package.json b/application/package.json index 339b415bdb..21a3789879 100644 --- a/application/package.json +++ b/application/package.json @@ -58,9 +58,9 @@ "@playwright/test": "1.61.0", "@rsbuild/core": "2.0.15", "@rsbuild/plugin-react": "2.1.0", - "@rsbuild/plugin-source-build": "1.0.6", "@rsbuild/plugin-svgr": "2.0.4", "@rsbuild/plugin-type-check": "1.4.0", + "@rslib/core": "0.22.1", "@rspack/binding": "2.0.8", "@tailwindcss/postcss": "4.3.1", "@tanstack/router-devtools": "1.167.0", diff --git a/application/shared-webapp/build/module-federation-types/account.d.ts b/application/shared-webapp/build/module-federation-types/account.d.ts index 02b16129f5..662c637265 100644 --- a/application/shared-webapp/build/module-federation-types/account.d.ts +++ b/application/shared-webapp/build/module-federation-types/account.d.ts @@ -36,11 +36,3 @@ declare module "account/TenantStateGuard" { declare module "account/UserMenu" { export default ReactNode; } -declare module "account/translations/da-DK" { - import type { Messages } from "@lingui/core"; - export const messages: Messages; -} -declare module "account/translations/en-US" { - import type { Messages } from "@lingui/core"; - export const messages: Messages; -} diff --git a/application/shared-webapp/build/package.json b/application/shared-webapp/build/package.json index aa24d4bc85..38bfba46c6 100644 --- a/application/shared-webapp/build/package.json +++ b/application/shared-webapp/build/package.json @@ -3,6 +3,10 @@ "version": "0.0.0", "private": true, "license": "MIT", + "files": [ + "dist" + ], + "type": "module", "exports": { ".": "./dist/index.js", "./*": "./dist/*.js", @@ -12,10 +16,10 @@ "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && tsc -b . environment", + "build": "rslib build && tsc --noEmit -p environment/tsconfig.json", "check": "oxfmt --check && oxlint", - "dev": "tsc -b . environment -w", - "dev:setup": "tsc -b . environment", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings" }, diff --git a/application/shared-webapp/build/platformSettings.ts b/application/shared-webapp/build/platformSettings.ts index 94fe363f97..6c43f0b564 100644 --- a/application/shared-webapp/build/platformSettings.ts +++ b/application/shared-webapp/build/platformSettings.ts @@ -68,7 +68,7 @@ export interface PlatformSettings { // platform-settings.jsonc is the single source of truth for brand configuration, shared by the // backend (embedded resource) and the frontend (injected here at build time). It lives at the // application root; this file compiles to shared-webapp/build/dist/, three levels below it. -const settingsPath = path.join(__dirname, "..", "..", "..", "platform-settings.jsonc"); +const settingsPath = path.join(import.meta.dirname, "..", "..", "..", "platform-settings.jsonc"); export function loadPlatformSettings(): PlatformSettings { const raw = fs.readFileSync(settingsPath, "utf8"); diff --git a/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts b/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts index d3cb7eb7e2..20d07daeb7 100644 --- a/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts +++ b/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts @@ -38,6 +38,12 @@ export type DevelopmentServerPluginOptions = { * update from reloading the host page while the remote is still rebuilding. */ liveReload?: boolean; + /** + * The HMR WebSocket path. Defaults to rsbuild's `/rsbuild-hmr`. Systems that share an origin with the + * host (e.g. the `account` remote loaded into `main` under `app.dev.localhost`) must use a distinct + * path so the gateway can route each system's WebSocket to its own dev server. + */ + hmrPath?: string; }; /** @@ -101,9 +107,13 @@ export function DevelopmentServerPlugin(options: DevelopmentServerPluginOptions) } }, dev: { - client: { - port: options.port - }, + // Leave client host/port unset so the HMR WebSocket targets the page's own origin -- the + // AppGateway (app.dev.localhost: for main/account) or the BackOffice Kestrel -- + // which forwards the upgrade to this dev server, matching how every other dev asset is served. + // rsbuild's client then uses location.host/port instead of pinning the dev server's own port, + // which would bypass the gateway. Co-hosted systems (main + the account remote) share one + // origin, so each passes a distinct hmrPath for the gateway to route each WebSocket on. + client: options.hmrPath ? { path: options.hmrPath } : {}, liveReload: options.liveReload ?? true, // Set publicPath to auto to enable the server to serve the files assetPrefix: "auto", diff --git a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts index 2491f0db90..f798387b3d 100644 --- a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts +++ b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts @@ -14,7 +14,7 @@ if (!fs.existsSync(applicationPackageJson)) { throw new Error(`Cannot find package.json in the application root: ${applicationRoot}`); } -const { dependencies } = require(applicationPackageJson); +const { dependencies } = JSON.parse(fs.readFileSync(applicationPackageJson, "utf-8")); const SYSTEM_ID = getSystemId(); @@ -48,6 +48,17 @@ export function ModuleFederationPlugin({ "react-dom": { singleton: true, requiredVersion: dependencies["react-dom"] + }, + // Lingui's `i18n` is a module-level singleton exported from @lingui/core. It must be a + // single instance across every remote so the merged catalog activated by one system is + // the same object every other system's ``/`useLingui` reads from. + "@lingui/core": { + singleton: true, + requiredVersion: dependencies["@lingui/core"] + }, + "@lingui/react": { + singleton: true, + requiredVersion: dependencies["@lingui/react"] } } } @@ -113,14 +124,7 @@ function generateModuleFederationTypesFolder(system: string, exposes: Record { logger.info(`[Module Federation] Expose: ${exportPath} => ${importPath}`); - // Pattern matching for different module types - // Translation files follow the pattern: ./translations/xx-XX (e.g., ./translations/en-US) - const translationPattern = /^\.\/translations\/[a-z]{2}-[A-Z]{2}$/; - if (translationPattern.test(exportPath)) { - return `declare module "${exportPath.replace(/^\./, system)}" {\n import type { Messages } from "@lingui/core";\n export const messages: Messages;\n}`; - } - - // Default to ReactNode export for components + // Every exposed federated module is a React component. return `declare module "${exportPath.replace(/^\./, system)}" {\n export default ReactNode;\n}`; }) .join("\n"); diff --git a/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts b/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts index 0c02f0b47a..488b5316f1 100644 --- a/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts +++ b/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts @@ -28,7 +28,7 @@ export function RunTimeEnvironmentPlugin> source: { entry: { // Add the runtime environment file as the first entry point - index: [path.join(__dirname, "..", "environment", "runtime.js"), "./main.tsx"] + index: [path.join(import.meta.dirname, "..", "environment", "runtime.js"), "./main.tsx"] }, // Define the runtime environment variables as part of import.meta.* // The method getApplicationEnvironment() is defined in the runtime diff --git a/application/shared-webapp/build/plugin/TailwindPlugin.ts b/application/shared-webapp/build/plugin/TailwindPlugin.ts index 94b97f5868..8e720c8a81 100644 --- a/application/shared-webapp/build/plugin/TailwindPlugin.ts +++ b/application/shared-webapp/build/plugin/TailwindPlugin.ts @@ -8,7 +8,7 @@ import path from "node:path"; /** * Path to the shared Tailwind CSS styles file. */ -const tailwindStyleFilename = path.resolve(__dirname, "..", "..", "..", "ui", "tailwind.css"); +const tailwindStyleFilename = path.resolve(import.meta.dirname, "..", "..", "..", "ui", "tailwind.css"); if (fs.existsSync(tailwindStyleFilename) === false) { // This is a critical error, so we exit the process letting the developer know. diff --git a/application/shared-webapp/build/rslib.config.ts b/application/shared-webapp/build/rslib.config.ts new file mode 100644 index 0000000000..c29ac89afc --- /dev/null +++ b/application/shared-webapp/build/rslib.config.ts @@ -0,0 +1,22 @@ +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: ["./**/*.tsx", "./**/*.ts", "./**/*.svg", "./**/*.css", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web" + }, + plugins: [pluginReact(), pluginTypeCheck()] +}); diff --git a/application/shared-webapp/build/tsconfig.json b/application/shared-webapp/build/tsconfig.json index 800ce20eeb..2d407e268f 100644 --- a/application/shared-webapp/build/tsconfig.json +++ b/application/shared-webapp/build/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["node_modules", "dist", "environment"] + "exclude": ["node_modules", "dist", "environment", "rslib.config.ts"] } diff --git a/application/shared-webapp/emails/build/build.ts b/application/shared-webapp/emails/build/build.ts index a82adcd0e6..bb164b674f 100644 --- a/application/shared-webapp/emails/build/build.ts +++ b/application/shared-webapp/emails/build/build.ts @@ -10,7 +10,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createElement } from "react"; -import i18nConfig from "../../infrastructure/translations/i18n.config.json" with { type: "json" }; +import i18nConfig from "../../infrastructure/translations/i18n.config"; process.env.EMAIL_RENDER_MODE = "build"; diff --git a/application/shared-webapp/emails/lingui.factory.ts b/application/shared-webapp/emails/lingui.factory.ts index 893a9d0a13..5b290b9ba4 100644 --- a/application/shared-webapp/emails/lingui.factory.ts +++ b/application/shared-webapp/emails/lingui.factory.ts @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf"; import { formatter } from "@lingui/format-po"; -import i18nConfig from "../infrastructure/translations/i18n.config.json"; +import i18nConfig from "../infrastructure/translations/i18n.config"; // Factory used by every system's emails/lingui.config.ts. Lingui resolves relative to the // directory containing the lingui.config file, which for emails is always /WebApp/emails. diff --git a/application/shared-webapp/infrastructure/package.json b/application/shared-webapp/infrastructure/package.json index ce666af1d0..89a42c1bdd 100644 --- a/application/shared-webapp/infrastructure/package.json +++ b/application/shared-webapp/infrastructure/package.json @@ -3,46 +3,22 @@ "version": "0.0.0", "private": true, "license": "MIT", - "source": "./index.ts", - "main": "./dist/index.js", + "files": [ + "dist" + ], + "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "source": "./index.ts", - "default": "./dist/index.js" - }, - "./auth/*": { - "types": "./dist/auth/*.d.ts", - "source": "./auth/*", - "default": "./dist/auth/*" - }, - "./http/*": { - "types": "./dist/http/*.d.ts", - "source": "./http/*", - "default": "./dist/http/*" - }, - "./applicationInsights/*": { - "types": "./dist/applicationInsights/*.d.ts", - "source": "./applicationInsights/*", - "default": "./dist/applicationInsights/*" - }, - "./translations/*": { - "types": "./dist/translations/*.d.ts", - "source": "./translations/*", - "default": "./dist/translations/*" - }, "./*": { "types": "./dist/*.d.ts", - "source": "./*", - "default": "./dist/*" + "import": "./dist/*.js" }, "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && tsc -p .", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "dev": "tsc -p . -w", - "dev:setup": "tsc -p .", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings" }, diff --git a/application/shared-webapp/infrastructure/rslib.config.ts b/application/shared-webapp/infrastructure/rslib.config.ts new file mode 100644 index 0000000000..868cb93a2f --- /dev/null +++ b/application/shared-webapp/infrastructure/rslib.config.ts @@ -0,0 +1,22 @@ +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: ["./**/*.tsx", "./**/*.ts", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web" + }, + plugins: [pluginReact(), pluginTypeCheck()] +}); diff --git a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx index 7e378530b8..b0951fb5f9 100644 --- a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx +++ b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx @@ -10,7 +10,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { CheckIcon, GlobeIcon } from "lucide-react"; import { use, useMemo } from "react"; -import { preferredLocaleKey } from "./constants"; import { type Locale, translationContext } from "./TranslationContext"; export function LocaleSwitcher({ "aria-label": ariaLabel }: Readonly<{ "aria-label": string }>) { @@ -28,9 +27,7 @@ export function LocaleSwitcher({ "aria-label": ariaLabel }: Readonly<{ "aria-lab const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx b/application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx new file mode 100644 index 0000000000..a6bcb1c1cf --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx @@ -0,0 +1,10 @@ +import { loadUiTranslations } from "@repo/ui/translations/loadUiTranslations"; + +import { createSystemTranslationProvider } from "./Translation"; + +// App-level provider that contributes `@repo/ui`'s own catalog to the shared translation dictionary. +// Applications render `` around their tree so the shared component library's strings +// are registered and loaded — without the host having to know they exist. It lives here (not in +// `@repo/ui`) because the translation registry is in `@repo/infrastructure`, and `@repo/ui` sits below +// infrastructure in the dependency graph; `@repo/ui` owns the catalog and the loader, this owns the wiring. +export const RepoUiTranslation = createSystemTranslationProvider("shared-ui", loadUiTranslations); diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index 6dbe0b6873..67b3723cfc 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -1,11 +1,13 @@ import type React from "react"; import { i18n, type Messages } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { useEffect, useMemo, useState } from "react"; +import { I18nProvider, useLingui } from "@lingui/react"; +import { type ComponentType, Suspense, useEffect, useMemo, useState } from "react"; -import localeMap from "./i18n.config.json"; -import { type TranslationContext, translationContext } from "./TranslationContext"; +import { ensureSystemActive, hasFailed, isLoaded, loadAllAndActivate, registerCatalog } from "./catalogStore"; +import { persistPreferredLocale, preferredLocaleKey } from "./constants"; +import localeMap from "./i18n.config"; +import { type TranslationContext as TranslationContextValue, translationContext } from "./TranslationContext"; export type Locale = keyof typeof localeMap; @@ -26,128 +28,72 @@ export type LocalLoaderFunction = (locale: Locale) => Promise; const TranslationContextProvider = translationContext.Provider; -export class Translation { - private readonly _messageCache = new Map(); - private readonly _defaultLocale = document.documentElement.lang as Locale; +export const locales = Object.keys(localeMap) as Locale[]; - /** - * Prefer using `TranslationConfig.create` instead of this constructor - */ - private readonly localeLoader: LocalLoaderFunction; - - constructor(localeLoader: LocalLoaderFunction) { - this.localeLoader = localeLoader; - } - - private readonly _locales: Locale[] = Object.keys(localeMap) as Locale[]; - - /** - * Get the list of available locales - */ - public get locales(): Locale[] { - return this._locales; - } +export function getLocaleInfo(locale: Locale): LocaleInfo { + return localeMap[locale]; +} - /** - * Create a new TranslationConfig instance and load the initial locale - */ - public static async create(localeLoader: LocalLoaderFunction): Promise { - const config = new Translation(localeLoader); - await config.dynamicActivate(); - return config; - } +export function isLocale(value: string): value is Locale { + return value in localeMap; +} - /** - * Load and activate the given locale - */ - public async dynamicActivate(newLocale?: string | undefined) { - const locale = this.getLocale(newLocale); - const { messages } = await this.loadCatalog(locale); - i18n.loadAndActivate({ locale: locale as string, messages }); +/** + * Resolve the initial locale before the first render. Authenticated users follow the server-rendered + * `` (set from their JWT/DB locale) -- it is authoritative. Anonymous users, whose stored + * preference the server cannot know, have that choice applied here so the first paint is already in the + * right locale rather than flashing the default until `useInitializeLocale` runs post-mount. + */ +function resolveInitialLocale(): Locale { + const serverLocale = document.documentElement.lang; + if (import.meta.user_info_env.isAuthenticated && isLocale(serverLocale)) { + return serverLocale; } - - /** - * Get the locale info for the given locale - */ - public getLocaleInfo(locale: Locale): LocaleInfo { - return localeMap[locale]; + const storedLocale = localStorage.getItem(preferredLocaleKey); + if (storedLocale && isLocale(storedLocale)) { + return storedLocale; } - - /** - * This component should be used as a wrapper around the application to provide - * the translation context to the rest of the application - * - * @param children The children to render - */ - public TranslationProvider = ({ children }: { children: React.ReactNode }) => { - return {children}; - }; - - /** - * Get the locale for the application - */ - private getLocale(locale?: string): Locale { - if (locale && this.isLocale(locale)) { - return locale; - } - if (import.meta.env.LOCALE && this.isLocale(import.meta.env.LOCALE)) { - return import.meta.env.LOCALE; - } - return this._defaultLocale; + if (isLocale(serverLocale)) { + return serverLocale; } - - /** - * Load the catalog for the given locale - */ - private async loadCatalog(locale: Locale): Promise { - const existingLocaleFile = this._messageCache.get(locale); - if (existingLocaleFile) { - return existingLocaleFile; - } - - const messageFile = await this.localeLoader(locale); - this._messageCache.set(locale, messageFile); - - return messageFile; + if (import.meta.env.LOCALE && isLocale(import.meta.env.LOCALE)) { + return import.meta.env.LOCALE; } + return locales[0]; +} - /** - * Assert that the given string is a valid locale - */ - private isLocale(locale: string): locale is Locale { - return locale in localeMap; +/** Change the active locale for every registered system and persist the choice. */ +export function setLocale(locale: string): Promise { + if (!isLocale(locale)) { + return Promise.resolve(); } + persistPreferredLocale(locale); + return loadAllAndActivate(locale); } -type TranslationProviderProps = { - translation: Translation; - children: React.ReactNode; -}; +function TranslationProvider({ children }: { children: React.ReactNode }) { + // Re-key the provider on locale change so the whole subtree remounts. Plain `t` macro strings read the + // global i18n but don't subscribe to context updates, so without a remount their labels stay stale + // after a locale switch while ``/`useLingui` consumers update. + const [currentLocale, setCurrentLocale] = useState(() => i18n.locale as Locale); -function TranslationProvider({ children, translation }: Readonly) { - const [currentLocale, setCurrentLocale] = useState(i18n.locale as Locale); - - const value: TranslationContext = useMemo( - () => ({ - currentLocale, - setLocale: async (locale: string) => { - await translation.dynamicActivate(locale); - setCurrentLocale(locale as Locale); // Update state to force re-render - }, - locales: translation.locales, - getLocaleInfo: translation.getLocaleInfo - }), - [translation, currentLocale] - ); + useEffect(() => { + const unsubscribe = i18n.on("change", () => setCurrentLocale(i18n.locale as Locale)); + return unsubscribe; + }, []); useEffect(() => { - const handleLocaleChangeRequest = async (event: Event) => { - const locale = (event as CustomEvent).detail.locale; - await value.setLocale(locale); + const handleLocaleChangeRequest = (event: Event) => { + void setLocale((event as CustomEvent).detail.locale); }; document.addEventListener("locale-change-request", handleLocaleChangeRequest); return () => document.removeEventListener("locale-change-request", handleLocaleChangeRequest); - }, [value]); + }, []); + + const value: TranslationContextValue = useMemo( + () => ({ currentLocale, setLocale, locales, getLocaleInfo }), + [currentLocale] + ); return ( @@ -157,3 +103,76 @@ function TranslationProvider({ children, translation }: Readonly ); } + +export const Translation = { + /** + * Bootstrap translations for a host SPA: register the shared-UI and host catalogs, then load and + * activate the initial locale before the app renders. + */ + async create(ownLoader: LocalLoaderFunction): Promise<{ TranslationProvider: typeof TranslationProvider }> { + registerCatalog("host", ownLoader); + await loadAllAndActivate(resolveInitialLocale()); + return { TranslationProvider }; + } +}; + +/** + * Blocks its subtree until `systemId`'s catalog for the active locale has merged into the shared + * dictionary — suspending (via a thrown promise) while it loads. Shared by the federated-module HOC and + * the app-level package provider below. + */ +function SystemTranslationGate({ + systemId, + loader, + children +}: { + systemId: string; + loader: LocalLoaderFunction; + children: React.ReactNode; +}) { + const { i18n: activeI18n } = useLingui(); + const locale = activeI18n.locale as Locale; + if (!isLoaded(systemId, locale) && !hasFailed(systemId, locale)) { + throw ensureSystemActive(systemId, loader, locale); + } + return <>{children}; +} + +/** + * Wrap a federated module so it contributes its own catalog to the shared dictionary. The system + * self-registers (no host needs to know it exists) and suspends until its catalog for the active locale + * is merged, preventing a flash of untranslated content inside the federated subtree. + */ +export function withSystemTranslations(systemId: string, loader: LocalLoaderFunction) { + registerCatalog(systemId, loader); + return function wrap

(Component: ComponentType

) { + return function WithSystemTranslations(props: P) { + return ( + + + + + + ); + }; + }; +} + +/** + * Build an app-level provider that contributes a bundled shared-webapp package's own catalog to the + * shared dictionary. Unlike a federated module (which decorates each exposed export), a bundled package + * is used throughout the app, so the application renders this provider once around its tree. The package + * owns its loader; this only registers it and gates rendering until it's merged. + */ +export function createSystemTranslationProvider(systemId: string, loader: LocalLoaderFunction) { + registerCatalog(systemId, loader); + return function SystemTranslationProvider({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + }; +} diff --git a/application/shared-webapp/infrastructure/translations/catalogStore.ts b/application/shared-webapp/infrastructure/translations/catalogStore.ts new file mode 100644 index 0000000000..035af39a7d --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/catalogStore.ts @@ -0,0 +1,135 @@ +import { i18n, type Messages } from "@lingui/core"; + +import type { Locale, LocalLoaderFunction } from "./Translation"; + +/** + * The single point of coordination for translations across all self-contained systems. + * + * Only the global Lingui `i18n` object (shared as a module-federation singleton) and `globalThis` are + * guaranteed to be single instances across remotes -- `@repo/*` packages are compiled per-remote, so a + * module-level store would be duplicated. Every system registers a loader for its own catalog; catalogs + * merge into one shared dictionary in registration order and activate on the shared `i18n`. + */ +type TranslationStore = { + activeLocale: Locale | null; + requestedLocale: Locale | null; + loaders: Map; + systemMessages: Map; + merged: Map; + systemLoaded: Map>; + failed: Set; + loadInflight: Map>; + activateInflight: Map>; +}; + +const store: TranslationStore = ((globalThis as Record).__appTranslation ??= { + activeLocale: null, + requestedLocale: null, + loaders: new Map(), + systemMessages: new Map(), + merged: new Map(), + systemLoaded: new Map(), + failed: new Set(), + loadInflight: new Map(), + activateInflight: new Map() +} satisfies TranslationStore) as TranslationStore; + +export function registerCatalog(systemId: string, loader: LocalLoaderFunction): void { + if (!store.loaders.has(systemId)) { + store.loaders.set(systemId, loader); + } +} + +export function isLoaded(systemId: string, locale: Locale): boolean { + return store.systemLoaded.get(systemId)?.has(locale) ?? false; +} + +/** A prior load attempt failed; the Suspense gate renders untranslated instead of re-suspending forever. */ +export function hasFailed(systemId: string, locale: Locale): boolean { + return store.failed.has(`${systemId}:${locale}`); +} + +function markLoaded(systemId: string, locale: Locale): void { + let set = store.systemLoaded.get(systemId); + if (!set) { + set = new Set(); + store.systemLoaded.set(systemId, set); + } + set.add(locale); +} + +/** + * Rebuild the merged dictionary for a locale in loader-registration order (shared-ui < host < remotes), + * so precedence is deterministic regardless of which dynamic import happens to resolve first. + */ +function rebuildMerged(locale: Locale): Messages { + const merged: Messages = {}; + for (const systemId of store.loaders.keys()) { + const messages = store.systemMessages.get(`${systemId}:${locale}`); + if (messages) { + Object.assign(merged, messages); + } + } + store.merged.set(locale, merged); + return merged; +} + +/** Load a system's catalog for a locale (no activation). Idempotent; a failed load stays retryable. */ +function loadSystemLocale(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { + if (isLoaded(systemId, locale)) { + return Promise.resolve(); + } + const key = `${systemId}:${locale}`; + const existing = store.loadInflight.get(key); + if (existing) { + return existing; + } + const promise = loader(locale) + .then(({ messages }) => { + store.systemMessages.set(key, messages); + store.failed.delete(key); + markLoaded(systemId, locale); + }) + .catch((error) => { + // A single system's catalog failing must not reject (that would crash the Suspense boundary or abort a + // locale switch for every other system). Record the failure so the gate degrades to untranslated, but + // do NOT mark it loaded -- the next locale activation retries it once the inflight entry is cleared. + console.error(`Failed to load translations for "${systemId}" (${locale})`, error); + store.failed.add(key); + }) + .finally(() => { + store.loadInflight.delete(key); + }); + store.loadInflight.set(key, promise); + return promise; +} + +/** Load every registered system's catalog for the locale, then activate the merged dictionary once. */ +export async function loadAllAndActivate(locale: Locale): Promise { + store.requestedLocale = locale; + await Promise.all([...store.loaders].map(([systemId, loader]) => loadSystemLocale(systemId, loader, locale))); + // A newer switch superseded this one while awaiting; let it win so a slow earlier load can't clobber it. + if (store.requestedLocale !== locale) { + return; + } + store.activeLocale = locale; + document.documentElement.lang = locale; + i18n.loadAndActivate({ locale, messages: rebuildMerged(locale) }); +} + +/** Merge a single system's catalog into the currently active locale (used when it mounts late). */ +export function ensureSystemActive(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { + const key = `${systemId}:${locale}`; + const existing = store.activateInflight.get(key); + if (existing) { + return existing; + } + const promise = loadSystemLocale(systemId, loader, locale).then(() => { + store.activateInflight.delete(key); + if (store.activeLocale === locale) { + i18n.loadAndActivate({ locale, messages: rebuildMerged(locale) }); + } + }); + store.activateInflight.set(key, promise); + return promise; +} diff --git a/application/shared-webapp/infrastructure/translations/constants.ts b/application/shared-webapp/infrastructure/translations/constants.ts index eefee14bb0..0830366de3 100644 --- a/application/shared-webapp/infrastructure/translations/constants.ts +++ b/application/shared-webapp/infrastructure/translations/constants.ts @@ -1 +1,8 @@ export const preferredLocaleKey = "preferred-locale"; + +/** Persist the user's explicit locale choice so it survives reloads. Always store it -- an anonymous + * user's server-rendered default is derived from Accept-Language (`UserInfo.GetValidLocale(browserLocale)`), + * so it is not necessarily the config default; clearing "the default" would silently drop a real choice. */ +export function persistPreferredLocale(locale: string): void { + localStorage.setItem(preferredLocaleKey, locale); +} diff --git a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts b/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts deleted file mode 100644 index c4e13a7d95..0000000000 --- a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Messages } from "@lingui/core"; - -import { type Locale, type LocaleFile, Translation } from "./Translation"; - -// Module federation container type -type FederatedContainer = { - get(module: string): Promise<() => { messages: Messages }>; -}; - -// Cache for loaded translation modules -const translationModuleCache = new Map(); - -/** - * Configuration for federated translations - * Each application that consumes federated modules should configure - * which remotes might provide translations - */ -const FEDERATED_TRANSLATION_REMOTES = [ - "account" - // Add more remotes here as they are created -] as const; - -/** - * Creates a Translation instance that automatically loads and merges translations - * from all federated modules configured in the current application. - * - * This function: - * 1. Uses the base translation loader for the host application - * 2. Automatically discovers and loads translations from configured federated remotes - * 3. Merges all translations together with remote translations taking precedence - * - * @param baseLoader - Function to load base translations for the host application - * @returns Translation instance with federated translation support - */ -export function createFederatedTranslation(baseLoader: (locale: Locale) => Promise): Promise { - const federatedLoader = createFederatedLoader(baseLoader); - return Translation.create(federatedLoader); -} - -/** - * Try to load translations from a federated module - */ -async function loadRemoteTranslations(remoteName: string, locale: Locale): Promise { - // Check cache first - const cacheKey = `${remoteName}:${locale}`; - const cached = translationModuleCache.get(cacheKey); - if (cached) { - return cached; - } - - // Get container using RSBuild's naming convention (hyphens to underscores) - const containerName = remoteName.replace(/-/g, "_"); - const container = (window as unknown as Record)[containerName] as FederatedContainer | null; - - if (!container?.get) { - return null; - } - - try { - const factory = await container.get(`./translations/${locale}`); - const module = factory(); - - if (module?.messages) { - translationModuleCache.set(cacheKey, module.messages); - return module.messages; - } - } catch { - // Silently fail - the remote might not have translations for this locale - } - - return null; -} - -/** - * Load translations from `@repo/ui` (the shared component library catalog). - * These are merged underneath the host SPA's own messages so the host can override. - */ -async function loadSharedTranslations(locale: Locale): Promise { - try { - const module = await import(`@repo/ui/translations/locale/${locale}.ts`); - return module?.messages ?? null; - } catch { - return null; - } -} - -/** - * Creates a translation loader that merges translations from federated modules - */ -function createFederatedLoader( - baseLoader: (locale: Locale) => Promise -): (locale: Locale) => Promise { - return async (locale: Locale): Promise => { - const [sharedMessages, baseMessages] = await Promise.all([loadSharedTranslations(locale), baseLoader(locale)]); - - // Precedence (last write wins): shared < own < federated remote - const allMessages = { ...sharedMessages, ...baseMessages.messages }; - - await Promise.all( - FEDERATED_TRANSLATION_REMOTES.map(async (remoteName) => { - const remoteMessages = await loadRemoteTranslations(remoteName, locale); - if (remoteMessages) { - Object.assign(allMessages, remoteMessages); - } - }) - ); - - return { messages: allMessages }; - }; -} diff --git a/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts b/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts index 2d2e500f6f..0c2002029d 100644 --- a/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts +++ b/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf"; import { formatter } from "@lingui/format-po"; -import i18nConfig from "./i18n.config.json"; +import i18nConfig from "./i18n.config"; export function createLinguiConfig(): LinguiConfig { return { diff --git a/application/shared-webapp/infrastructure/translations/i18n.config.json b/application/shared-webapp/infrastructure/translations/i18n.config.json deleted file mode 100644 index 81be396968..0000000000 --- a/application/shared-webapp/infrastructure/translations/i18n.config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "en-US": { - "locale": "en-US", - "label": "English", - "territory": "US", - "rtl": false - }, - "da-DK": { - "locale": "da-DK", - "label": "Dansk", - "territory": "DK", - "rtl": false - } -} diff --git a/application/shared-webapp/infrastructure/translations/i18n.config.ts b/application/shared-webapp/infrastructure/translations/i18n.config.ts new file mode 100644 index 0000000000..19362cacbe --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/i18n.config.ts @@ -0,0 +1,8 @@ +// Single source of truth for the set of supported locales and their display metadata. +// `sourceLocale` is en-US; every locale listed here must have a `.po` catalog in each system. +const i18nConfig = { + "en-US": { locale: "en-US", label: "English", territory: "US", rtl: false }, + "da-DK": { locale: "da-DK", label: "Dansk", territory: "DK", rtl: false } +}; + +export default i18nConfig; diff --git a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts index 598f4bd455..984e91c630 100644 --- a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts +++ b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts @@ -1,9 +1,9 @@ +import { i18n } from "@lingui/core"; import { useContext, useEffect } from "react"; -import type { Locale } from "./Translation"; - import { AuthenticationContext } from "../auth/AuthenticationProvider"; -import { preferredLocaleKey } from "./constants"; +import { persistPreferredLocale, preferredLocaleKey } from "./constants"; +import { isLocale } from "./Translation"; import { translationContext } from "./TranslationContext"; export function useInitializeLocale() { @@ -12,11 +12,13 @@ export function useInitializeLocale() { useEffect(() => { if (userInfo?.isAuthenticated) { - localStorage.setItem(preferredLocaleKey, document.documentElement.lang); + persistPreferredLocale(document.documentElement.lang); } else { - const storedLocale = localStorage.getItem(preferredLocaleKey) as Locale; - if (storedLocale) { - document.documentElement.lang = storedLocale; + // Anonymous: re-apply a stored preference only if it is a supported locale and not already active. + // resolveInitialLocale already applies it before first paint; this covers an in-session transition + // to anonymous (e.g. logout without a full reload). setLocale validates and sets . + const storedLocale = localStorage.getItem(preferredLocaleKey); + if (storedLocale && isLocale(storedLocale) && storedLocale !== i18n.locale) { setLocale(storedLocale); } } diff --git a/application/shared-webapp/infrastructure/tsconfig.json b/application/shared-webapp/infrastructure/tsconfig.json index 241384c924..14ddddee08 100644 --- a/application/shared-webapp/infrastructure/tsconfig.json +++ b/application/shared-webapp/infrastructure/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "rslib.config.ts"] } diff --git a/application/shared-webapp/ui/copyImages.js b/application/shared-webapp/ui/copyImages.js deleted file mode 100644 index e8534f99f8..0000000000 --- a/application/shared-webapp/ui/copyImages.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * This script copies the images from the images folder to the dist folder. - * We are waiting for rslib to provide a better solution for this. - */ -const fs = require("node:fs"); -const path = require("node:path"); - -fs.cpSync(path.resolve(__dirname, "images"), path.resolve(__dirname, "dist", "images"), { recursive: true }); diff --git a/application/shared-webapp/ui/package.json b/application/shared-webapp/ui/package.json index 9a56a20ba0..394dde90cf 100644 --- a/application/shared-webapp/ui/package.json +++ b/application/shared-webapp/ui/package.json @@ -3,44 +3,28 @@ "version": "0.0.0", "private": true, "license": "MIT", - "source": "./index.ts", - "main": "./dist/index.js", + "files": [ + "dist" + ], + "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "source": "./index.ts", - "default": "./dist/index.js" - }, - "./components/*": { - "types": "./dist/components/*.d.ts", - "source": "./components/*", - "default": "./dist/components/*" - }, - "./forms/*": { - "types": "./dist/forms/*.d.ts", - "source": "./forms/*", - "default": "./dist/forms/*" - }, - "./theme/*": { - "types": "./dist/theme/*.d.ts", - "source": "./theme/*", - "default": "./dist/theme/*" - }, + "./*.css": "./dist/*.css", + "./*.svg": "./dist/*.svg", + "./*.webp": "./dist/*.webp", + "./*.png": "./dist/*.png", "./*": { "types": "./dist/*.d.ts", - "source": "./*", - "default": "./dist/*" + "import": "./dist/*.js" }, "./theme.css": "./theme.css", "./tailwind.css": "./tailwind.css", "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && npm run copyImages && tsc -p .", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "copyImages": "node ./copyImages.js", - "dev": "tsc -p . -w", - "dev:setup": "rimraf ./dist && npm run copyImages && tsc -p .", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings", "update-translations": "lingui extract --clean && lingui compile --typescript" diff --git a/application/shared-webapp/ui/rslib.config.ts b/application/shared-webapp/ui/rslib.config.ts new file mode 100644 index 0000000000..c8f0b2ba6d --- /dev/null +++ b/application/shared-webapp/ui/rslib.config.ts @@ -0,0 +1,47 @@ +import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: [ + "./**/*.tsx", + "./**/*.ts", + "./**/*.svg", + "./**/*.css", + "./**/*.png", + "./**/*.webp", + // The root Tailwind entry stylesheets are consumed directly via the package root exports + // (`./theme.css`, `./tailwind.css`), not from `dist`; keep them out of the entry so rslib does + // not emit dead, shadowed copies into `dist`. + "!theme.css", + "!tailwind.css", + "!rslib.config.ts", + "!node_modules/**", + "!dist/**" + ] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web", + // Emit raster image assets (png/webp logos + hero images) unhashed into `dist/images` so they land + // exactly where the package `exports` (`./*.png`, `./*.webp`) and component imports resolve, letting + // rslib own image emission instead of a hand-rolled copy step. + distPath: { + image: "images" + }, + filename: { + image: "[name][ext]" + } + }, + plugins: [pluginReact(), pluginTypeCheck(), LinguiPlugin()] +}); diff --git a/application/shared-webapp/ui/translations/loadUiTranslations.ts b/application/shared-webapp/ui/translations/loadUiTranslations.ts new file mode 100644 index 0000000000..5476544796 --- /dev/null +++ b/application/shared-webapp/ui/translations/loadUiTranslations.ts @@ -0,0 +1,15 @@ +import type { Messages } from "@lingui/core"; + +// `@repo/ui` owns its own catalog. This loader is what makes the shared component library's +// translations self-contained: the package knows which locales it ships and how to load them, and the +// application wires it in via `` (see @repo/infrastructure/translations). Keep the +// locale keys in sync with the shared i18n config. +const catalogs: Record Promise<{ messages?: Messages }>> = { + "en-US": () => import("./locale/en-US"), + "da-DK": () => import("./locale/da-DK") +}; + +export async function loadUiTranslations(locale: string): Promise<{ messages: Messages }> { + const module = await catalogs[locale]?.(); + return { messages: module?.messages ?? {} }; +} diff --git a/application/shared-webapp/ui/tsconfig.json b/application/shared-webapp/ui/tsconfig.json index 07df91894e..2f4a377c30 100644 --- a/application/shared-webapp/ui/tsconfig.json +++ b/application/shared-webapp/ui/tsconfig.json @@ -9,5 +9,5 @@ "@/*": ["./*"] } }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "rslib.config.ts"] } diff --git a/application/shared-webapp/utils/package.json b/application/shared-webapp/utils/package.json index 553a4d87e9..f44f3533a4 100644 --- a/application/shared-webapp/utils/package.json +++ b/application/shared-webapp/utils/package.json @@ -3,16 +3,20 @@ "version": "0.0.0", "private": true, "license": "MIT", + "files": [ + "dist" + ], + "type": "module", "exports": { ".": "./dist/index.js", "./*": "./dist/*.js", "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && tsc -p .", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "dev": "tsc -p . -w", - "dev:setup": "tsc -p .", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings" }, diff --git a/application/shared-webapp/utils/rslib.config.ts b/application/shared-webapp/utils/rslib.config.ts new file mode 100644 index 0000000000..e72b311825 --- /dev/null +++ b/application/shared-webapp/utils/rslib.config.ts @@ -0,0 +1,21 @@ +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: ["./**/*.tsx", "./**/*.ts", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web" + }, + plugins: [pluginTypeCheck()] +}); diff --git a/application/shared-webapp/utils/tsconfig.json b/application/shared-webapp/utils/tsconfig.json index dd796a94b6..a840efb26b 100644 --- a/application/shared-webapp/utils/tsconfig.json +++ b/application/shared-webapp/utils/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "rslib.config.ts"] }