Skip to main content

Multilingual (i18n)

Since April 2026, the portal is fully available in English and German. End customers switch the language at any time via a flag dropdown at the top right of the header, and the choice is stored in LocalStorage (licensio_locale).

Architecture​

The i18n package lives as its own workspace under packages/i18n/:

packages/i18n/
├── src/
│ ├── index.ts → re-exports (server-safe)
│ ├── translate.ts → reine t()-Funktion, kein React
│ ├── LanguageContext.tsx → 'use client' Provider + useLanguage
│ ├── LanguageDropdown.tsx → 'use client' EN/DE-Dropdown
│ ├── server.ts → getServerLocale() Helper für Server Components
│ └── locales/
│ ├── en.ts → ~1.100 Keys, Single Source of Truth
│ └── de.ts → typed gegen en (TS-Error bei fehlenden Keys)
├── package.json
└── ...

Apps import only from @licensio/i18n:

import { useLanguage, LanguageProvider, LanguageDropdown, t, type Locale } from '@licensio/i18n'

Usage​

Client Components​

'use client'
import { useLanguage } from '@licensio/i18n'

function MyComponent() {
const { t, locale, setLocale } = useLanguage()
return <h1>{t('portal.dashboard.title')}</h1>
}

With variable interpolation​

t('portal.products.heading', { count: '12' })
// EN: "Products (12)"
// DE: "Produkte (12)"

Server Components​

t() from @licensio/i18n is React-free and works on the server, but needs the locale explicitly:

import { t } from '@licensio/i18n'
const subject = t('de', 'email.welcome.subject')

For Server Components that should derive the locale from a URL param or the provider default, there is the helper getServerLocale() from @licensio/i18n (re-exported from server.ts): priority ?lang= (URL) → provider default_locale → 'en'. Synchronous, framework-agnostic.

import { getServerLocale, t } from '@licensio/i18n'
const locale = getServerLocale(searchParams, tenant.settings?.default_locale)
const title = t(locale, 'admin.something.title')

Note Next.js 16: searchParams is a Promise, so in the page first await searchParams, then pass it to getServerLocale.

For SSR pages with many dynamic strings, we alternatively extract the view layer into a client component (pattern: OverviewView.tsx, LicenseTabView.tsx, LegalPageView.tsx).

In addition, the LanguageProvider (client) reads the ?lang= URL param on first paint (before LocalStorage, before the default prop), so deep links from the marketing site (…?lang=en) also work in the portal/admin.

Provider settings​

Configurable per provider in tenants.settings:

  • default_locale: 'en' or 'de' : default language on first visit (overridable via the dropdown)
  • enabled_locales: ['en', 'de'] : restricts the available languages. If only one language is allowed, the dropdown does not render.

Backwards compat: if default_locale is not set, the LanguageProvider falls back to 'de', which protects live providers without a migration.

Translated areas (Portal)​

100% translated:

  • Auth: login, OTP flow, password reset (banners/errors/success)
  • Dashboard: sidebar, overview KPIs, downloads, notifications (incl. time-ago + notification badges)
  • Products: list (filter/search/cards), detail (print settings, accessories, base parts), feed export (complete 738-line form)
  • Account: all 4 tabs (My Account, License & Contract, Printers, CRM) with all sub-components (AccountEditForm with 15 countries, ChangeEmailModal, CancelSection, IntervalSection, InvoicesSection, LegalSection, PackageUsageSection)
  • Support: FAQ, ticket form, ticket list, ticket detail (sender labels, locale-aware date formatting)
  • Printers: PrinterList, PrinterForm (incl. IP help), printer detail (5 sections + 4-step setup wizard + modals)
  • Join Flow: /join overview + /join/[packageSlug] registration form (4 steps: company → OTP → password → payment) + /join/success
  • Register Flow: /register/[token] (full reg + checkout-only + invalid link), /register/payment, /register/success
  • Public landing page: header (login/register), packages section (recommended badge, feature lists), footer (legal column + powered-by), 4 demo views of the portal preview (dashboard/products/assets/account)
  • Components: OnboardingTour (5 steps), Lightbox, BackButton, LegalFooter, MobilePreview
  • Misc: maintenance page, unsubscribe page, legal pages (Impressum/AGB/Datenschutz)

Date and number formatting​

Locale-specific via Locale-aware helpers:

  • DE: de-DE (date: 15.04.2026, numbers: 20,00)
  • EN: en-US (date: 04/15/2026, numbers: 20.00)

Example:

const { locale } = useLanguage()
const dateStr = new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')

Adding new keys​

  1. Add the key in packages/i18n/src/locales/en.ts
  2. Add the SAME key in packages/i18n/src/locales/de.ts with the German translation, otherwise TypeScript reports a compile error (Record<TranslationKey, string>)
  3. Use it in a component: t('mein.neuer.key')

Translated areas (Admin)​

The Admin area has been migrated systematically in tiers since May 2026. Tier 1+2 (12 components) are done; ~22 small files + 7 sub-components (Tier 3) are still pending.

