chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-11 08:11:01 +02:00
commit f0853ebe73
188 changed files with 20784 additions and 0 deletions

101
apps/app/src/i18n/config.ts Normal file
View File

@@ -0,0 +1,101 @@
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
// message functions (`m`).
//
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
import { useSyncExternalStore } from 'react'
import { locales, baseLocale, overwriteGetLocale, getLocale } from '../paraglide/runtime.js'
export const supportedLocales = locales
export const defaultLocale = baseLocale
export type Locale = (typeof locales)[number]
// `getLocale()` reads the active locale (via the overwrite below) — used by
// pages that format dates/values for the current language.
export { getLocale }
const LANGUAGE_STORAGE_KEY = 'seminarhof.language'
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
const normalizeLanguage = (value: string | null | undefined): Locale | undefined => {
const short = value?.slice(0, 2).toLowerCase()
return supportedLocales.includes(short as Locale) ? (short as Locale) : undefined
}
// Initial locale: persisted choice → browser → <html lang> → base. (Ported from
// the old i18next init; germantax has no in-app switcher, locale is read once.)
export function detectInitialLocale(): Locale {
if (typeof window === 'undefined') return defaultLocale
return (
normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)) ??
normalizeLanguage(window.navigator.language) ??
normalizeLanguage(window.document.documentElement.lang) ??
defaultLocale
)
}
// ── reactive locale store ────────────────────────────────────────────────────
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
// it to a tiny external store: `getLocale` reads `activeLocale`; components
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
let activeLocale: Locale = defaultLocale
const listeners = new Set<() => void>()
overwriteGetLocale(() => activeLocale)
export function setActiveLocale(next: Locale): void {
if (next === activeLocale) return
activeLocale = next
// Persist + reflect on <html lang> (ported from the old i18next languageChanged
// handler), so a reload restores the choice.
if (typeof window !== 'undefined') {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next)
window.document.documentElement.lang = next
}
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// Resolve the initial locale once at startup (import side-effect, matching the
// old i18n.ts init). Importing this module from the app entry runs it.
activeLocale = detectInitialLocale()
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
// used to live; it also returns the active locale + direction for components
// that need them (e.g. the language switcher).
export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } {
const locale = useSyncExternalStore<Locale>(subscribe, () => activeLocale, () => activeLocale)
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
}
// ── i18n-debug masking ───────────────────────────────────────────────────────
// When enabled, every *translated* string is masked to block glyphs (█) so any
// readable text left on screen is text that never went through a message (a
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
export function isI18nDebug(): boolean {
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
return window.localStorage?.getItem('i18n-debug') === '1'
}
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
export function maskI18n(s: string): string {
return isI18nDebug() ? s.replace(/\S/g, '█') : s
}

View File

