diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..0ccf1dd --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,282 @@ +# Internationalization (i18n) + +This project uses [i18next](https://www.i18next.com/) with [react-i18next](https://react.i18next.com/). The setup works with both function components and class components (HOCs). + +## Overview + +- **Default language:** German (`de`), bundled at startup. +- **Other languages:** Loaded on demand when the user switches (see `loadLanguage` in `src/i18n/index.js`). +- **Persistence:** Language choice is stored in `sessionStorage` / `localStorage` (`i18nextLng`) via a custom detector in `src/i18n/index.js`. +- **HTML `lang`:** Updated when the language changes (see `LanguageProvider` in `src/i18n/withTranslation.js`). +- **Fallback:** `fallbackLng` is `de`. + +### Product-facing behavior + +- **Language switcher** — `src/components/LanguageSwitcher.js`: shows current language with flag, dropdown to change language, persists selection. +- **Translated areas** — Navigation, auth, cart, checkout, product UI, footer, profile, and more; legal pages use dedicated **`legal-*`** namespaces (long-form text). + +## Layout + +| Path | Role | +|------|------| +| `src/i18n/index.js` | Initializes i18n, registers namespaces, lazy-loads non-German locales | +| `src/i18n/withTranslation.js` | `LanguageProvider`, `withTranslation`, `withI18n`, `withLanguage` | +| `src/i18n/locales//` | Per-language modules (`de`, `en`, …) | + +Each language folder contains: + +- **`index.js`** — Merges feature modules into the main **`translation`** namespace (shop UI: `navigation`, `cart`, `product`, …). Some legal content is also re-exported here under camelCase keys (e.g. `legalAgbDelivery`) for convenience; runtime legal pages use separate namespaces (see below). +- **One file per topic** — e.g. `cart.js`, `navigation.js`, `product.js` (not a single `translation.json`). +- **Legal bundles** — e.g. `legal-agb-delivery.js`, `legal-datenschutz-basic.js`, `legal-impressum.js`, registered as their **own i18next namespaces** in `src/i18n/index.js`. + +## Namespaces + +- **`translation`** (default) — Everything merged in `locales//index.js`. Keys are dot paths such as `cart.addToCart` or `product.title`. +- **`legal-*`** — Long-form legal copy. Loaded the same way, but you select the namespace in `useTranslation('legal-impressum')` (or equivalent). Keys are relative to that namespace, e.g. `sections.operator.title`, not `legal-impressum.sections.operator.title`. + +Default namespace is `translation`; omit the argument for shop UI: + +```javascript +const { t } = useTranslation(); +// same as useTranslation('translation') +``` + +Legal page example: + +```javascript +const { t } = useTranslation('legal-impressum'); +return t('title'); +``` + +Multiple namespaces in one component: + +```javascript +const { t: tDelivery } = useTranslation('legal-agb-delivery'); +const { t: tPayment } = useTranslation('legal-agb-payment'); +``` + +## Usage in components + +### Function components + +```javascript +import { useTranslation } from 'react-i18next'; + +const MyComponent = () => { + const { t } = useTranslation(); + + return {t('navigation.home')}; +}; +``` + +### Class components — `withI18n` (translation + language context) + +`withI18n` combines translation and language context. Use it when you need `t` and optionally `languageContext`: + +```javascript +import { withI18n } from '../i18n/withTranslation.js'; + +class MyComponent extends Component { + render() { + const { t } = this.props; + + return {t('navigation.home')}; + } +} + +export default withI18n()(MyComponent); +``` + +### Class components — `withTranslation` + `withLanguage` + +Some components only need `t` and `languageContext` from separate HOCs: + +```javascript +import { withTranslation } from 'react-i18next'; +import { withLanguage } from '../i18n/withTranslation.js'; + +export default withTranslation()(withLanguage(MyComponent)); +``` + +Destructuring in `render`: + +```javascript +const { t, title } = this.props; +t('product.new'); +``` + +`this.props.t('…')` resolves against the default `translation` namespace. + +### Language context (`changeLanguage`) + +```javascript +import { withLanguage } from '../i18n/withTranslation.js'; + +class MyComponent extends Component { + render() { + const { languageContext } = this.props; + + return ( + + ); + } +} + +export default withLanguage(MyComponent); +``` + +Available language codes are defined on `LanguageProvider` (`allLanguages` in `withTranslation.js`); the switcher only offers languages that are loaded or loading. + +## Key naming + +- Use **dot notation** for nested objects in the JS exports: `product.inclVat`, `delivery.times.standard2to3Days`. +- File names in `locales/de/` map to **top-level segments** inside `index.js` (e.g. `cart.js` → `cart.*`). Keep new strings in the appropriate module and **export them from `index.js`** if you add a new file. + +## Interpolation + +i18next interpolation uses `{{name}}` in strings and an options object: + +```javascript +t('product.weight', { weight: '1,2' }); +``` + +```javascript +// locale string example: "Gewicht: {{weight}} kg" +``` + +## Language switching + +`LanguageProvider` coordinates `i18n.changeLanguage` and lazy loading. Non-default languages are loaded via `loadLanguage` in `src/i18n/index.js`, which dynamic-imports the same file layout under `locales//`. + +## Adding or changing copy + +1. Edit the right module under `src/i18n/locales/de/` (source of truth for structure). +2. Mirror changes in other languages under `src/i18n/locales//` as needed, or use the project’s translation tooling (`npm run translate`, etc. — see `translate-i18n.js` and `package.json` scripts). +3. For a **new top-level section**, add the module import and export in `locales//index.js` for every language you ship. + +### Example (new keys in an existing file) + +`src/i18n/locales/de/cart.js`: + +```javascript +export default { + // ... + newFeature: { + title: 'Neuer Titel', + description: 'Neue Beschreibung', + }, +}; +``` + +Use in code: `t('cart.newFeature.title')` (assuming the `cart` object is merged under `cart` in `index.js`). + +## Adding a new language + +1. Add a folder `src/i18n/locales//` with the same **file set** as German (at minimum `index.js` and every module `index.js` imports, plus all `legal-*.js` files registered in `src/i18n/index.js`). +2. Ensure `loadLanguage` in `src/i18n/index.js` can import that folder (pattern already uses `import(\`./locales/${language}/...\`)`). +3. Include the code in `allLanguages` (and any UI lists) in `withTranslation.js` / `LanguageSwitcher.js` as needed. +4. Clear the language cache logic if you add special cases (`languageCache` in `index.js`). + +## Configuration notes + +- **Detection:** Custom detector in `src/i18n/index.js` prefers session/localStorage, then defaults to `de` (browser language is not used for the initial default). +- **Debug:** i18next `debug` follows `NODE_ENV` (on in development). +- **Missing keys:** `saveMissing` can run in development (see `src/i18n/index.js`). + +## SEO + +- Document `lang` updates with language changes. +- You can extend the app with hreflang, localized routes, or metadata per language as needed. + +## Best practices + +1. **Keep structure parallel** across `locales//` files. +2. **Prefer German (`de`) as the structural source**; keep `fallbackLng` aligned with your primary market. +3. **Use interpolation** for variable fragments: `t('key', { name: value })`. +4. **Test** critical flows after adding strings or languages. + +## Translation coverage (high level) + +Coverage varies by screen; shop flows, auth, cart, products, and legal pages have substantial strings. Remaining gaps are normal for a growing app—search for hard-coded German/English in components when polishing. + +## Performance + +- **German** is in the initial bundle. +- **Other languages** load on first switch (dynamic import), then stay cached in the i18n layer for the session. + +## Browser support + +Modern browsers with ES modules, `localStorage` / `sessionStorage`, and your supported React version. + +--- + +## Key usage check (`scripts/check-i18n-keys.mjs`) + +The check answers: *which keys defined for German (`src/i18n/locales/de`) appear to be unused in `src/`?* + +Run: + +```bash +npm run i18n:check-keys +# or +node scripts/check-i18n-keys.mjs +``` + +Machine-readable output: + +```bash +node scripts/check-i18n-keys.mjs --json +``` + +### What the script detects + +The script only understands **static** key strings: + +- `t('cart.addToCart')`, `t("…")` +- `this.props.t('…')`, `props.t('…')` +- Aliases from `useTranslation('namespace')`, e.g. `const { t: tDelivery } = useTranslation('legal-agb-delivery');` then `tDelivery('deliveryTerms.1')` +- Destructuring `const { t } = this.props` / `props` for HOC-wrapped components +- Fully static template literals without interpolation: `` t(`some.key`) `` + +It flattens locale objects the same way i18next does (leaf keys only). + +### Embedded legal copy vs separate namespaces + +Legal strings exist in two shapes: + +1. **Separate namespace** (what pages use): `legal-agb-delivery` + key `deliveryTerms.1`. +2. **Embedded in `translation`**: `legalAgbDelivery.deliveryTerms.1` inside `locales/de/index.js`. + +The script treats a key as used in the **embedded** `translation` tree when the matching **separate-namespace** key is used, so you do not see false “unused” duplicates for the same text. + +### Dynamic keys (`t(\`…${x}…\`)`) + +If the key is built at runtime, the static scan will not see it. The script includes **hard-coded expansions** for known patterns on: + +- `src/pages/AGB.js` +- `src/pages/Datenschutz.js` (basic Datenschutz sections) +- `src/pages/Impressum.js` + +If you add **new** dynamic patterns elsewhere, either: + +- Prefer **static** keys where reasonable (`t('feature.section.title')` per section), or +- Extend `addKnownDynamicLegalKeys` (or equivalent) in `scripts/check-i18n-keys.mjs` so the checker knows which key paths can occur. + +Other files may still be listed under “template literals in `t(...)`” when the script cannot prove all keys. + +### Making new keys “count” for the checker + +1. Call `t` with a **string literal** (or a template literal **without** `${…}`) as the first argument. +2. Use **`useTranslation('namespace')`** with a **string literal** namespace so aliases like `tFoo` map correctly. +3. For class components, keep **`const { t } = this.props`** (or `props`) so the script ties `t` to `translation`. + +Avoid only referencing a key from non-`src` code (build scripts, server-only files): the checker only scans `src/` and skips `src/i18n/locales/**`. + +### Interpreting results + +- **Unused** — No static (and no expanded dynamic) reference found; may still be dead copy, or only used via variables/dynamic templates. +- **Used but not in locale files** — A reference in code does not match any defined German key (typo or missing `de` entry). + +The report is **best-effort**; use it to find obvious dead strings and drift, not as a formal guarantee. diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.mjs new file mode 100644 index 0000000..f30b282 --- /dev/null +++ b/scripts/check-i18n-keys.mjs @@ -0,0 +1,368 @@ +#!/usr/bin/env node +/** + * Reports which keys from src/i18n/locales/de are referenced in application code. + * + * - Loads the same namespaces as src/i18n/index.js (translation bundle + legal-* bundles). + * - Parses static t("...") / t('...') calls and maps useTranslation() aliases to namespaces. + * - Legal text is duplicated: separate namespaces (e.g. legal-agb-delivery) AND embedded under + * translation (legalAgbDelivery.*). When a separate-namespace key is used, the embedded + * translation::* copy is treated as used too. + * - Known dynamic patterns from AGB.js, Datenschutz.js, Impressum.js are expanded so those + * keys are not falsely listed as unused. + * + * Usage: node scripts/check-i18n-keys.mjs + * node scripts/check-i18n-keys.mjs --json + */ + +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, '..'); +const SRC = path.join(ROOT, 'src'); +const LOCALE_DE = path.join(SRC, 'i18n', 'locales', 'de'); + +const SEPARATE_NAMESPACES = [ + 'legal-agb-delivery', + 'legal-agb-payment', + 'legal-agb-consumer', + 'legal-datenschutz-basic', + 'legal-datenschutz-customer', + 'legal-datenschutz-google-orders', + 'legal-datenschutz-newsletter', + 'legal-datenschutz-chatbot', + 'legal-datenschutz-push', + 'legal-datenschutz-cookies-payment', + 'legal-datenschutz-rights', + 'legal-impressum', + 'legal-widerruf', + 'legal-batterie', +]; + +/** Separate i18n namespace -> key prefix inside translation bundle (locales/de/index.js). */ +const NS_TO_EMBEDDED_PREFIX = { + 'legal-agb-delivery': 'legalAgbDelivery', + 'legal-agb-payment': 'legalAgbPayment', + 'legal-agb-consumer': 'legalAgbConsumer', + 'legal-datenschutz-basic': 'legalDatenschutzBasic', + 'legal-datenschutz-customer': 'legalDatenschutzCustomer', + 'legal-datenschutz-google-orders': 'legalDatenschutzGoogleOrders', + 'legal-datenschutz-newsletter': 'legalDatenschutzNewsletter', + 'legal-datenschutz-chatbot': 'legalDatenschutzChatbot', + 'legal-datenschutz-cookies-payment': 'legalDatenschutzCookiesPayment', + 'legal-datenschutz-rights': 'legalDatenschutzRights', +}; + +/** + * Keys reached only via t(`…${var}…`) in a few pages — expand so they count as used. + */ +function addKnownDynamicLegalKeys(used) { + for (let n = 1; n <= 14; n++) { + used.add(keySet('legal-agb-delivery', `deliveryTerms.${n}`)); + } + for (const n of [1, 2, 3, 5, 6, 7, 8]) { + used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.title`)); + used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.content`)); + } + for (const section of ['informationDeletion', 'serverLogfiles']) { + used.add(keySet('legal-datenschutz-basic', `sections.${section}.title`)); + used.add(keySet('legal-datenschutz-basic', `sections.${section}.content`)); + } + for (const section of ['operator', 'contact', 'vatId', 'disclaimer', 'copyright']) { + used.add(keySet('legal-impressum', `sections.${section}.title`)); + used.add(keySet('legal-impressum', `sections.${section}.content`)); + } +} + +/** If legal-agb-delivery::foo is used, translation::legalAgbDelivery.foo is the same strings. */ +function propagateEmbeddedCopies(used) { + const additions = []; + for (const entry of used) { + const sep = entry.indexOf('::'); + if (sep === -1) continue; + const ns = entry.slice(0, sep); + const keyPath = entry.slice(sep + 2); + const prefix = NS_TO_EMBEDDED_PREFIX[ns]; + if (!prefix) continue; + additions.push(keySet('translation', `${prefix}.${keyPath}`)); + } + for (const a of additions) used.add(a); +} + +function flattenLeaves(obj, prefix = '') { + const keys = []; + if (obj === null || obj === undefined) return keys; + if (typeof obj !== 'object' || Array.isArray(obj)) { + if (prefix) keys.push(prefix); + return keys; + } + const entries = Object.entries(obj); + if (entries.length === 0 && prefix) keys.push(prefix); + for (const [k, v] of entries) { + const next = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + keys.push(...flattenLeaves(v, next)); + } else { + keys.push(next); + } + } + return keys; +} + +function keySet(ns, keyPath) { + return `${ns}::${keyPath}`; +} + +async function loadDefinedKeys() { + const translationMod = await import( + pathToFileUrl(path.join(LOCALE_DE, 'index.js')) + ); + const translation = translationMod.default; + const defined = new Map(); + defined.set('translation', new Set(flattenLeaves(translation))); + + for (const ns of SEPARATE_NAMESPACES) { + const mod = await import(pathToFileUrl(path.join(LOCALE_DE, `${ns}.js`))); + defined.set(ns, new Set(flattenLeaves(mod.default))); + } + + return defined; +} + +function pathToFileUrl(p) { + const normalized = path.resolve(p); + return new URL(`file://${normalized}`).href; +} + +async function collectSourceFiles(dir, out = []) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (full.includes(`${path.sep}i18n${path.sep}locales`)) continue; + await collectSourceFiles(full, out); + } else if (/\.(jsx?|tsx?)$/.test(e.name)) { + out.push(full); + } + } + return out; +} + +/** + * Per file: map translation function alias -> i18next namespace. + */ +function buildAliasMap(source) { + const map = new Map(); + + const named = /const\s*\{\s*t:\s*(\w+)\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + let m; + while ((m = named.exec(source))) { + map.set(m[1], m[2]); + } + + const tWithNs = + /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + while ((m = tWithNs.exec(source))) { + map.set('t', m[1]); + } + + const tDefault = /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*\)/g; + while (tDefault.exec(source)) { + map.set('t', 'translation'); + } + + // withTranslation HOC: t comes from props (any position in destructuring) + const tFromThisProps = + /const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*this\.props/; + const mProps = tFromThisProps.exec(source); + if (mProps) { + map.set(mProps[1] || 't', 'translation'); + } + const tFromProps = + /const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*props\b/; + const mP = tFromProps.exec(source); + if (mP) { + map.set(mP[1] || 't', 'translation'); + } + + if (!map.has('t')) { + map.set('t', 'translation'); + } + + return map; +} + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Extract static keys from t-like calls for known aliases. + */ +function extractUsedKeysFromSource(source, filePath) { + const aliasMap = buildAliasMap(source); + const used = new Set(); + const dynamicHints = []; + let m; + + const propsT = + /(?:this\.props\.t|props\.t)\(\s*['"]([^'"]+)['"]/g; + while ((m = propsT.exec(source))) { + used.add(keySet('translation', m[1])); + } + + for (const [alias, ns] of aliasMap) { + const re = new RegExp( + `\\b${escapeRe(alias)}\\(\\s*['"]([^'"]+)['"]`, + 'g' + ); + while ((m = re.exec(source))) { + used.add(keySet(ns, m[1])); + } + } + + const tplStatic = /\b(\w+)\(\s*`([^`${}]*)`\s*\)/g; + while ((m = tplStatic.exec(source))) { + const alias = m[1]; + const key = m[2].trim(); + if (!key || !aliasMap.has(alias)) continue; + if (/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(key)) { + used.add(keySet(aliasMap.get(alias), key)); + } + } + + if ( + /(?:this\.props\.t|props\.t|\bt\w*)\(\s*`[^`]*\$\{/.test(source) + ) { + dynamicHints.push(filePath); + } + + return { used, dynamicHints }; +} + +function mergeSets(into, from) { + for (const x of from) into.add(x); +} + +async function main() { + const jsonOut = process.argv.includes('--json'); + + const defined = await loadDefinedKeys(); + const files = await collectSourceFiles(SRC); + + const allUsed = new Set(); + const dynamicFiles = new Set(); + + for (const file of files) { + const source = await readFile(file, 'utf8'); + const { used, dynamicHints } = extractUsedKeysFromSource(source, file); + mergeSets(allUsed, used); + if (dynamicHints.length) dynamicFiles.add(file); + } + + addKnownDynamicLegalKeys(allUsed); + propagateEmbeddedCopies(allUsed); + + const unusedByNs = new Map(); + let totalDefined = 0; + let totalUnused = 0; + + for (const [ns, keys] of defined) { + const unused = []; + for (const k of keys) { + totalDefined++; + const full = keySet(ns, k); + if (!allUsed.has(full)) { + unused.push(k); + totalUnused++; + } + } + if (unused.length) unusedByNs.set(ns, unused.sort()); + } + + const orphans = new Set(); + for (const u of allUsed) { + const [ns, keyPath] = u.split('::'); + const set = defined.get(ns); + if (!set || !set.has(keyPath)) { + orphans.add(u); + } + } + + if (jsonOut) { + console.log( + JSON.stringify( + { + totalDefined, + totalUsed: allUsed.size, + totalUnused, + unusedByNamespace: Object.fromEntries(unusedByNs), + usedButNotDefined: [...orphans].sort(), + filesWithLikelyDynamicT: [...dynamicFiles].map((f) => + path.relative(ROOT, f) + ), + }, + null, + 2 + ) + ); + return; + } + + console.log('i18n key usage (locale: de)\n'); + console.log( + 'Legal strings exist twice: separate namespaces (AGB, Datenschutz, …) and embedded copies' + ); + console.log( + 'under translation (legalAgbDelivery.*, …). The latter are marked used when the former are.\n' + ); + console.log(`Defined keys (all namespaces): ${totalDefined}`); + console.log(`References after static scan + known dynamic legal patterns: ${allUsed.size}`); + console.log(`Unused (best-effort): ${totalUnused}`); + console.log(`Used but not in locale files: ${orphans.size}\n`); + + const stillDynamic = [...dynamicFiles].filter( + (f) => + ![ + 'pages/AGB.js', + 'pages/Datenschutz.js', + 'pages/Impressum.js', + ].includes(path.relative(SRC, f).replace(/\\/g, '/')) + ); + if (stillDynamic.length) { + console.log( + 'Other files with t(`…${…}…`) (may still hide unused keys — not expanded):' + ); + for (const f of stillDynamic.sort()) { + console.log(` ${path.relative(ROOT, f)}`); + } + console.log(''); + } + + for (const [ns, keys] of [...unusedByNs.entries()].sort((a, b) => + a[0].localeCompare(b[0]) + )) { + console.log(`--- ${ns} (${keys.length} unused) ---`); + for (const k of keys) { + console.log(` ${k}`); + } + console.log(''); + } + + if (orphans.size) { + console.log('--- Referenced in code but missing from de locale ---'); + for (const o of [...orphans].sort()) { + console.log(` ${o.replace('::', ' / ')}`); + } + } + + console.log( + '\nNote: Keys built from arbitrary variables or unknown dynamic templates may still look unused.' + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/utils/jsErrorTelemetry.js b/src/utils/jsErrorTelemetry.js new file mode 100644 index 0000000..aae2dc4 --- /dev/null +++ b/src/utils/jsErrorTelemetry.js @@ -0,0 +1,49 @@ +/** + * POST client errors to /api/telemetry/js-errors (shop API). + * Uses same-origin relative URL so the dev-server proxy forwards to the backend. + */ + +const TELEMETRY_PATH = '/api/telemetry/js-errors'; + +function send(payload) { + fetch(TELEMETRY_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => {}); +} + +/** + * Register global listeners once. Safe to call in browser only. + */ +export function installJsErrorTelemetry() { + if (typeof window === 'undefined') { + return; + } + + window.addEventListener('error', (event) => { + send({ + message: event.message || 'Error', + name: event.error?.name, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + url: window.location.href, + stack: event.error && event.error.stack, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + send({ + message: + reason && typeof reason.message === 'string' + ? reason.message + : String(reason), + name: reason && reason.name, + stack: reason && reason.stack, + url: window.location.href, + context: { type: 'unhandledrejection' }, + }); + }); +}