Already translated:

  • Auth: login (with floating dropdown at the top right), forgot password, reset password (Supabase manages the actual reset mail)
  • Chrome: header (dropdown left of the email + LogoutButton), sidebar (nav items + unread counter)
  • Dashboard: welcome heading, KPIs (products/active customers/downloads), summary, admin notifications + open tickets
  • Customers: list (filter pills + status badges + activate button + toast messages), customer detail (customer/[id] : all fields, packages, notes, activation/rejection, ~115 keys), invite form (package selection + 31 countries)
  • Products: list heading + import/export/create buttons, product editor (products/[id]/edit : general/variants/print settings/accessories, ~50 keys)
  • Assets: AssetManager (logos / product images / accessories / base parts + empty states)
  • Settings: advanced settings hub with 13 tiles, Language & Region page, billing (BillingForm, ~50 keys + 14 countries), branding (BrandingForm, ~40 keys), legal texts (LegalForm), Landing Page Builder, stub pages + section helpers, Danger Zone, email notifications page
  • Support: ticket list + ticket detail, product suggestions tab (SuggestionList + detail panel)
  • FAQs: FaqManager (CRUD + categories + templates), accessories library (AccessoriesManager)
  • Onboarding: OnboardingChecklist (6-step popup) + OnboardingWizard
  • Maintenance, Forgot, Reset: public-facing pages

Still open (Tier 3 / Phase 3): ~22 small files, including NotificationsManager, NewsletterManager, PrintFarm stub, IntervalEditor, ImageUploader, plus 7 sub-components (VariantEditor, AccessoryAutocomplete, ImageUploader, PrintSettingsEditor, CategorySelect, CompatibilitySelect, BasePartPicker) and 5 server-component headings. RegisterForm keeps its own inline EN/DE dict (already works bilingually, shares the LocalStorage key). DomainForm + StripeForm are still hardcoded German.

Email translation​

The transactional email routes read from the central template system (apps/{admin,portal}/lib/email-templates.ts : 2 copies kept in sync). The locale is resolved country-aware (see resolveEmailLocale / resolveCustomerLanguage below). See Email Templates for the provider view of the 10 template slots + variables.

Migrated routes (as of 2026-05-28)​

Admin:

  • /api/customers/[id]/activate : activation slot
  • /api/customers/[id]/cancel : cancellation slot
  • /api/customers/[id]/change-email : email_change_old + email_change_new (2 sends)
  • /api/customers/[id]/reject : rejection slot
  • /api/register : welcome slot

Portal:

  • /api/portal/cancel-subscription : cancellation slot
  • /api/portal/cancel-subscription/withdraw : cancellation_withdraw slot
  • /api/portal/change-interval : interval_change slot

Helper API​

import { renderEmailTemplate, resolveEmailLocale } from '@/lib/email-templates'

const { subject, body } = renderEmailTemplate(
'activation',
{
tenant_name: tenant.name,
kunde_firma: customer.company,
kundennummer: customer.customer_number,
support_email: tenant.settings.support_email,
},
tenant.settings,
)

renderEmailTemplate(slot, vars, tenantSettings, country?) does: (1) locale via resolveEmailLocale(tenantSettings?.default_locale, country), (2) overlay the provider custom template onto the default template (per field), (3) variable interpolation. The provider custom template wins per field; missing fields fall back to the locale default.

resolveEmailLocale(default_locale, country?) is country-aware (step E, outbound i18n): an explicit default_locale ('de'/'en') wins; otherwise the provider country decides: DE/AT → de, any other country → en. So a non-DE provider without default_locale gets English mails, while Plant Technology (country DE) stays German. All mails sent via getMailer() (invitation, payment link, OTPs, cancellation, interval, reject/activate, newsletter + notification broadcast, Stripe webhook activation) are localized through this; shared email.chrome.* keys carry footer/fallback-link/CTA buttons.

Non-migrated routes​

  • /api/tickets/[id] (ticket reply) : still uses the old t(locale, key) pattern. Migration with the Stripe webhook sprint.
  • /api/portal/tickets (ticket forward) : see above.
  • Stripe webhook routes (portal) : out of scope until the Stripe webhook sprint.

Supabase-managed​

Reset password email: the locale is configured via Supabase Studio email templates, outside the code and outside the template system.

Known limitations​

  • Hydration flicker: if the LocalStorage locale differs from the server default, the user sees the default language for ~30ms, then it switches. Acceptable: the marketing site has the same flicker.
  • Provider user content (product names, FAQ texts, notifications) is not translated automatically: providers maintain content themselves in the desired language.
  • Admin Tier 3 (open): StripeForm, DomainForm, NotificationsManager, NewsletterManager, PrintFarm stub and 7 form sub-components (VariantEditor, AccessoryAutocomplete, ImageUploader, PrintSettingsEditor, CategorySelect, CompatibilitySelect, BasePartPicker) as well as 5 server-component headings are still hardcoded German.

Status​

As of 2026-05-29. Portal 100% translated. Admin: Tier 1+2 completed (12 components, ~480 keys), Tier 3 mostly done (cleanup sprint 2026-05-21 to 26: 4 of 6 Tier-3 tickets fully carried through, landing-editor only partially done, the LandingPageBuilder still has ~30 hardcoded DE chrome strings, Danger Zone open). The Multi-Locale Foundation sprint migrated 7 email routes to the central renderEmailTemplate helper + a language dropdown at provider registration + default_locale in tenants.settings. Total dictionary ~1,100 keys in the @licensio/i18n workspace.

Complete inventory of the remaining hardcoded DE strings: docs/i18n-audit.md (in the repo).