@@ -0,0 +1,116 @@
// AUTO-GENERATED by @pikku/paraglide — do not edit.
// Source of truth: the `enum__*` keys in the message catalog.
import { m } from './messages.js'
import type { I18nString } from '@pikku/react'
/** A message accessor — call it at render time so the label tracks the active locale. */
export type I18nMessage = () => I18nString
/** A static, exhaustive map from an enum member to its branded i18n label. */
export type EnumI18n<E extends string> = Record<E, I18nMessage>
export const role = {
client: m.enum__role__client,
admin: m.enum__role__admin,
owner: m.enum__role__owner,
} satisfies EnumI18n<"client" | "admin" | "owner">
export type RoleKey = keyof typeof role
export const participantKind = {
overnight: m.enum__participant_kind__overnight,
day_guest: m.enum__participant_kind__day_guest,
} satisfies EnumI18n<"overnight" | "day_guest">
export type ParticipantKindKey = keyof typeof participantKind
export const dietary = {
omnivore: m.enum__dietary__omnivore,
vegetarian: m.enum__dietary__vegetarian,
vegan: m.enum__dietary__vegan,
gluten_free: m.enum__dietary__gluten_free,
lactose_free: m.enum__dietary__lactose_free,
pescatarian: m.enum__dietary__pescatarian,
unknown: m.enum__dietary__unknown,
} satisfies EnumI18n<"omnivore" | "vegetarian" | "vegan" | "gluten_free" | "lactose_free" | "pescatarian" | "unknown">
export type DietaryKey = keyof typeof dietary
export const severity = {
mild: m.enum__severity__mild,
moderate: m.enum__severity__moderate,
severe: m.enum__severity__severe,
standard: m.enum__severity__standard,
separate_prep: m.enum__severity__separate_prep,
} satisfies EnumI18n<"mild" | "moderate" | "severe" | "standard" | "separate_prep">
export type SeverityKey = keyof typeof severity
export const enquiryStatus = {
pending: m.enum__enquiry_status__pending,
approved: m.enum__enquiry_status__approved,
declined: m.enum__enquiry_status__declined,
waitlisted: m.enum__enquiry_status__waitlisted,
} satisfies EnumI18n<"pending" | "approved" | "declined" | "waitlisted">
export type EnquiryStatusKey = keyof typeof enquiryStatus
export const invoiceKind = {
deposit: m.enum__invoice_kind__deposit,
final: m.enum__invoice_kind__final,
} satisfies EnumI18n<"deposit" | "final">
export type InvoiceKindKey = keyof typeof invoiceKind
export const invoiceStatus = {
outstanding: m.enum__invoice_status__outstanding,
paid: m.enum__invoice_status__paid,
cancelled: m.enum__invoice_status__cancelled,
} satisfies EnumI18n<"outstanding" | "paid" | "cancelled">
export type InvoiceStatusKey = keyof typeof invoiceStatus
export const emailKind = {
send_contract_and_deposit_email: m.enum__email_kind__send_contract_and_deposit_email,
send_deposit_reminder_email: m.enum__email_kind__send_deposit_reminder_email,
send_reservation_confirmed_email: m.enum__email_kind__send_reservation_confirmed_email,
send_booking_confirmed_email: m.enum__email_kind__send_booking_confirmed_email,
send_cancellation_email: m.enum__email_kind__send_cancellation_email,
send_enquiry_declined_email: m.enum__email_kind__send_enquiry_declined_email,
} satisfies EnumI18n<"send_contract_and_deposit_email" | "send_deposit_reminder_email" | "send_reservation_confirmed_email" | "send_booking_confirmed_email" | "send_cancellation_email" | "send_enquiry_declined_email">
export type EmailKindKey = keyof typeof emailKind
export const flagReason = {
deposit_overdue: m.enum__flag_reason__deposit_overdue,
short_window: m.enum__flag_reason__short_window,
contract_declined: m.enum__flag_reason__contract_declined,
} satisfies EnumI18n<"deposit_overdue" | "short_window" | "contract_declined">
export type FlagReasonKey = keyof typeof flagReason
export const building = {
gaestehaus: m.enum__building__gaestehaus,
haupthaus: m.enum__building__haupthaus,
} satisfies EnumI18n<"gaestehaus" | "haupthaus">
export type BuildingKey = keyof typeof building
export const floor = {
eg: m.enum__floor__eg,
og: m.enum__floor__og,
} satisfies EnumI18n<"eg" | "og">
export type FloorKey = keyof typeof floor
export const allergen = {
gluten_strict: m.enum__allergen__gluten_strict,
nuts: m.enum__allergen__nuts,
soy: m.enum__allergen__soy,
dairy: m.enum__allergen__dairy,
egg: m.enum__allergen__egg,
fish: m.enum__allergen__fish,
shellfish: m.enum__allergen__shellfish,
sesame: m.enum__allergen__sesame,
celery: m.enum__allergen__celery,
mustard: m.enum__allergen__mustard,
other: m.enum__allergen__other,
} satisfies EnumI18n<"gluten_strict" | "nuts" | "soy" | "dairy" | "egg" | "fish" | "shellfish" | "sesame" | "celery" | "mustard" | "other">
export type AllergenKey = keyof typeof allergen
export const bookingStatus = {
enquiry: m.enum__booking_status__enquiry,
reserved: m.enum__booking_status__reserved,
confirmed: m.enum__booking_status__confirmed,
ended: m.enum__booking_status__ended,
cancelled: m.enum__booking_status__cancelled,
} satisfies EnumI18n<"enquiry" | "reserved" | "confirmed" | "ended" | "cancelled">
export type BookingStatusKey = keyof typeof bookingStatus

View File

@@ -0,0 +1,24 @@
// Typed message functions for the app. `m.landing_title()` returns a branded
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
// `t('landing.title')` used to — no call-site boilerplate.
//
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
// per-message tree-shaking — fine here: locale files are KB and the app bundled
// all of en.json under i18next anyway.
import { m as _m } from '../paraglide/messages.js'
import type { I18nString } from '@pikku/react'
import { maskI18n } from './config.js'
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
const _wrapped: Record<string, unknown> = {}
for (const key of Object.keys(_raw)) {
const fn = _raw[key]
_wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn
}
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
export const m = _wrapped as unknown as Branded<typeof _m>