Feat/i18n#38
Conversation
Changes: - Add web app manifest (app/manifest.ts) with standalone display, theme color, icons, and shortcuts derived from userNavItems - Generate PWA icon set (192/512/maskable/apple-touch) from the logo via a sharp script (scripts/generate-pwa-icons.mjs) - Add install UX: persistent sidebar banner + dismissible floating banner + platform-aware "how to install" sheet, behind a useInstallPrompt hook - Show install UI whenever the app isn't installed (not only when the native beforeinstallprompt has fired), falling back to manual instructions - Register a minimal service worker (public/sw.js) so browsers offer install - Wire PWA metadata/viewport into layout and fix stale metadataBase - Persist floating-banner dismissal in the existing app localStorage object First stage of the staged PWA roadmap (offline shell, push, and engagement extras follow). The minimal service worker is replaced by Serwist in Stage 2. Notes: - theme_color (#2ec50d) also tints the mobile browser address bar site-wide - Adds sharp as a devDependency (icon generation only)
… or iOS Safari, hide on unsupported browsers and in-app webviews; hide once installed
Changes: - Lift useInstallPrompt state into a module-level store read via useSyncExternalStore, so the floating and sidebar banners share one captured beforeinstallprompt event - Clear the prompt for all consumers after promptInstall(), preventing a stale install action in the other banner - Attach beforeinstallprompt/appinstalled listeners once on first subscribe instead of once per component - Add a `ready` flag (false in the server snapshot) so install UI is gated until client detection runs, avoiding a flash in standalone sessions Fixes CodeRabbit findings on PR #36: separate per-component prompt state (major) and install UI flashing before hydration (minor). Notes: - Public hook API is unchanged, so InstallBanner/InstallSidebarBanner needed no edits - Dropped now-redundant `typeof window` guards in detectIOS/detectStandalone since they only run inside the client-only init()
Changes: - Add Serwist service worker (src/app/sw.ts): precache app shell, /offline fallback, StaleWhileRevalidate for public /api/cijene GETs, NetworkOnly for other /api (keeps authed responses out of Cache Storage); wire withSerwistInit in next.config (disabled in dev), webworker lib in tsconfig, build --webpack - Persist React Query cache to IndexedDB (src/lib/offline/*): whitelist of cijene/lists/watchlist/etc., 7-day maxAge + buster, success-only dehydration; swap to PersistQueryClientProvider with gcTime >= maxAge - Purge persisted cache on logout (user-context handleLogout) - Add offline UX: online-status hook, offline indicator banner, relative-time util, and "last synced" labels on product detail, watchlist, shopping list - Remove interim Stage-1 sw.js + manual registration (Serwist auto-registers) Read-only offline; offline writes (queue + replay) follow in Stage 2b. Pins @TanStack persist packages to 5.101.1 to match react-query (avoids dup query-core). Adds @serwist/next, serwist, react-query-persist-client, query-async-storage-persister, idb-keyval. Notes: - Service worker is active in production builds only; data-layer offline works in dev. TODO(offline) markers mark coming-soon screens (cards/spending/ updates/map) to wire when they ship.
Changes: - Add offline mutation allowlist + keys (lib/offline/offline-mutation-keys.ts) and replay defaults (lib/offline/offline-mutations.ts): register default mutationFn + invalidation per mutationKey so paused writes resume after reload - Persist only paused, allowlisted mutations (persister shouldDehydrateMutation) - Register defaults on client creation and resumePausedMutations on cache restore (react-query-provider) - Tag shopping-list and watchlist mutation hooks with their mutationKey - Offline indicator shows count of queued writes; create/rename list modal closes with a "will sync" toast when offline instead of spinning Builds on Stage 2a read caching: offline edits to shopping lists/items and watchlist now queue optimistically and replay on reconnect (same session or after a reload), cross-browser via React Query (no SW Background Sync needed). Notes: - Watchlist add/remove and list creation are queued but not yet optimistic offline; follow-up enhancement - No new dependencies
Changes: - Add .claude/skills/document-subsystem/SKILL.md that generates a docs/<NAME>.md reference for a given part of the app, in the style of docs/DEPLOYMENT.md Gives a reusable, consistent way to produce detailed, beginner-friendly subsystem docs: explore the code first, then document the flow, automatic vs manual work, key files, config/env, libraries, gotchas, and future TODOs. Notes: - Project-level skill; move to ~/.claude/skills/ to use it across all projects
…l polish Changes: - Set status bar / theme color to neutral white (viewport themeColor + manifest theme_color), removing the green top bar - Request persistent storage (navigator.storage.persist) so the offline IndexedDB cache isn't evicted, especially on iOS - Add iOS splash screens: 18 device sizes generated into public/splash via scripts/generate-ios-splash.mjs, emitted as apple-touch-startup-image links from a shared constants list (rendered in <head>) - Add manifest screenshots (branded narrow + wide promo cards) for the richer install dialog, and launch_handler focus-existing to avoid duplicate windows Hardens the Stage 2 offline work and completes Stage 1 install UX on iOS. Notes: - Splash/screenshot images are generated; re-run the scripts to refresh them
Changes:
- Add next-intl 4.13 with cookie-based locale (NEXT_LOCALE), no URL routing: src/i18n/{config,request,locale-actions,message-fallback,intl-client-provider}.ts, wired via createNextIntlPlugin in next.config.ts and a client provider in the root layout
- Add 4 message catalogs (hr default, en, de, sl), 652 keys each with full parity, plus compile-time key safety (src/typings/next-intl.d.ts)
- Auto-detect locale from Accept-Language on first visit (the cookie still wins); fall back to hr for any missing key; set a global Europe/Zagreb time zone to avoid date/time hydration mismatches
- Localize the root layout (dynamic <html lang>, generateMetadata) and every page.tsx metadata
- Migrate all user-facing surfaces to t()/t.rich()/ICU plurals: header, sidebar, footer, auth modal and forms, settings modals, dashboard, product detail, price history, add-to-list and watchlist modals, scanner, shopping-list detail and its mutation-hook toasts, watchlist/digital-cards/suggestions/updates/map/statistics, offline indicator, and PWA install banners
- Translate attributes too (placeholders, image alts, aria-labels, tooltips) and render account-type/acquisition-channel labels via i18n; add a useNavTranslation hook
- Add a LanguageSwitcher: an icon variant (header for guests, footer) with a muted-to-primary hover, and a sidebar variant shown on mobile above the PWA banner
- Add a "validation" namespace and build the forgot-password and reset-password Zod schemas inside their components so their messages localize
Establishes a type-safe, cookie-based i18n base so the app serves Croatian, English, German, and Slovenian; adding a language is a one-line config change plus a catalog file.
Notes:
- Reading the cookie / Accept-Language header opts routes into dynamic rendering, which is expected for cookie-based i18n without routing.
- next-intl and eslint-plugin-i18next are added via pnpm (package.json / lock are dependency-managed, not hand-edited).
Changes: - Add getEmailTranslator + an "emails" catalog namespace so the verification, password-reset, set-password, and change-email templates render in the recipient's locale (subject, heading, body, button, footnote, footer) - Add getRequestLocale to resolve the recipient locale from the cookie / Accept-Language in non-render contexts, and thread it through EmailService and the Better Auth email callbacks in lib/auth.ts - Set the email <html lang> and time zone per locale; use t.rich for the change-email address and the footer link Emails previously always sent in Croatian regardless of the user's language; they now match it. Notes: - The locale is resolved inside the request context before the fire-and-forget send, so cookies()/headers() are available. - Email templates keep their react-email PreviewProps (locale defaults to hr) so "pnpm email" preview still works.
Changes: - Add the i18next/no-literal-string ESLint rule (eslint-plugin-i18next): fails on hardcoded JSX text and on the placeholder/alt/title/aria-label/label attributes, tuned with callees/words excludes for zero false positives - Add scripts/check-i18n.mjs (run via "pnpm i18n:check") to verify all four catalogs stay in parity - Document the i18n workflow (rules, scripts, no em dashes) in AGENTS.md Makes the translation coverage self-defending: a hardcoded string or a missing key now fails CI instead of shipping. Notes: - The eslint-plugin-i18next dependency and the i18n:check script entry live in package.json (added in the foundation commit); this commit adds the config and script file that use them.
|
Important Review skippedToo many files! This PR contains 164 files, which is 14 over the limit of 150. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: ⛔ Files ignored due to path filters (25)
📒 Files selected for processing (164)
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ Deploy Preview for disscount canceled.
|
There was a problem hiding this comment.
Pull request overview
This PR introduces cookie-based internationalization (next-intl) across the Next.js frontend, while also adding PWA/offline capabilities (Serwist service worker + React Query persistence) and migrating a large amount of UI copy (and transactional email copy) to translation catalogs.
Changes:
- Add next-intl configuration (request-based locale resolution, typed message augmentation, locale switcher, message fallback handling) and enforce i18n via ESLint + a catalog parity script.
- Add offline support via React Query persisted cache (IndexedDB), offline mutation allowlisting, logout cache purge, offline UI indicators, and an
/offlinefallback page. - Add PWA wiring (manifest, icons/splash tooling, install banners, Serwist service worker and runtime caching rules).
Reviewed changes
Copilot reviewed 161 out of 188 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/tsconfig.json | Adds Serwist typings and WebWorker lib; excludes generated SW output. |
| frontend/src/utils/date.ts | Adds relative time formatting helper for UI “last synced” labels. |
| frontend/src/utils/browser/local-storage.ts | Adds persisted “install banner dismissed” flag helpers. |
| frontend/src/typings/next-intl.d.ts | Augments next-intl types with app locale + message schema. |
| frontend/src/typings/local-storage.ts | Extends local storage typing with install banner flag. |
| frontend/src/lib/offline/purge.ts | Adds helper to clear in-memory + persisted React Query caches. |
| frontend/src/lib/offline/persister.ts | Implements IndexedDB persister + persist options for React Query. |
| frontend/src/lib/offline/offline-mutation-keys.ts | Defines allowlisted offline mutation keys and persistence predicate. |
| frontend/src/lib/offline/cached-query-keys.ts | Defines persisted query key prefixes and whitelist check. |
| frontend/src/lib/email/email-service.ts | Localizes transactional email subjects/templates via locale-aware translator. |
| frontend/src/lib/auth.ts | Resolves request locale and passes it into email sending flows. |
| frontend/src/lib/api/watchlist/index.ts | Adds offline mutation keys to watchlist mutations for replay. |
| frontend/src/lib/api/shopping-lists/index.ts | Adds offline mutation keys to shopping list mutations for replay. |
| frontend/src/i18n/request.ts | Adds per-request locale/messages resolution (cookie + Accept-Language). |
| frontend/src/i18n/message-fallback.ts | Adds hr-based fallback resolution and error handling for missing keys. |
| frontend/src/i18n/locale-actions.ts | Adds server action to persist locale cookie. |
| frontend/src/i18n/intl-client-provider.tsx | Wraps NextIntlClientProvider with shared fallback/error handler. |
| frontend/src/i18n/get-request-locale.ts | Adds locale resolver for non-render server contexts (emails). |
| frontend/src/i18n/config.ts | Defines locale list, cookie name, timezone, and Accept-Language matching. |
| frontend/src/hooks/use-online-status.ts | Adds shared online/offline source of truth using React Query onlineManager. |
| frontend/src/hooks/use-nav-translation.ts | Centralizes navigation label lookup via next-intl. |
| frontend/src/emails/verification-email.tsx | Localizes verification email copy via email translator. |
| frontend/src/emails/set-password-email.tsx | Localizes set-password email copy via email translator. |
| frontend/src/emails/password-reset-email.tsx | Localizes password reset email copy via email translator. |
| frontend/src/emails/email-translator.ts | Adds locale-bound translator for email templates (outside request context). |
| frontend/src/emails/components/email-layout.tsx | Localizes footer and sets HTML lang based on locale. |
| frontend/src/emails/components/action-email.tsx | Localizes link fallback text and supports rich footnotes. |
| frontend/src/emails/change-email-confirmation.tsx | Localizes change-email confirmation copy via rich translations. |
| frontend/src/context/user-context.tsx | Purges persisted/offline cache on logout (instead of only in-memory clear). |
| frontend/src/constants/ios-splash-screens.json | Adds iOS device list for generating apple startup images. |
| frontend/src/components/custom/view-switcher.tsx | Translates aria-labels/tooltips for view mode toggle. |
| frontend/src/components/custom/store-chain-select.tsx | Translates placeholder and refactors diff color logic. |
| frontend/src/components/custom/store-chain-multi-select.tsx | Translates placeholder for multi-select. |
| frontend/src/components/custom/search-bar.tsx | Translates default placeholder/labels and aria text. |
| frontend/src/components/custom/pwa/request-persistent-storage.tsx | Requests persistent storage to reduce IndexedDB eviction risk. |
| frontend/src/components/custom/pwa/install-sidebar-banner.tsx | Adds persistent sidebar install CTA with i18n copy. |
| frontend/src/components/custom/pwa/install-instructions-sheet.tsx | Adds i18n’d manual install instructions sheet (iOS vs others). |
| frontend/src/components/custom/pwa/install-banner.tsx | Adds dismissible bottom install banner w/ persisted dismissal. |
| frontend/src/components/custom/pwa/apple-splash-screens.tsx | Renders <link rel="apple-touch-startup-image"> tags from device list. |
| frontend/src/components/custom/price-history-period-select.tsx | Localizes period labels. |
| frontend/src/components/custom/offline/offline-indicator.tsx | Adds offline banner with pending write count. |
| frontend/src/components/custom/offline/last-synced-label.tsx | Adds “last refreshed” label using relative time (client-only). |
| frontend/src/components/custom/oauth-error-toast.tsx | Localizes OAuth error toasts via key mapping. |
| frontend/src/components/custom/no-results.tsx | Localizes default “no results” title/description. |
| frontend/src/components/custom/legal-page.tsx | Converts legal wrapper to server component with localized labels. |
| frontend/src/components/custom/language-switcher.tsx | Adds cookie-based language switcher UI. |
| frontend/src/components/custom/header/user-menu.tsx | Localizes user menu labels and account type badge text. |
| frontend/src/components/custom/header/notifications-dropdown.tsx | Localizes notifications dropdown copy. |
| frontend/src/components/custom/header/forms/signup-form.tsx | Localizes signup UI and error messages. |
| frontend/src/components/custom/header/forms/login-form.tsx | Localizes login UI and error messages. |
| frontend/src/components/custom/header/forms/linked-accounts.tsx | Localizes linked accounts UI and toasts. |
| frontend/src/components/custom/header/forms/inbox-notice.tsx | Localizes spam hint text. |
| frontend/src/components/custom/header/forms/forgot-password-form.tsx | Localizes forgot-password UI and validation message. |
| frontend/src/components/custom/footer.tsx | Localizes footer aria/alt text and integrates language switcher. |
| frontend/src/components/custom/coming-soon.tsx | Localizes “coming soon” badge/default description. |
| frontend/src/components/custom/camera-scanner.tsx | Localizes scanner UI/error messages. |
| frontend/src/app/updates/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/updates/components/updates-client.tsx | Localizes Updates search/heading/button copy. |
| frontend/src/app/updates/[id]/page.tsx | Localizes metadata fallbacks and back link. |
| frontend/src/app/terms-of-service/page.tsx | Localizes legal page content and metadata. |
| frontend/src/app/privacy-policy/page.tsx | Localizes legal page content and metadata. |
| frontend/src/app/data-deletion/page.tsx | Localizes legal page content and metadata. |
| frontend/src/app/reset-password/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/offline/page.tsx | Adds offline fallback page for SW navigation fallback. |
| frontend/src/app/offline/components/offline-retry-button.tsx | Adds client retry button with localized label. |
| frontend/src/app/not-found.tsx | Localizes 404 page metadata and content. |
| frontend/src/app/map/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/map/components/map-client.tsx | Localizes map page placeholder/copy. |
| frontend/src/app/suggestions/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/suggestions/components/suggestions-client.tsx | Localizes suggestions heading/search. |
| frontend/src/app/suggestions/[id]/page.tsx | Localizes suggestion detail metadata. |
| frontend/src/app/suggestions/[id]/components/suggestion-details-client.tsx | Localizes back link and comments section copy. |
| frontend/src/app/statistics/page.tsx | Localizes metadata and page heading/description. |
| frontend/src/app/statistics/components/stores-list.tsx | Localizes statistics list headings/loading/empty state. |
| frontend/src/app/statistics/components/store-item.tsx | Localizes per-chain stats and table labels/unknown copy. |
| frontend/src/app/statistics/components/health-status.tsx | Localizes API health status copy. |
| frontend/src/app/dashboard/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/dashboard/components/dashboard-content.tsx | Localizes dashboard headings and empty/admin copy. |
| frontend/src/app/products/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/products/components/products-client.tsx | Localizes search placeholder, headings, and error/empty states. |
| frontend/src/app/products/components/watchlist-action-button.tsx | Localizes watchlist action label. |
| frontend/src/app/products/components/product-item/product-price.tsx | Localizes unknown price fallback. |
| frontend/src/app/products/components/product-item/product-info.tsx | Localizes unknown product name fallback. |
| frontend/src/app/products/components/product-info-table.tsx | Localizes product info table labels and unknown fallbacks. |
| frontend/src/app/products/components/product-info-display.tsx | Localizes unknown name fallback in product header. |
| frontend/src/app/products/components/product-action-buttons.tsx | Localizes tooltips/aria-labels for product actions. |
| frontend/src/app/products/components/forms/shopping-list-selector.tsx | Localizes labels/placeholders/groups for add-to-list selector. |
| frontend/src/app/products/components/forms/quantity-input.tsx | Localizes quantity label + aria labels. |
| frontend/src/app/products/components/forms/mark-as-checked-checkbox.tsx | Localizes mark-as-checked label and hint. |
| frontend/src/app/products/[id]/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/products/[id]/components/store-item/store-prices-table.tsx | Localizes table headers and unknown fallbacks. |
| frontend/src/app/products/[id]/components/store-item/store-item.tsx | Localizes store item labels and badges. |
| frontend/src/app/products/[id]/components/product-detail-client.tsx | Adds last-synced label and localizes copy/states. |
| frontend/src/app/products/[id]/components/price-history/price-history-chart.tsx | Localizes “average” series label. |
| frontend/src/app/products/[id]/components/price-history/price-history-base.tsx | Localizes headings/toggles/empty states. |
| frontend/src/app/(root)/page.tsx | Localizes home page metadata via generateMetadata. |
| frontend/src/app/(root)/components/sections/hero-section.tsx | Moves taglines into i18n catalogs and picks one at render time. |
| frontend/src/app/(root)/components/sections/hero-actions.tsx | Localizes hero search placeholder and CTA copy. |
| frontend/src/app/(user)/watchlist/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/(user)/watchlist/components/watchlist-item.tsx | Localizes aria labels and toast messages. |
| frontend/src/app/(user)/watchlist/components/watchlist-item-discount-info.tsx | Localizes discount info states and actions. |
| frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx | Localizes button labels and toast messages. |
| frontend/src/app/(user)/spending/page.tsx | Localizes metadata and coming-soon copy. |
| frontend/src/app/(user)/shopping-lists/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx | Localizes search/heading and empty state copy. |
| frontend/src/app/(user)/shopping-lists/components/shopping-list-item.tsx | Localizes public/private tooltip text. |
| frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx | Localizes modal title/labels/placeholders/actions. |
| frontend/src/app/(user)/shopping-lists/components/forms/delete-shopping-list-dialog.tsx | Localizes delete dialog title/description/buttons. |
| frontend/src/app/(user)/shopping-lists/components/create-shopping-list-button.tsx | Localizes create list button label. |
| frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts | Improves offline UX: closes modal immediately and shows info toast. |
| frontend/src/app/(user)/shopping-lists/[id]/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts | Exposes dataUpdatedAt for last-synced label usage. |
| frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts | Localizes toasts and copy suffix for list duplication. |
| frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts | Localizes item mutation toasts. |
| frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx | Adds last-synced label and localizes error/back copy. |
| frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header.tsx | Localizes public/private title tooltip text. |
| frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table.tsx | Localizes labels for stats table (created/updated/total/etc). |
| frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx | Localizes headings/toggles/empty states. |
| frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items.tsx | Localizes products header and empty state copy. |
| frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx | Localizes aria labels for quantity/remove actions. |
| frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list.tsx | Localizes store chain section headings/toggles/errors. |
| frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card.tsx | Localizes availability/status badges and labels. |
| frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-items-table.tsx | Localizes table headers/tooltips and sr-only labels. |
| frontend/src/app/(user)/digital-cards/page.tsx | Localizes metadata via generateMetadata. |
| frontend/src/app/(user)/digital-cards/components/digital-cards-client.tsx | Localizes headings/placeholders/empty state and FAB label. |
| frontend/src/app/(user)/digital-cards/components/digital-card-item.tsx | Localizes confirm/toasts and labels. |
| frontend/src/app/layout.tsx | Integrates next-intl provider, localized metadata, offline/install UI, and PWA metadata. |
| frontend/src/app/manifest.ts | Adds PWA web manifest with shortcuts and screenshots. |
| frontend/src/app/sw.ts | Adds Serwist service worker with API caching rules and offline fallback. |
| frontend/src/app/providers/react-query-provider.tsx | Switches to PersistQueryClientProvider and registers offline replay defaults. |
| frontend/src/app/providers/providers.tsx | Adds persistent storage request and iOS splash link rendering into provider tree. |
| frontend/scripts/check-i18n.mjs | Adds catalog parity checker for CI/local gating. |
| frontend/scripts/generate-pwa-icons.mjs | Adds icon generation script for manifest/layout icons. |
| frontend/scripts/generate-pwa-screenshots.mjs | Adds placeholder screenshot generator for manifest screenshots. |
| frontend/scripts/generate-ios-splash.mjs | Adds iOS splash generator driven by shared device list. |
| frontend/eslint.config.mjs | Adds i18next/no-literal-string rule for JSX + key attributes. |
| frontend/next.config.ts | Adds next-intl plugin and Serwist integration; precaches offline route. |
| frontend/package.json | Adds i18n check script and new dependencies for next-intl + offline/PWA. |
| frontend/pnpm-workspace.yaml | Allows builds for additional native deps used in the toolchain. |
| frontend/.gitignore | Ignores generated Serwist outputs in public/. |
| AGENTS.md | Documents i18n conventions and required checks. |
| .gitignore | Adds ignore for docs/disscount_* artifacts. |
| .claude/skills/document-subsystem/SKILL.md | Adds a documentation skill description for subsystem docs. |
Files not reviewed (1)
- frontend/pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export async function purgeOfflineCache(queryClient: QueryClient) { | ||
| queryClient.clear(); | ||
| await offlinePersister.removeClient(); | ||
| } |
| onSuccess={() => { | ||
| queryClient.resumePausedMutations(); | ||
| }} |
| import RequestPersistentStorage from "@/components/custom/pwa/request-persistent-storage"; | ||
| import AppleSplashScreens from "@/components/custom/pwa/apple-splash-screens"; |
Disscount: Internationalization (i18n) Guide
A complete reference for how Disscount is translated into multiple languages, written to be understandable even if you are new to i18n. Keep it up to date as the setup changes.
Last verified on 2026-07-04: 652 keys in parity across hr/en/de/sl,
pnpm i18n:checkgreen,pnpm exec tsc --noEmitclean,no-literal-stringclean.Table of contents
1. Quick reference
next-intl^4.13.0hr(default) ·en·de·sl/products,/shopping-lists)NEXT_LOCALE(1 year,sameSite=lax)Accept-Languageheader, thenhrfrontend/src/i18n/messages/{hr,en,de,sl}.jsonEurope/Zagreb(avoids date hydration mismatches)frontend/src/typings/next-intl.d.tsi18next/no-literal-string(fails CI on hardcoded strings)pnpm i18n:checkDaily workflow when adding text: add the key to all four catalogs (
hris the reference), read it withuseTranslations/getTranslations, then runpnpm i18n:checkandpnpm exec tsc --noEmit.2. How it works (end to end)
Nothing about the language lives in the URL. On every request, a small config function reads the locale, loads the matching catalog, and hands the messages to both Server and Client Components through a provider. Components never hold Croatian text; they hold keys.
flowchart TD A[Incoming request] --> B{NEXT_LOCALE cookie valid?} B -->|yes| C[use cookie locale = explicit choice] B -->|no| D{Accept-Language matches hr/en/de/sl?} D -->|yes| E[use browser locale] D -->|no| F[defaultLocale = hr] C --> G[request.ts: load messages/<locale>.json] E --> G F --> G G --> H[RootLayout: <html lang> + IntlClientProvider] H --> I[Server Components: await getTranslations ns] H --> J[Client Components: useTranslations ns / t.rich / useFormatter]The two ways to read a string:
const t = await getTranslations("namespace")fromnext-intl/server.const t = useTranslations("namespace")fromnext-intl.Both return a
t("key")function.t.rich("key", { tag: (chunks) => <El>{chunks}</El> })renders embedded links or icons, and counts use ICU plurals ({count, plural, one {...} few {...} other {...}}) so Croatian and Slovenian plural rules are correct, not just singular/plural.3. Locale resolution & the language switcher
request.tsresolves the active locale with a clear priority so an explicit choice always wins and a first-time visitor still gets a sensible default:NEXT_LOCALEcookie (the user's explicit choice, written by the switcher).Accept-Languageheader (the browser's preference), matched on the primary subtag sode-ATmaps tode.defaultLocale(hr).The switcher is
frontend/src/components/custom/language-switcher.tsx. It has two variants driven by avariantprop, so the language logic lives in one place:icon(default)sidebarLanguagesicon and a translated "Language" labelOn mobile the header/footer switchers are hidden (
hidden md:flex) and the sidebar one takes over (md:hidden), matching the convention already used for the dashboard and header-only items in the sidebar.Selecting a language calls the
setLocaleserver action (frontend/src/i18n/locale-actions.ts), which writes theNEXT_LOCALEcookie, thenrouter.refresh()re-renders with the new locale. Because it is a refresh and not a navigation, the sidebar stays open.flowchart LR U[User picks a language] --> S[LanguageSwitcher] S --> A["setLocale() server action writes NEXT_LOCALE cookie"] A --> R["router.refresh()"] R --> Q[next render re-resolves the locale from the cookie]The language names in the dropdown are endonyms (each language written in itself: Hrvatski, English, Deutsch, Slovenščina). These are intentionally not translated, which is the conventional way to list languages.
4. Message catalogs & namespaces
Catalogs live in
frontend/src/i18n/messages/<locale>.jsonand are organized by feature namespace, mirroring the app's feature folders.hr.jsonis the reference: it defines the full set of keys and also drives the TypeScript types. The other three mirror its keys exactly.Representative namespaces:
commonmetadata,pages.*navigationidauth(+auth.modal,auth.oauthErrors, ...)userMenu,settings.*productDetail,addToList,watchModal,scannershoppingListDetail(+.toasts)priceHistory,statisticsaccountTypes,acquisitionChannelsoffline,pwavalidationemailsData-driven labels (navigation items, account types, acquisition channels) keep their
idor enum value in the data file and are translated at render time by key, so the data files never store display strings.5. Type safety, fallback & time zone
Type-safe keys.
frontend/src/typings/next-intl.d.tsaugments next-intl'sAppConfigsoMessagesequals the shape ofhr.json. That meanst("some.key")is checked at compile time: a typo or a missing key is a TypeScript error, sopnpm exec tsc --noEmitpassing is a strong guarantee that no key is dangling.Missing-key fallback.
frontend/src/i18n/message-fallback.tsprovidesgetMessageFallback, which resolves a missing key from the default (hr) catalog instead of showing the rawnamespace.keypath, andonIntlError, which swallows the "missing message" error so it does not throw. These are wired on the server inrequest.tsand on the client throughintl-client-provider.tsx. Parity is enforced (section 7), so this is a safety net, not an everyday path.Time zone.
config.tsexports a fixedTIME_ZONE = "Europe/Zagreb", applied on the server config, the client provider, and the email translator. A fixed zone (rather than the server's own zone) guarantees the server-rendered HTML and the client's first render format dates identically, which prevents hydration mismatches. Without it, next-intl raises anENVIRONMENT_FALLBACKwarning as soon as anything formats a date.The client wrapper.
getMessageFallbackandonIntlErrorare functions, and functions cannot be passed from a Server Component to a Client Component across the React boundary. That is whyintl-client-provider.tsxis a small"use client"module: it imports those functions and hands them toNextIntlClientProviderfrom inside the client boundary.6. Emails (localized separately)
Transactional emails (verification, password reset, set password, change-email confirmation) are rendered with react-email outside a normal request render, so they cannot use
getTranslations(which is request-bound). Instead they usegetEmailTranslator(locale)infrontend/src/emails/email-translator.ts, which builds a translator with next-intl'screateTranslatorover theemailsnamespace.The recipient's locale is resolved at send time by
getRequestLocale()(frontend/src/i18n/get-request-locale.ts), which reads the cookie orAccept-Languagethe same wayrequest.tsdoes.lib/auth.tscalls it inside each Better Auth email callback and threads the locale throughEmailServiceinto the templates.flowchart LR CB[Better Auth email callback] --> GL["getRequestLocale()"] GL --> ES[EmailService.send...] ES --> ET["getEmailTranslator(locale)"] ET --> TPL[react-email template renders in the recipient's language]The templates keep their react-email
PreviewProps(locale defaults tohr) so thepnpm emailpreview server still works.7. Guardrails: the ESLint rule & parity script
Two checks make the translation coverage self-defending, so a regression fails CI instead of shipping.
i18next/no-literal-string(fromeslint-plugin-i18next^6.1.5, configured infrontend/eslint.config.mjs, run bypnpm lint). It fails on any hardcoded JSX text and on the user-visible attributesplaceholder,alt,title,aria-label, andlabel. It is tuned to zero false positives with:callees.exclude: skip the key argument of translation and utility calls (t,t*,nav.label,form.register,localeCompare, and similar), sot("key")is not itself flagged.words.exclude: allow symbol/number-only text, hex color examples, and intentional proper names (Disscount, Jakov Jakovac, GitHub, LinkedIn, N/A).ignores: the shadcnui/primitives, unused scaffolds (sidebar-08/,shadcn-studio/, the unused landingsections/*), andemails/(localized separately).Because the rule's
jsx-onlymode also flags string literals inside JSX expression containers, one tailwind class ternary instore-chain-select.tsxwas hoisted into a module-level helper so its class names live in plain JS rather than JSX.pnpm i18n:check(frontend/scripts/check-i18n.mjs). It flattens every catalog to dotted key paths and compares each locale againsthr, printing any missing or extra keys and exiting non-zero on a mismatch. Suitable for CI.pnpm exec tsc --noEmitis the third leg: it verifies everyt("key")actually exists in the catalog shape.8. Key files
frontend/src/i18n/config.tslocales,defaultLocale,LOCALE_COOKIE,TIME_ZONE,isLocale,matchLocalefrontend/src/i18n/request.tsgetRequestConfig: resolves the locale per request, loads the catalog, sets fallback + time zonefrontend/src/i18n/locale-actions.ts"use server"setLocale(locale): writes theNEXT_LOCALEcookiefrontend/src/i18n/get-request-locale.tsfrontend/src/i18n/message-fallback.tsgetMessageFallback(falls back tohr) andonIntlErrorfrontend/src/i18n/intl-client-provider.tsx"use client"wrapper adding fallback/onError/time zone toNextIntlClientProviderfrontend/src/i18n/messages/{hr,en,de,sl}.jsonhris the reference)frontend/src/typings/next-intl.d.tsfrontend/src/hooks/use-nav-translation.tsid(label/short)frontend/src/components/custom/language-switcher.tsxfrontend/src/emails/email-translator.tsgetEmailTranslator: acreateTranslatorbound to a locale for emailsfrontend/next.config.tscreateNextIntlPlugin("./src/i18n/request.ts")frontend/src/app/layout.tsx<html lang>,generateMetadata,IntlClientProviderfrontend/eslint.config.mjsno-literal-stringrule configurationfrontend/scripts/check-i18n.mjspnpm i18n:check)9. What's automatic vs manual
t("key")existsen/de/slno-literal-string)t()i18n:check)Intl.PluralRules{count, plural, ...}messagehrfor a missing keyemailsnamespace10. How to: add a language / add a string
Add a new language (about 5 minutes).
localesinfrontend/src/i18n/config.ts(for example"it").LANGUAGE_NAMESinlanguage-switcher.tsx(for exampleit: "Italiano").frontend/src/i18n/messages/it.jsonmirroring every key inhr.json.pnpm i18n:check(must pass) andpnpm exec tsc --noEmit.The switcher, provider, detection, and type-checking pick it up automatically. This is exactly how
slwas added on top ofhr/en/de.Add or change a user-facing string.
hr.json, then translate it inen.json,de.json,sl.json.useTranslations(client) orgetTranslations(server). Uset.richfor embedded links/icons, ICU plurals for counts, anduseFormatterfor numbers/currency/dates.pnpm i18n:checkandpnpm exec tsc --noEmit.11. Language vs market (the two-axis design)
docs/disscount_knowledge_base.mdsection 19 specifies i18n as two independent axes, and only the first is implemented today:Language is what this document covers: the UI text the user reads. Market is which country's stores, price sources, currency, and retailer data are shown. They are independent, so combinations like "English language + Croatia market" or "Croatian language + Austria market" are intended.
When the market axis is built, it should be a separate setting (its own cookie/preference), not an overload of
NEXT_LOCALE. The fixedEurope/Zagrebtime zone can then derive from the market instead of being constant (all current markets HR/AT/SI happen to share CET).12. Gotchas & lessons learned
no-literal-stringinjsx-onlymode also flagst("key")arguments and className ternariescallees.exclude; hoist class ternaries into plain-JS helpersgetMessageFallback/onErrorcannot be passed from Server to Client Components (they are functions)"use client"module (intl-client-provider.tsx)ENVIRONMENT_FALLBACKwhen a date is formatted with no time zoneTIME_ZONEon server config, client provider, and email translatorgetTranslationsis unavailablecreateTranslator(viagetEmailTranslator) with the locale resolved bygetRequestLocaleat send time{count, plural, one {} few {} other {}}, not a binary singular/plural helperpnpm exec tscsometimes runs a deps-status check that fails on ignored build scripts./node_modules/.bin/tsc --noEmitdirectly if that happens.toFixed(2)€and the customformatDateare not yet locale-aware (see TODOs)13. Files that had hardcoded Croatian (migration checklist)
Every file below contained hardcoded user-facing Croatian and was migrated to
t()/t.rich()in this branch. If the app is ever re-internationalized from scratch, use this as the checklist of files to re-verify. It does not include the i18n infrastructure files (catalogs,config.ts,request.ts, the provider,next-intl.d.ts, the switcher,use-nav-translation.ts), which are new rather than migrated.Deliberately not migrated (intentional, so do not "fix" them): SEO keyword arrays in
layout.tsx,manifest.tsandnavigation.tsmanifest labels,constants/name-mappings.ts(store/city proper nouns), mock content data (updates/posts.ts,suggestions/suggestions.ts), theSlovenščinaendonym, and the deferred Zod messages inlib/api/schemas/*(see TODOs).Root, layout & legal
app/layout.tsxapp/not-found.tsxapp/(root)/page.tsxapp/(root)/components/sections/hero-section.tsxapp/(root)/components/sections/hero-actions.tsxapp/privacy-policy/page.tsxapp/terms-of-service/page.tsxapp/data-deletion/page.tsxcomponents/custom/legal-page.tsxHeader, footer, sidebar & shared UI
components/custom/header/header.tsxcomponents/custom/header/user-menu.tsxcomponents/custom/header/notifications-dropdown.tsxcomponents/custom/footer.tsxcomponents/custom/app-sidebar.tsxcomponents/custom/search-bar.tsxcomponents/custom/coming-soon.tsxcomponents/custom/no-results.tsxcomponents/custom/camera-scanner.tsxcomponents/custom/view-switcher.tsxcomponents/custom/price-history-period-select.tsxcomponents/custom/store-chain-select.tsxcomponents/custom/store-chain-multi-select.tsxcomponents/custom/oauth-error-toast.tsxcomponents/custom/offline/offline-indicator.tsxcomponents/custom/offline/last-synced-label.tsxcomponents/custom/pwa/install-banner.tsxcomponents/custom/pwa/install-sidebar-banner.tsxcomponents/custom/pwa/install-instructions-sheet.tsxAuth (forms & modals)
components/custom/header/forms/auth-modal.tsxcomponents/custom/header/forms/login-form.tsxcomponents/custom/header/forms/signup-form.tsxcomponents/custom/header/forms/forgot-password-form.tsxcomponents/custom/header/forms/inbox-notice.tsxcomponents/custom/header/forms/account-credentials-form.tsxcomponents/custom/header/forms/linked-accounts.tsxcomponents/custom/header/forms/profile-modal.tsxcomponents/custom/header/forms/security-modal.tsxcomponents/custom/header/forms/user-preferences-modal.tsxapp/reset-password/page.tsxapp/reset-password/reset-password-modal.tsxProducts & product detail
app/products/page.tsxapp/products/components/products-client.tsxapp/products/components/product-info-display.tsxapp/products/components/product-info-table.tsxapp/products/components/product-action-buttons.tsxapp/products/components/watchlist-action-button.tsxapp/products/components/product-item/product-info.tsxapp/products/components/product-item/product-price.tsxapp/products/components/forms/add-to-shopping-list-form.tsxapp/products/components/forms/shopping-list-selector.tsxapp/products/components/forms/quantity-input.tsxapp/products/components/forms/mark-as-checked-checkbox.tsxapp/products/components/forms/watchlist-item-modal.tsxapp/products/[id]/page.tsxapp/products/[id]/components/product-detail-client.tsxapp/products/[id]/components/store-item/store-item.tsxapp/products/[id]/components/store-item/store-prices-table.tsxapp/products/[id]/components/price-history/price-history-base.tsxapp/products/[id]/components/price-history/price-history-chart.tsxShopping lists
app/(user)/shopping-lists/page.tsxapp/(user)/shopping-lists/components/shopping-lists-client.tsxapp/(user)/shopping-lists/components/shopping-list-item.tsxapp/(user)/shopping-lists/components/create-shopping-list-button.tsxapp/(user)/shopping-lists/components/forms/shopping-list-modal.tsxapp/(user)/shopping-lists/components/forms/delete-shopping-list-dialog.tsxapp/(user)/shopping-lists/hooks/use-shopping-list-modal.tsapp/(user)/shopping-lists/[id]/page.tsxapp/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsxapp/(user)/shopping-lists/[id]/components/shopping-list-header.tsxapp/(user)/shopping-lists/[id]/components/shopping-list-info-table.tsxapp/(user)/shopping-lists/[id]/components/shopping-list-action-buttons.tsxapp/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsxapp/(user)/shopping-lists/[id]/components/items/shopping-list-items.tsxapp/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsxapp/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list.tsxapp/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card.tsxapp/(user)/shopping-lists/[id]/components/stores/shopping-list-items-table.tsxapp/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.tsapp/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.tsWatchlist
app/(user)/watchlist/page.tsxapp/(user)/watchlist/components/watchlist-client.tsxapp/(user)/watchlist/components/watchlist-item.tsxapp/(user)/watchlist/components/watchlist-item-discount-info.tsxapp/(user)/watchlist/components/create-discounted-list-button.tsxDigital cards
app/(user)/digital-cards/page.tsxapp/(user)/digital-cards/components/digital-cards-client.tsxapp/(user)/digital-cards/components/digital-card-item.tsxapp/(user)/digital-cards/components/forms/digital-card-modal.tsxOther pages
app/(user)/spending/page.tsxapp/map/page.tsxapp/map/components/map-client.tsxapp/suggestions/page.tsxapp/suggestions/components/suggestions-client.tsxapp/suggestions/[id]/page.tsxapp/suggestions/[id]/components/suggestion-details-client.tsxapp/updates/page.tsxapp/updates/components/updates-client.tsxapp/updates/[id]/page.tsxapp/offline/page.tsxapp/offline/components/offline-retry-button.tsxDashboard & statistics
app/dashboard/page.tsxapp/dashboard/components/dashboard-content.tsxapp/dashboard/components/admin-users-table.tsxapp/statistics/page.tsxapp/statistics/components/health-status.tsxapp/statistics/components/store-item.tsxapp/statistics/components/stores-list.tsxEmails & their wiring
emails/verification-email.tsxemails/password-reset-email.tsxemails/set-password-email.tsxemails/change-email-confirmation.tsxemails/components/action-email.tsxemails/components/email-layout.tsxlib/email/email-service.tslib/auth.ts14. Future improvements & TODOs
.toFixed(2)€and dates via a customformatDate, so a German user sees Croatian-style numbers. Replace withuseFormatter().number(v, { style: "currency", currency: "EUR" })andformat.dateTime(...), ideally behind one shared helper reused everywhere.market: 'HR' | 'AT' | 'SI'as a separate setting (see section 11), which controls stores, price sources, and currency independently of language.lib/api/schemas/*. These schemas are double-duty (form validation and API-response parsing via the@/lib/api/typesbarrel), so translating them cleanly needs a message-object factory plus passthrough static instances for the DTO schemas. Thevalidationnamespace keys are already in the catalogs; only the form-only inline schemas (forgot-password, reset-password) are translated so far.languagefield via better-authadditionalFields, synced on login, would make the preference follow the account.deandsl. Current German and Slovenian are solid machine-quality; a native pass (or a TMS like Tolgee/Crowdin/Lokalise if this grows) is the professional next step.hr). If rankingen/de/slcontent matters later, that requires URL-based routing plushreflangtags and per-locale sitemaps, a deliberate trade-off away from the current simplicity.disscount_technical_documentation.mdanddisscount_state_of_reality.mdstill describe the app as "Croatian only / i18n not wired"; refresh them now that this subsystem is live.