commit 00c0dbd5ea702a6abca1013927813bcaf5f93d2b Author: e2e Date: Fri Jul 10 23:39:57 2026 +0200 chore: seminarhof customer project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f8dfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +vendor/* +!vendor/pikku-cli.tgz +node_modules +dist +.vite +.tanstack +*.tsbuildinfo + +# Coverage artifacts — c8's raw output and the function-coverage report +coverage/ +.nyc_output/ + +# Test reports — generated by cucumber (e2e/.reports/cucumber-report.html) +.reports/ + +# Runtime artifacts — dev DB, uploaded content, scratch files +/.pikku-runtime/ +/.e2e-runtime/ +*.db +*.db-shm +*.db-wal + +# Production data — never commit +data/seed/ +data/seed-scrubbed/ +docs/handover/ + +# Generated by Cloudflare deploy bundling +packages/functions/.deploy/ +.deploy/ + +# Generated by @pikku/cli — regenerated on `pikku all`. +# Ignore the entire .pikku/ tree (it includes meta JSON, schemas, scaffolds, +# pikku-bootstrap, the dev SQLite, etc.) and whitelist only the api.gen.ts +# stub that keeps the skeleton compilable pre-`pikku all`. +packages/functions/.pikku/ +!packages/functions/.pikku/client/ +packages/functions/.pikku/client/* +!packages/functions/.pikku/client/api.gen.ts +src/scaffold/*.gen.ts +packages/functions/src/pikku/* +packages/functions/src/scaffold/*.gen.ts +packages/functions/src/scaffold/*.gen.js +packages/functions/src/types/*.gen.ts +packages/functions-sdk/src/pikku/*.gen.ts +packages/functions-sdk/src/pikku/*.gen.d.ts +versions.pikku.json + +# Bun +.bun/ + +# Playwright MCP debug artifacts +.playwright-mcp/ + +# Compiled classification sidecar — regenerated by `pikku db migrate` from db/annotations.ts +db/annotations.gen.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4e72ed7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# Project conventions + +## Error handling + +Always use **defined error classes** for caught / expected exceptions — never +`throw new Error('some_string')`. + +- Domain errors live in `packages/functions/src/errors.ts`. Each extends a + Pikku core error (`BadRequestError`, `ConflictError`, `ForbiddenError`, + `NotFoundError`, …) and is registered with `addError(...)` so it maps to the + correct HTTP status and a stable client-facing code. +- Raw `throw new Error(...)` is only acceptable for truly unexpected, + programmer-error conditions that should surface as a 500. +- Validation/contract failures (bad params, illegal state transitions, etc.) + must be a defined error so callers can branch on the type and the client + receives a meaningful status + message. + +## Sessions (optional auth everywhere) + +`sessionCookieMiddleware` is registered globally (`addHTTPMiddleware('*', …)` +in `wirings/middleware.ts`), so the session cookie is resolved on **every** +route — including `auth: false` / `pikkuSessionlessFunc` endpoints. + +- In a sessionless function, read the optional session from the third arg: + `func: async (services, input, { session }) => { const s = await session?.get(); … }`. + `s` is `undefined` for anonymous callers and the `UserSession` + (`{ userId, role }`) for logged-in ones. +- This lets a public endpoint vary its response by viewer role (e.g. show + admins more than anonymous visitors) without a separate authenticated route. +- Use `pikkuFunc` (required session, third arg `{ session }` non-optional) only + when the route must reject anonymous callers outright. + +## Database changes (Pikku DB-first pipeline) + +Schema changes go **DB-first**, in this order — doing it code-first breaks the +Pikku inspector / generated types: + +1. Edit the SQL (`db/sqlite/*.sql`, `db/sqlite-seed.sql`). +2. Regenerate: `yarn db:reset && yarn db:migrate` + (`pikku db migrate` refreshes `.pikku/db/schema.d.ts` and the generated + zod at `.pikku/db/zod.gen.ts`, imported as `#pikku/db/zod.gen.js`). +3. Update backend function logic against the new types. +4. Regenerate the client SDK: `yarn workspace @project/functions pikku all`. +5. Update the frontend / consumers. + +This is a pre-production project — edit migrations in place rather than +appending compatibility migrations. diff --git a/apps/app/.gitignore b/apps/app/.gitignore new file mode 100644 index 0000000..11b69e0 --- /dev/null +++ b/apps/app/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +.vite + +# Generated by @pikku/cli — regenerated on `pikku all`. +src/gen/ + +# Generated by @tanstack/router-plugin — the seed at src/routeTree.gen.ts +# is committed so tsc passes before the plugin has run; the plugin +# overwrites it as soon as dev starts. diff --git a/apps/app/.stylelintrc.json b/apps/app/.stylelintrc.json new file mode 100644 index 0000000..f4ce2ae --- /dev/null +++ b/apps/app/.stylelintrc.json @@ -0,0 +1,33 @@ +{ + "extends": ["stylelint-config-standard"], + "plugins": ["stylelint-use-logical"], + "rules": { + "csstools/use-logical": [ + "always", + { + "except": [ + "top", + "bottom", + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height" + ] + } + ], + "declaration-no-important": true, + "at-rule-no-unknown": [ + true, + { "ignoreAtRules": ["tailwind", "apply", "variants", "responsive", "screen", "layer"] } + ] + }, + "ignoreFiles": [ + "dist/**", + "node_modules/**", + "src/routeTree.gen.ts", + "**/*.gen.ts", + "**/*.gen.d.ts" + ] +} diff --git a/apps/app/README.md b/apps/app/README.md new file mode 100644 index 0000000..04d01ae --- /dev/null +++ b/apps/app/README.md @@ -0,0 +1,49 @@ +# `_app_template_` (scaffold-resident) + +Skeleton for every user app. **Lives in the scaffold container, not the user's repo.** Users never see this directly — the scaffold instantiates from it when a new app is needed. + +## How it gets instantiated + +`pikku create-app ` (landed in #58) reads this directory from the scaffold's known path (`/opt/fabric/_app_template_/` inside the container) and: + +1. Copies every file to `apps//` in the user's repo +2. Substitutes placeholders — `app`, `@project/app`, `5001`, `App`, `0.0.1`, `2026-04-17T11:42:52Z` +3. Adds to yarn workspaces, runs `yarn install` +4. Regenerates Caddyfile to route to the new app's dev server +5. Stamps `package.json` → `fabric.templateVersion` with the current skeleton version (for 3-way merge on later `upgrade-app`) + +On first scaffold boot against an empty `apps/` dir, the scaffold auto-runs `create-app app` to seed the default. + +## Placeholders + +| Placeholder | Example | Notes | +|---|---|---| +| `app` | `admin` | Dir name + workspace slug | +| `@project/app` | `@project/admin` | `package.json` `name` | +| `5001` | `5002` | Next free port from 5001+ | +| `App` | `Admin` | ``, app header | +| `0.0.1` | `0.0.1` | Version at instantiation — ancestor for `upgrade-app` | +| `2026-04-17T11:42:52Z` | ISO timestamp | Audit | + +## What's here + +- Vite + Mantine + TypeScript + ESLint + Stylelint config +- Root providers wiring (`main.tsx`) — react-query, router, i18next, Pikku RPC +- Theme entry (`src/theme.css`) — imports `@project/components/themes/default.css`, allows local overrides +- Default routes: `/login`, `/logout`, `/hello` +- Auth config scaffold (Auth0 active; GitHub / Google / Microsoft / Passwordless as `.ts.inactive` templates) +- EN locale baseline + +## What's NOT here + +- **Widgets / components / themes** → `@project/components` in the user's template repo (shared across every app) +- **Backend functions** → `@project/functions` +- **App-specific pages / wirings** — land in the instantiated `apps/<name>/src/pages/` after creation + +## Upgrading an app to this skeleton version + +```sh +pikku upgrade-app app # 3-way merge (see #57) +``` + +Uses `package.json` → `fabric.templateVersion` as the ancestor. Untouched files update; edited files get conflict markers or interactive diff. diff --git a/apps/app/eslint.config.js b/apps/app/eslint.config.js new file mode 100644 index 0000000..d1f7c89 --- /dev/null +++ b/apps/app/eslint.config.js @@ -0,0 +1,49 @@ +import i18next from 'eslint-plugin-i18next' +import react from 'eslint-plugin-react' +import jsxA11y from 'eslint-plugin-jsx-a11y' + +/** + * Enforces the CONVENTIONS.md rules: + * - i18n keying (no-literal-string) + * - strict jsx-a11y + * - react hooks / components + * + * Custom rules that ship in Stage 3 (noted in CONVENTIONS.md): + * - no-array-methods-in-widgets + * - no-date-libraries-in-widgets + */ +export default [ + { + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/routeTree.gen.ts', + '**/*.gen.ts', + '**/*.gen.d.ts', + 'dist/**', + 'node_modules/**', + ], + plugins: { i18next, react, 'jsx-a11y': jsxA11y }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + rules: { + // i18n keying — every visible string must go through t() + 'i18next/no-literal-string': [ + 'error', + { + markupOnly: true, + onlyAttribute: ['label', 'placeholder', 'title', 'aria-label'], + ignoreCallee: ['t', 'i18n.t', 'useTranslation'], + ignoreComponent: ['Code', 'Kbd'], + }, + ], + + // A11y — Radix defaults stay; no escape hatch to <div onClick> + ...jsxA11y.configs.strict.rules, + }, + }, +] diff --git a/apps/app/index.html b/apps/app/index.html new file mode 100644 index 0000000..839c442 --- /dev/null +++ b/apps/app/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>App + + +
+ + + diff --git a/apps/app/messages/de.json b/apps/app/messages/de.json new file mode 100644 index 0000000..a76f3ce --- /dev/null +++ b/apps/app/messages/de.json @@ -0,0 +1,507 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "common__app__title": "App", + "common__brand__name": "Seminarhof Drawehn", + "common__brand__tagline": "Ankommen, entspannen, auftanken.", + "common__nav__hello": "Hallo", + "common__nav__login": "Anmelden", + "common__nav__logout": "Abmelden", + "common__nav__toggle_direction": "Richtung umschalten", + "common__nav__toggle": "Navigation umschalten", + "common__nav__back": "Zurück", + "common__nav__sections__workspace": "Arbeitsbereich", + "common__nav__sections__public": "Öffentlich", + "common__nav__items__client": "Meine Retreats", + "common__nav__items__dashboard": "Übersicht", + "common__nav__items__bookings": "Buchungen", + "common__nav__items__enquiries": "Anfragen", + "common__nav__items__kanban": "Kanban", + "common__nav__items__availability": "Verfügbarkeit", + "common__nav__items__enquiry": "Öffentliches Anfrageformular", + "common__nav__items__events": "Veranstaltungen", + "enum__role__client": "Kunde", + "enum__role__admin": "Admin", + "enum__role__owner": "Inhaber", + "common__pages__kanban__title": "Kanban", + "common__pages__hello__subtitle": "Ihre App-Vorlage ist eingerichtet.", + "common__pages__hello__body": "Seiten setzen sich aus Widgets zusammen; Widgets sind reine Props-gesteuert; i18n-Keys werden über die Namespace-Kette aufgelöst (siehe CONVENTIONS.md).", + "common__pages__hello__rtl_tip": "Probieren Sie ?dir=rtl in dieser URL — jedes Layout muss in beide Richtungen korrekt darstellen.", + "common__pages__launchpad__title": "Startseite", + "common__pages__launchpad__client__title": "Kundenportal", + "common__pages__launchpad__client__body": "Anstehende Buchungen, Vollständigkeit %, aktuelle Rechnungen.", + "common__pages__launchpad__client__cta": "Kundenübersicht öffnen", + "common__pages__launchpad__availability__title": "Verfügbarkeit (öffentlich)", + "common__pages__launchpad__availability__body": "12-Monats-Verfügbarkeitskalender. Klicken Sie auf einen freien Tag, um anzufragen.", + "common__pages__launchpad__availability__cta": "Verfügbarkeit öffnen", + "common__pages__launchpad__admin__title": "Admin · Buchungen", + "common__pages__launchpad__admin__body": "Ersetzt die Buchungsübersicht. Filtern, suchen, Detailansicht.", + "common__pages__launchpad__admin__cta": "Admin-Buchungen öffnen", + "common__pages__launchpad__events__title": "Öffentliche Veranstaltungen", + "common__pages__launchpad__events__body": "Karten für freigegebene Retreats mit Coverbild, Daten und Veranstalter-Link.", + "common__pages__launchpad__events__cta": "Veranstaltungen öffnen", + "common__pages__launchpad__enquiry__title": "Anfrageformular", + "common__pages__launchpad__enquiry__body": "Öffentliches Buchungsformular — wird als neue Anfrage übermittelt.", + "common__pages__launchpad__enquiry__cta": "Anfrageformular öffnen", + "common__pages__client__title": "Kundenportal", + "common__pages__client__upcoming": "Anstehende Buchungen", + "common__pages__client__no_upcoming": "Keine anstehenden Buchungen.", + "common__pages__client__current_badge": "Aktuell", + "common__pages__client__participants_count": "{count} / {target} Teilnehmende", + "common__pages__client__participants_count_unknown": "{count} Teilnehmende", + "common__pages__client__completeness": "Buchungsdaten vollständig", + "common__pages__client__recent_invoices": "Aktuelle Rechnungen", + "common__pages__client__no_invoices": "Noch keine Rechnungen — Ihre Rechnung erscheint, sobald sie ausgestellt wurde.", + "common__pages__client__invoice_cols__booking": "Buchung", + "common__pages__client__invoice_cols__number": "Nummer", + "common__pages__client__invoice_cols__kind": "Art", + "common__pages__client__invoice_cols__amount": "Betrag", + "common__pages__client__invoice_cols__status": "Status", + "common__pages__client__invoice_cols__issued": "Ausgestellt", + "common__pages__booking__tabs__overview": "Übersicht", + "common__pages__booking__tabs__participants": "Teilnehmende", + "common__pages__booking__tabs__invoices": "Rechnungen", + "common__pages__booking__overview__schedule": "Zeitplan", + "common__pages__booking__overview__breakfast": "Frühstück", + "common__pages__booking__overview__lunch": "Mittagessen", + "common__pages__booking__overview__dinner": "Abendessen", + "common__pages__booking__overview__headcount": "Personenzahl", + "common__pages__booking__overview__added": "{count} / {target} hinzugefügt", + "common__pages__booking__participants__dietary_breakdown": "Ernährungsübersicht", + "common__pages__booking__participants__total_participants": "{count} Teilnehmende", + "common__pages__booking__participants__title": "Teilnehmende", + "common__pages__booking__participants__add_cta": "+ Teilnehmer:in hinzufügen", + "common__pages__booking__participants__empty": "Noch keine Teilnehmenden — fügen Sie die erste Person hinzu.", + "common__pages__booking__participants__cols__name": "Name", + "common__pages__booking__participants__cols__kind": "Art", + "common__pages__booking__participants__cols__diet": "Ernährung", + "common__pages__booking__participants__cols__allergies": "Allergien", + "common__pages__booking__participants__cols__room": "Zimmer", + "enum__participant_kind__overnight": "Übernachtung", + "enum__participant_kind__day_guest": "Tagesgast", + "enum__dietary__omnivore": "Allesesser:in", + "enum__dietary__vegetarian": "Vegetarisch", + "enum__dietary__vegan": "Vegan", + "enum__dietary__gluten_free": "Glutenfrei", + "enum__dietary__lactose_free": "Laktosefrei", + "enum__dietary__pescatarian": "Pescetarisch", + "enum__dietary__unknown": "Unbekannt", + "common__pages__booking__participants__add_modal__title": "Teilnehmer:in hinzufügen", + "common__pages__booking__participants__add_modal__name": "Name", + "common__pages__booking__participants__add_modal__kind": "Art", + "common__pages__booking__participants__add_modal__add": "Hinzufügen", + "common__pages__booking__participants__remove_confirm": "{name} entfernen?", + "common__pages__booking__participants__allergy_add": "+ Allergie", + "common__pages__booking__participants__allergy_none": "Keine Allergien", + "common__pages__booking__participants__allergy_count": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=one": "{count} Allergie", + "countPlural=other": "{count} Allergien" + } + } + ], + "common__pages__booking__participants__allergy_remove_aria": "Allergie {allergen} entfernen", + "enum__severity__mild": "Leicht", + "enum__severity__moderate": "Mittel", + "enum__severity__severe": "Schwer", + "enum__severity__standard": "Standard", + "enum__severity__separate_prep": "Getrennte Zubereitung", + "common__pages__booking__invoices__empty": "Noch keine Rechnungen — Ihre Rechnung erscheint, sobald sie ausgestellt wurde.", + "common__pages__booking__invoices__cols__number": "Nummer", + "common__pages__booking__invoices__cols__kind": "Art", + "common__pages__booking__invoices__cols__amount": "Betrag", + "common__pages__booking__invoices__cols__status": "Status", + "common__pages__booking__invoices__cols__issued": "Ausgestellt", + "common__pages__booking__invoices__cols__due": "Fällig", + "common__pages__booking__invoices__cols__paid": "Bezahlt", + "common__pages__admin__dashboard__title": "Übersicht", + "common__pages__admin__dashboard__needs_action": "Zu erledigen", + "common__pages__admin__dashboard__upcoming": "Anstehende Veranstaltungen", + "common__pages__admin__dashboard__upcoming_window": "Nächste 30 Tage", + "common__pages__admin__dashboard__all_clear": "Derzeit ist nichts zu erledigen.", + "common__pages__admin__dashboard__no_upcoming": "Keine Veranstaltungen in den nächsten 30 Tagen.", + "common__pages__admin__dashboard__more": "+{count} weitere", + "common__pages__admin__dashboard__waitlisted": "Warteliste", + "common__pages__admin__dashboard__persons": "{count} Gäste", + "common__pages__admin__dashboard__cards__pending_enquiries": "Offene Anfragen", + "common__pages__admin__dashboard__cards__contracts_to_send": "Verträge zu versenden", + "common__pages__admin__dashboard__cards__contracts_awaiting": "Verträge: Rückmeldung offen", + "common__pages__admin__dashboard__cards__deposits_outstanding": "Anzahlungen offen", + "common__pages__admin__bookings__title": "Buchungen", + "common__pages__admin__bookings__subtitle": "Ersetzt die Buchungsübersicht-Tabelle.", + "common__pages__admin__bookings__new_booking": "+ Neue Buchung", + "common__pages__admin__bookings__search_placeholder": "Veranstaltung oder Kunde suchen…", + "common__pages__admin__bookings__filters__all": "Alle", + "common__pages__admin__bookings__filters__half_house_only": "Nur halbes Haus", + "common__pages__admin__bookings__filters__year": "Jahr", + "common__pages__admin__bookings__filters__status": "Status", + "common__pages__admin__bookings__stats__showing": "{count} Buchungen", + "common__pages__admin__bookings__cols__dates": "Daten", + "common__pages__admin__bookings__cols__course": "Veranstaltung", + "common__pages__admin__bookings__cols__client": "Kunde", + "common__pages__admin__bookings__cols__status": "Status", + "common__pages__admin__bookings__cols__persons": "Personen", + "common__pages__admin__bookings__cols__half_house": "½", + "common__pages__admin__bookings__cols__deposit": "Anzahlung", + "common__pages__admin__bookings__cols__final": "Schluss", + "common__pages__admin__bookings__cols__regular_group": "★", + "common__pages__admin__bookings__deposit__missing": "—", + "common__pages__admin__bookings__deposit__outstanding": "Ausgestellt", + "common__pages__admin__bookings__deposit__paid": "Bezahlt", + "common__pages__admin__bookings__empty": "Keine Buchungen entsprechen den aktuellen Filtern.", + "common__pages__admin__enquiries__title": "Anfragen", + "common__pages__admin__enquiries__empty": "Keine Anfragen.", + "common__pages__admin__enquiries__no_dates": "Noch keine Daten", + "common__pages__admin__enquiries__transition_error": "Die Aktion konnte nicht ausgeführt werden.", + "common__pages__admin__enquiries__filters__status": "Status", + "common__pages__admin__enquiries__stats__showing": "{count} Anfragen", + "enum__enquiry_status__pending": "Offen", + "enum__enquiry_status__approved": "Bestätigt", + "enum__enquiry_status__declined": "Abgelehnt", + "enum__enquiry_status__waitlisted": "Warteliste", + "common__pages__admin__enquiries__cols__received": "Eingegangen", + "common__pages__admin__enquiries__cols__event": "Veranstaltung", + "common__pages__admin__enquiries__cols__contact": "Kontakt", + "common__pages__admin__enquiries__cols__dates": "Daten", + "common__pages__admin__enquiries__cols__persons": "Personen", + "common__pages__admin__enquiries__cols__status": "Status", + "common__pages__admin__enquiries__cols__actions": "Aktionen", + "common__pages__admin__enquiries__actions__approve": "Bestätigen", + "common__pages__admin__enquiries__actions__waitlist": "Warteliste", + "common__pages__admin__enquiries__actions__decline": "Ablehnen", + "common__pages__admin__enquiries__actions__view_booking": "Buchung ansehen", + "common__pages__admin__enquiries__approve_modal__title": "Anfrage bestätigen", + "common__pages__admin__enquiries__approve_modal__start_date": "Startdatum", + "common__pages__admin__enquiries__approve_modal__end_date": "Enddatum", + "common__pages__admin__enquiries__approve_modal__date_error": "Das Startdatum muss vor oder am Enddatum liegen.", + "common__pages__admin__enquiries__approve_modal__existing_client": "Bestehender Kunde", + "common__pages__admin__enquiries__approve_modal__new_client": "Neuer Kunde", + "common__pages__admin__enquiries__approve_modal__select_client": "Kunde", + "common__pages__admin__enquiries__approve_modal__search_client": "Nach Name oder E-Mail suchen…", + "common__pages__admin__enquiries__approve_modal__no_clients": "Keine passenden Kunden", + "common__pages__admin__enquiries__approve_modal__client_name": "Name", + "common__pages__admin__enquiries__approve_modal__client_email": "E-Mail", + "common__pages__admin__enquiries__approve_modal__client_phone": "Telefon", + "common__pages__admin__enquiries__approve_modal__client_website": "Website", + "common__pages__admin__enquiries__approve_modal__client_billing_address": "Rechnungsadresse", + "common__pages__admin__enquiries__approve_modal__date_options": "Vorgeschlagene Termine — zum Übernehmen klicken", + "common__pages__admin__enquiries__waitlist_modal__title": "Auf Warteliste setzen", + "common__pages__admin__enquiries__waitlist_modal__intro": "Die Anfrage bleibt offen, aber Wunschdaten werden vermerkt, damit sie bei frei werdenden Terminen berücksichtigt werden kann.", + "common__pages__admin__enquiries__waitlist_modal__no_options": "Diese Anfrage hat noch keine Terminoptionen und kann daher nicht auf die Warteliste gesetzt werden.", + "common__pages__admin__enquiries__decline_modal__title": "Anfrage ablehnen", + "common__pages__admin__enquiries__decline_modal__confirm": "Die Anfrage für „{event}“ vom {date} ablehnen? Dies kann nicht rückgängig gemacht werden.", + "common__pages__admin_booking__title": "Buchung · {course}", + "common__pages__admin_booking__tabs__details": "Details", + "common__pages__admin_booking__tabs__client": "Kunde", + "common__pages__admin_booking__tabs__rooms": "Zimmer", + "common__pages__admin_booking__tabs__participants": "Teilnehmende", + "common__pages__admin_booking__tabs__invoices": "Rechnungen", + "common__pages__admin_booking__tabs__overview": "Übersicht", + "common__pages__admin_booking__tabs__audit": "Protokoll", + "common__pages__admin_booking__invoices__title": "Rechnungen", + "common__pages__admin_booking__invoices__empty": "Noch keine Rechnungen für diese Buchung.", + "common__pages__admin_booking__invoices__cols__number": "Nummer", + "common__pages__admin_booking__invoices__cols__kind": "Art", + "common__pages__admin_booking__invoices__cols__amount": "Betrag", + "common__pages__admin_booking__invoices__cols__issued": "Ausgestellt", + "common__pages__admin_booking__invoices__cols__due": "Fällig", + "common__pages__admin_booking__invoices__cols__paid": "Bezahlt", + "common__pages__admin_booking__invoices__cols__status": "Status", + "common__pages__admin_booking__invoices__cols__action": "Aktion", + "enum__invoice_kind__deposit": "Anzahlung", + "enum__invoice_kind__final": "Schlussrechnung", + "enum__invoice_status__outstanding": "Offen", + "enum__invoice_status__paid": "Bezahlt", + "enum__invoice_status__cancelled": "Storniert", + "common__pages__admin_booking__invoices__create_title": "Neue Rechnung", + "common__pages__admin_booking__invoices__create": "Rechnung erstellen", + "common__pages__admin_booking__invoices__attach_pdf": "PDF anhängen (optional)", + "common__pages__admin_booking__invoices__pdf_selected": "PDF ausgewählt", + "common__pages__admin_booking__invoices__upload_pdf": "PDF hochladen", + "common__pages__admin_booking__invoices__replace_pdf": "PDF ersetzen", + "common__pages__admin_booking__invoices__open_pdf": "PDF öffnen", + "common__pages__admin_booking__invoices__mark_paid": "Bezahlt markieren", + "common__pages__admin_booking__invoices__cancel": "Stornieren", + "common__pages__admin_booking__invoices__cancel_confirm": "Diese Rechnung wirklich stornieren?", + "common__pages__admin_booking__invoices__paid_on_prompt": "Zahldatum", + "common__pages__admin_booking__invoices__errors__create": "Rechnung konnte nicht erstellt werden.", + "common__pages__admin_booking__invoices__errors__upload_pdf": "PDF konnte nicht hochgeladen werden.", + "common__pages__admin_booking__invoices__errors__pay": "Zahlung konnte nicht erfasst werden.", + "common__pages__admin_booking__invoices__errors__cancel": "Rechnung konnte nicht storniert werden.", + "common__pages__admin_booking__audit__empty": "Keine Einträge für diese Buchung.", + "common__pages__admin_booking__audit__system": "System", + "common__pages__admin_booking__audit__email_sent": "E-Mail gesendet", + "common__pages__admin_booking__audit__cols__when": "Wann", + "common__pages__admin_booking__audit__cols__who": "Wer", + "common__pages__admin_booking__audit__cols__change": "Änderung", + "enum__email_kind__send_contract_and_deposit_email": "Vertrag & Anzahlungsrechnung", + "enum__email_kind__send_deposit_reminder_email": "Anzahlungserinnerung", + "enum__email_kind__send_reservation_confirmed_email": "Reservierung bestätigt", + "enum__email_kind__send_booking_confirmed_email": "Buchung bestätigt", + "enum__email_kind__send_cancellation_email": "Stornierung", + "enum__email_kind__send_enquiry_declined_email": "Anfrage abgelehnt", + "common__pages__admin_booking__fields__event_name": "Veranstaltungsname", + "common__pages__admin_booking__fields__start_date": "Startdatum", + "common__pages__admin_booking__fields__end_date": "Enddatum", + "common__pages__admin_booking__fields__expected_persons": "Erwartete Teilnehmende", + "common__pages__admin_booking__fields__half_house": "Halbes Haus", + "common__pages__admin_booking__fields__online_ad": "Öffentliche Listung", + "common__pages__admin_booking__fields__event_outline": "Veranstaltungsüberblick", + "common__pages__admin_booking__fields__description": "Veranstaltungsbeschreibung", + "common__pages__admin_booking__fields__cover_image_url": "Coverbild-URL", + "common__pages__admin_booking__fields__organiser_website": "Veranstalter-Website", + "common__pages__admin_booking__fields__meal_time_breakfast": "Frühstückszeit", + "common__pages__admin_booking__fields__meal_time_lunch": "Mittagszeit", + "common__pages__admin_booking__fields__meal_time_dinner": "Abendessenszeit", + "common__pages__admin_booking__fields__arrival_time": "Anreisezeit", + "common__pages__admin_booking__fields__departure_time": "Abreisezeit", + "common__pages__admin_booking__fields__daily_plan": "Tagesplan", + "common__pages__admin_booking__fields__notes": "Interne Notizen", + "common__pages__admin_booking__client__title": "Kunde", + "common__pages__admin_booking__client__name": "Name", + "common__pages__admin_booking__client__contact_email": "E-Mail", + "common__pages__admin_booking__client__contact_phone": "Telefon", + "common__pages__admin_booking__client__billing_address": "Rechnungsadresse", + "common__pages__admin_booking__client__website": "Website", + "common__pages__admin_booking__client__save": "Organisation speichern", + "common__pages__admin_booking__client__saved": "Organisation gespeichert", + "common__pages__admin_booking__client__save_error": "Organisation konnte nicht gespeichert werden.", + "common__pages__admin_booking__client__regular_group": "Stammgruppe", + "common__pages__admin_booking__allergies__empty": "Keine Allergien erfasst.", + "common__pages__admin_booking__allergies__add_placeholder": "Allergen…", + "common__pages__admin_booking__allergies__add": "Hinzufügen", + "common__pages__admin_booking__status__panel_title": "Buchungsablauf", + "common__pages__admin_booking__status__flagged": "Zur Prüfung markiert", + "enum__flag_reason__deposit_overdue": "Anzahlung überfällig – Kunde hat nicht innerhalb von 14 Tagen gezahlt.", + "enum__flag_reason__short_window": "Startdatum weniger als 14 Tage entfernt – Zahlungsfrist sehr knapp.", + "enum__flag_reason__contract_declined": "Kunde hat den Vertrag abgelehnt – bitte stornieren oder besprechen.", + "common__pages__admin_booking__status__milestones__enquiry": "Anfrage", + "common__pages__admin_booking__status__milestones__reserved": "Reserviert", + "common__pages__admin_booking__status__milestones__contract_sent": "Vertrag versendet", + "common__pages__admin_booking__status__milestones__contract_signed": "Vertrag unterzeichnet", + "common__pages__admin_booking__status__milestones__deposit_paid": "Anzahlung erhalten", + "common__pages__admin_booking__status__milestones__confirmed": "Bestätigt", + "common__pages__admin_booking__status__milestones__ended": "Beendet", + "common__pages__admin_booking__status__milestones__cancelled": "Storniert", + "common__pages__admin_booking__status__deposit_window": "Tag {day} von 14", + "common__pages__admin_booking__status__deposit_overdue": "Anzahlung überfällig", + "common__pages__admin_booking__status__reminder_sent": "Erinnerung gesendet", + "common__pages__admin_booking__status__due_on": "Fällig {date}", + "common__pages__admin_booking__status__contract_not_sent_yet": "Vertragsversand geplant ~{date}", + "common__pages__admin_booking__status__awaiting_signature": "Warte auf Kundenunterschrift", + "common__pages__admin_booking__status__awaiting_deposit": "Warte auf Anzahlung", + "common__pages__admin_booking__status__actions__approve_enquiry": "Anfrage genehmigen", + "common__pages__admin_booking__status__actions__decline_enquiry": "Anfrage ablehnen", + "common__pages__admin_booking__status__actions__send_contract_now": "Vertrag jetzt senden", + "common__pages__admin_booking__status__actions__record_contract_approved": "Vertrag als unterzeichnet markieren", + "common__pages__admin_booking__status__actions__record_contract_declined": "Vertrag als abgelehnt markieren", + "common__pages__admin_booking__status__actions__record_deposit_received": "Anzahlung als erhalten markieren", + "common__pages__admin_booking__status__actions__mark_ended": "Als beendet markieren", + "common__pages__admin_booking__status__actions__cancel_booking": "Buchung stornieren", + "common__pages__admin_booking__status__actions__create_form_link": "Buchungsformular-Link erstellen", + "common__pages__admin_booking__status__actions__confirm": "Bestätigen?", + "common__pages__admin_booking__status__form_link__label": "Magic-Link — kopieren und an den Kunden senden:", + "common__pages__admin_booking__status__party__admin": "Admin-Aktion", + "common__pages__admin_booking__status__party__client": "Kunden-Aktion", + "common__pages__admin_booking__status__party__system": "Automatisch", + "common__pages__admin_booking__cancel": "Buchung stornieren", + "common__pages__admin_booking__cancel_confirm": "Diese Buchung wirklich stornieren? Das kann nicht rückgängig gemacht werden.", + "common__pages__admin_booking__rooms__title": "Zimmerplan", + "common__pages__admin_booking__rooms__occupied_other": "Von einem anderen Retreat belegt", + "common__pages__admin_booking__rooms__assign_to_participant": "Zuweisen an…", + "common__pages__admin_booking__rooms__unassign": "Entfernen", + "common__pages__admin_booking__rooms__beds": "Betten", + "common__pages__admin_booking__rooms__cols__room": "Zimmer", + "common__pages__admin_booking__rooms__cols__guests": "Gäste", + "common__pages__admin_booking__rooms__cols__assign": "Zuweisen", + "enum__building__gaestehaus": "Gästehaus", + "enum__building__haupthaus": "Haupthaus", + "enum__floor__eg": "Erdgeschoss", + "enum__floor__og": "Obergeschoss", + "common__pages__admin_booking__rooms__flags__wheelchair": "♿", + "common__pages__admin_booking__rooms__flags__ground_floor": "EG", + "common__pages__admin_booking__rooms__flags__quiet": "🔇", + "common__pages__admin_booking__rooms__flags__shared_bath": "geteilt", + "common__pages__admin_booking__rooms__flags__dogs_allowed": "🐕", + "common__pages__admin_booking__rooms__flags__double_bed_140": "2-Bett", + "common__pages__admin_booking__rooms__flags__allergy_friendly": "Allergie", + "common__pages__admin_booking__save": "Änderungen speichern", + "common__pages__admin_booking__saved": "Gespeichert", + "common__pages__admin_booking__save_error": "Speichern der Änderungen fehlgeschlagen.", + "common__pages__events__title": "Anstehende Retreats", + "common__pages__events__subtitle": "Öffentliche Veranstaltungen im Seminarhof Drawehn.", + "common__pages__events__search_placeholder": "Veranstaltung oder Veranstalter suchen…", + "common__pages__events__filters__this_year": "Dieses Jahr", + "common__pages__events__filters__next_year": "Nächstes Jahr", + "common__pages__events__filters__all": "Alle", + "common__pages__events__empty": "Aktuell keine Veranstaltungen gelistet — bitte schauen Sie in unseren Retreat-Kalender für freie Termine.", + "common__pages__events__more_info": "Mehr erfahren →", + "common__pages__staff__today": "Heute", + "common__pages__staff__prev_day": "Vorheriger Tag", + "common__pages__staff__next_day": "Nächster Tag", + "common__pages__availability__title": "Verfügbarkeit", + "common__pages__availability__venue_line": "{venue} · {from} → {to}", + "common__pages__availability__year_label": "Jahr", + "common__pages__availability__free_stat": "{free} freie Tage von {total} ({percent} %)", + "common__pages__availability__free_month_badge": "{count} frei", + "common__pages__availability__footnote": "Wählen Sie einen freien Starttag und dann einen freien Endtag, um diesen Zeitraum anzufragen — oder wählen Sie einen einzelnen Tag. Veranstaltungsnamen werden nur angezeigt, wenn der Veranstalter der öffentlichen Listung zugestimmt hat.", + "common__pages__availability__select_start_hint": "Wählen Sie einen freien Starttag.", + "common__pages__availability__select_end_hint": "Wählen Sie nun einen freien Endtag, oder fragen Sie einen einzelnen Tag an.", + "common__pages__availability__selected_range": "Ausgewählt: {range}", + "common__pages__availability__create_enquiry": "Anfrage erstellen", + "common__pages__availability__clear_selection": "Auswahl löschen", + "common__pages__availability__status__free": "Frei", + "common__pages__availability__status__enquiry": "Anfrage", + "common__pages__availability__status__reserved": "Reserviert", + "common__pages__availability__status__confirmed": "Bestätigt", + "common__pages__availability__status__half_house": "Halbes Haus", + "common__pages__enquiry__title": "Anfrage stellen", + "common__pages__enquiry__subtitle": "Erzählen Sie uns von Ihrem Retreat — wir melden uns innerhalb von 2 Werktagen.", + "common__pages__enquiry__sections__event": "Ihre Veranstaltung", + "common__pages__enquiry__sections__client": "Kunde", + "common__pages__enquiry__sections__billing": "Rechnung", + "common__pages__enquiry__sections__schedule": "Zeitplan", + "common__pages__enquiry__sections__public_listing": "Öffentliche Listung", + "common__pages__enquiry__sections__notes": "Anmerkungen", + "common__pages__enquiry__sections__terms": "Bedingungen", + "common__pages__enquiry__agb_accept": "Ich akzeptiere die AGB", + "common__pages__enquiry__fields__event_name": "Veranstaltungsname", + "common__pages__enquiry__fields__event_outline": "Veranstaltungsüberblick", + "common__pages__enquiry__fields__description": "Veranstaltungsbeschreibung", + "common__pages__enquiry__fields__start_date": "Anreisedatum", + "common__pages__enquiry__fields__end_date": "Abreisedatum", + "common__pages__enquiry__fields__expected_persons": "Erwartete Teilnehmende", + "common__pages__enquiry__fields__half_house": "Nur halbes Haus", + "common__pages__enquiry__fields__half_house_yes": "Ja", + "common__pages__enquiry__fields__half_house_no": "Nein", + "common__pages__enquiry__fields__client_name": "Name des Kunden", + "common__pages__enquiry__fields__contact_email": "Kontakt-E-Mail", + "common__pages__enquiry__fields__contact_phone": "Kontakt-Telefon", + "common__pages__enquiry__fields__billing_address": "Rechnungsadresse", + "common__pages__enquiry__fields__website": "Website", + "common__pages__enquiry__fields__arrival_time": "Anreisezeit", + "common__pages__enquiry__fields__departure_time": "Abreisezeit", + "common__pages__enquiry__fields__meal_time_breakfast": "Frühstückszeit", + "common__pages__enquiry__fields__meal_time_lunch": "Mittagszeit", + "common__pages__enquiry__fields__meal_time_dinner": "Abendessenszeit", + "common__pages__enquiry__fields__daily_plan": "Tagesplan", + "common__pages__enquiry__fields__online_ad": "Dieses Retreat öffentlich auf unserer Veranstaltungsseite listen", + "common__pages__enquiry__fields__cover_image_url": "Coverbild-URL", + "common__pages__enquiry__fields__organiser_website": "Veranstalter-Website", + "common__pages__enquiry__fields__notes": "Zusätzliche Anmerkungen", + "common__pages__enquiry__fields__privacy_accepted": "Ich akzeptiere die Datenschutzerklärung", + "common__pages__enquiry__fields__date_options": "Wunschtermine", + "common__pages__enquiry__hints__event_name": "Der öffentliche Titel Ihres Retreats oder Workshops — diesen sehen Gäste und unser Team.", + "common__pages__enquiry__hints__event_outline": "Eine einzeilige Zusammenfassung Ihrer Veranstaltung — wird neben dem Titel auf der öffentlichen Veranstaltungsseite angezeigt, falls Sie sie listen.", + "common__pages__enquiry__hints__description": "Einige Sätze zu Ihrer Veranstaltung — worum es geht, für wen sie ist und was die Teilnehmenden erwartet. Wird auf der öffentlichen Veranstaltungsseite angezeigt, falls Sie sie listen.", + "common__pages__enquiry__hints__start_date": "Der Tag, an dem Ihre Gruppe im Seminarhof ankommt.", + "common__pages__enquiry__hints__end_date": "Der Abreisetag Ihrer Gruppe. Eintägige Veranstaltungen können dasselbe Datum verwenden.", + "common__pages__enquiry__hints__expected_persons": "Ihre beste Schätzung der Gesamtteilnehmerzahl. Sie können dies später anpassen.", + "common__pages__enquiry__hints__half_house": "Wählen Sie Ja, wenn Sie nur das halbe Haus benötigen — so können wir gleichzeitig eine zweite Gruppe beherbergen.", + "common__pages__enquiry__hints__client_name": "Der Name der Gruppe, des Vereins oder des Unternehmens, das die Buchung vornimmt.", + "common__pages__enquiry__hints__contact_email": "Wir senden die Bestätigung und alle weiteren Informationen hierhin — bitte prüfen Sie die Adresse.", + "common__pages__enquiry__hints__contact_phone": "Optional — eine Nummer, unter der wir Sie bei kurzen Rückfragen erreichen können.", + "common__pages__enquiry__hints__website": "Optional — Ihre Website, falls vorhanden.", + "common__pages__enquiry__hints__notes": "Alles Weitere, das wir wissen sollten: besondere Wünsche, Barrierefreiheit, Fragen.", + "common__pages__enquiry__hints__privacy_accepted": "Sie müssen unsere Datenschutzerklärung akzeptieren, damit wir Ihre Anfrage bearbeiten können.", + "common__pages__enquiry__hints__date_options": "Gib bis zu drei Wunschtermine in Reihenfolge der Priorität an. Leer lassen, wenn du flexibel bist.", + "common__pages__enquiry__submit": "Anfrage absenden", + "common__pages__enquiry__success__title": "Vielen Dank — Ihre Anfrage ist eingegangen.", + "common__pages__enquiry__success__body": "Referenz: {enquiryId}. Wir antworten per E-Mail innerhalb von 2 Werktagen.", + "common__pages__enquiry__error": "Beim Absenden Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "common__pages__enquiry__date_options__none": "Noch keine Termine — füge bis zu 3 Wunschtermine hinzu oder lass das Feld leer, wenn du flexibel bist.", + "common__pages__enquiry__date_options__add": "Termin hinzufügen", + "common__pages__enquiry__date_options__remove": "Termin entfernen", + "common__pages__enquiry__date_options__invalid": "Die Abreise muss am oder nach der Anreise liegen.", + "common__pages__booking_form__title": "Buchungsformular", + "common__pages__booking_form__heading": "Ihre verbindliche Buchung", + "common__pages__booking_form__link_invalid__title": "Link ungültig oder abgelaufen", + "common__pages__booking_form__link_invalid__body": "Dieser Buchungsformular-Link ist nicht mehr gültig. Bitte fordern Sie beim Seminarhof einen neuen an.", + "common__pages__booking_form__sections__billing": "Rechnungsdaten", + "common__pages__booking_form__sections__event": "Veranstaltungsdetails", + "common__pages__booking_form__sections__terms": "Bestätigung", + "common__pages__booking_form__fields__name": "Name / Firma (Rechnungsempfänger)", + "common__pages__booking_form__fields__billing_address": "Rechnungsadresse", + "common__pages__booking_form__fields__contact_phone": "Telefon", + "common__pages__booking_form__fields__website": "Website", + "common__pages__booking_form__fields__payment_method": "Zahlungsart", + "common__pages__booking_form__fields__expected_persons": "Erwartete Teilnehmer", + "common__pages__booking_form__fields__arrival_time": "Ankunftszeit", + "common__pages__booking_form__fields__departure_time": "Abreisezeit", + "common__pages__booking_form__fields__daily_plan": "Tagesablauf", + "common__pages__booking_form__fields__online_ad": "Diese Veranstaltung öffentlich auf der Website anzeigen", + "common__pages__booking_form__fields__notes": "Sonstiges, das wir wissen sollten?", + "common__pages__booking_form__fields__accept_terms": "Ich akzeptiere die AGB. Das Absenden dieses Formulars ist rechtsverbindlich.", + "common__pages__booking_form__fields__accept_privacy": "Ich akzeptiere die Datenschutzerklärung.", + "common__pages__booking_form__payment__cash": "Bar vor Ort (gesammelt)", + "common__pages__booking_form__payment__transfer": "Banküberweisung", + "common__pages__booking_form__submit": "Verbindlich buchen", + "common__pages__booking_form__success__title": "Vielen Dank — Ihre Buchung ist bestätigt.", + "common__pages__booking_form__success__body": "Wir haben Ihre Angaben erhalten. Eine Anzahlung von 300 € ist innerhalb von 14 Tagen fällig; die Rechnung erhalten Sie per E-Mail.", + "common__pages__booking_form__error": "Beim Absenden des Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "enum__booking_status__enquiry": "Anfrage", + "enum__booking_status__reserved": "Reserviert", + "enum__booking_status__confirmed": "Bestätigt", + "enum__booking_status__ended": "Beendet", + "enum__booking_status__cancelled": "Storniert", + "common__pages__booking_status__advance": "Status ändern", + "common__pages__booking_status__confirm_title": "Statusänderung bestätigen", + "common__pages__booking_status__confirm_body": "Buchung auf {status} setzen?", + "common__pages__booking_status__no_transitions": "Keine weiteren Übergänge möglich.", + "enum__allergen__gluten_strict": "Gluten (streng)", + "enum__allergen__nuts": "Nüsse", + "enum__allergen__soy": "Soja", + "enum__allergen__dairy": "Milchprodukte", + "enum__allergen__egg": "Ei", + "enum__allergen__fish": "Fisch", + "enum__allergen__shellfish": "Meeresfrüchte", + "enum__allergen__sesame": "Sesam", + "enum__allergen__celery": "Sellerie", + "enum__allergen__mustard": "Senf", + "enum__allergen__other": "Sonstiges", + "common__actions__submit": "Absenden", + "common__actions__cancel": "Abbrechen", + "common__actions__save": "Speichern", + "common__actions__delete": "Löschen", + "common__actions__confirm": "Bestätigen", + "common__states__loading": "Wird geladen…", + "common__states__empty": "Keine Ergebnisse.", + "common__states__error": "Etwas ist schiefgelaufen.", + "common__components__file_uploader__browse": "Datei wählen", + "common__components__file_uploader__drop_now": "Datei zum Hochladen ablegen", + "common__components__file_uploader__uploading": "Wird hochgeladen...", + "common__components__file_uploader__uploaded": "Hochgeladen", + "common__components__file_uploader__pdf_only": "Nur PDF. Klicken oder per Drag-and-drop ablegen.", + "common__yes": "Ja", + "common__no": "Nein", + "auth__login__title": "Willkommen zurück", + "auth__login__subtitle": "Melden Sie sich an, um fortzufahren", + "auth__login__email": "E-Mail", + "auth__login__email_placeholder": "sie@beispiel.de", + "auth__login__password": "Passwort", + "auth__login__submit": "Anmelden", + "auth__login__forgot_password": "Passwort vergessen?", + "auth__login__no_account": "Noch kein Konto?", + "auth__login__signup_cta": "Konto erstellen", + "auth__login__or": "oder", + "auth__login__providers__continue_with": "Weiter mit {provider}", + "auth__login__errors__invalid_credentials": "E-Mail oder Passwort ist falsch.", + "auth__login__errors__account_locked": "Ihr Konto ist gesperrt. Wenden Sie sich an den Support.", + "auth__login__errors__generic": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "auth__logout__title": "Abgemeldet", + "auth__logout__subtitle": "Sie wurden abgemeldet. Bis bald.", + "auth__logout__back_to_login": "Zurück zur Anmeldung", + "auth__providers__auth0": "Auth0", + "auth__providers__email": "E-Mail", + "auth__providers__github": "GitHub", + "auth__providers__google": "Google", + "auth__providers__microsoft": "Microsoft", + "auth__providers__passwordless": "Magic Link" +} diff --git a/apps/app/messages/en.json b/apps/app/messages/en.json new file mode 100644 index 0000000..75e7393 --- /dev/null +++ b/apps/app/messages/en.json @@ -0,0 +1,507 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "common__app__title": "App", + "common__brand__name": "Seminarhof Drawehn", + "common__brand__tagline": "Arrive, unwind, recharge.", + "common__nav__hello": "Hello", + "common__nav__login": "Log in", + "common__nav__logout": "Log out", + "common__nav__toggle_direction": "Toggle direction", + "common__nav__toggle": "Toggle navigation", + "common__nav__back": "Back", + "common__nav__sections__workspace": "Workspace", + "common__nav__sections__public": "Public", + "common__nav__items__client": "My retreats", + "common__nav__items__dashboard": "Dashboard", + "common__nav__items__bookings": "Bookings", + "common__nav__items__enquiries": "Enquiries", + "common__nav__items__kanban": "Kanban", + "common__nav__items__availability": "Availability", + "common__nav__items__enquiry": "Public enquiry form", + "common__nav__items__events": "Events", + "enum__role__client": "Client", + "enum__role__admin": "Admin", + "enum__role__owner": "Owner", + "common__pages__kanban__title": "Kanban", + "common__pages__hello__subtitle": "Your app template is wired.", + "common__pages__hello__body": "Pages compose widgets; widgets are pure props-driven; i18n keys resolve via the namespace chain (see CONVENTIONS.md).", + "common__pages__hello__rtl_tip": "Try ?dir=rtl on this URL — every layout must render correctly in both directions.", + "common__pages__launchpad__title": "Launchpad", + "common__pages__launchpad__client__title": "Client portal", + "common__pages__launchpad__client__body": "Upcoming bookings, completeness %, recent invoices.", + "common__pages__launchpad__client__cta": "Open client overview", + "common__pages__launchpad__availability__title": "Availability (Public)", + "common__pages__launchpad__availability__body": "12-month availability calendar. Click a free day to enquire.", + "common__pages__launchpad__availability__cta": "Open availability", + "common__pages__launchpad__admin__title": "Admin · Bookings", + "common__pages__launchpad__admin__body": "Replaces Buchungsübersicht. Filter, search, drill into detail.", + "common__pages__launchpad__admin__cta": "Open admin bookings", + "common__pages__launchpad__events__title": "Public events", + "common__pages__launchpad__events__body": "Cards for opted-in retreats with cover, dates, and organiser link.", + "common__pages__launchpad__events__cta": "Open events", + "common__pages__launchpad__enquiry__title": "Enquiry form", + "common__pages__launchpad__enquiry__body": "Public booking form — submits as new enquiry.", + "common__pages__launchpad__enquiry__cta": "Open enquiry form", + "common__pages__client__title": "Client portal", + "common__pages__client__upcoming": "Upcoming bookings", + "common__pages__client__no_upcoming": "No upcoming bookings.", + "common__pages__client__current_badge": "Current", + "common__pages__client__participants_count": "{count} / {target} participants", + "common__pages__client__participants_count_unknown": "{count} participants", + "common__pages__client__completeness": "Booking info complete", + "common__pages__client__recent_invoices": "Recent invoices", + "common__pages__client__no_invoices": "No invoices yet — your invoice will appear once it has been issued.", + "common__pages__client__invoice_cols__booking": "Booking", + "common__pages__client__invoice_cols__number": "Number", + "common__pages__client__invoice_cols__kind": "Kind", + "common__pages__client__invoice_cols__amount": "Amount", + "common__pages__client__invoice_cols__status": "Status", + "common__pages__client__invoice_cols__issued": "Issued", + "common__pages__booking__tabs__overview": "Overview", + "common__pages__booking__tabs__participants": "Participants", + "common__pages__booking__tabs__invoices": "Invoices", + "common__pages__booking__overview__schedule": "Schedule", + "common__pages__booking__overview__breakfast": "Breakfast", + "common__pages__booking__overview__lunch": "Lunch", + "common__pages__booking__overview__dinner": "Dinner", + "common__pages__booking__overview__headcount": "Headcount", + "common__pages__booking__overview__added": "{count} / {target} added", + "common__pages__booking__participants__dietary_breakdown": "Dietary breakdown", + "common__pages__booking__participants__total_participants": "{count} participants", + "common__pages__booking__participants__title": "Participants", + "common__pages__booking__participants__add_cta": "+ Add participant", + "common__pages__booking__participants__empty": "No participants yet — add the first one.", + "common__pages__booking__participants__cols__name": "Name", + "common__pages__booking__participants__cols__kind": "Kind", + "common__pages__booking__participants__cols__diet": "Diet", + "common__pages__booking__participants__cols__allergies": "Allergies", + "common__pages__booking__participants__cols__room": "Room", + "enum__participant_kind__overnight": "Overnight", + "enum__participant_kind__day_guest": "Day guest", + "enum__dietary__omnivore": "Omnivore", + "enum__dietary__vegetarian": "Vegetarian", + "enum__dietary__vegan": "Vegan", + "enum__dietary__gluten_free": "Gluten-free", + "enum__dietary__lactose_free": "Lactose-free", + "enum__dietary__pescatarian": "Pescatarian", + "enum__dietary__unknown": "Unknown", + "common__pages__booking__participants__add_modal__title": "Add participant", + "common__pages__booking__participants__add_modal__name": "Name", + "common__pages__booking__participants__add_modal__kind": "Kind", + "common__pages__booking__participants__add_modal__add": "Add", + "common__pages__booking__participants__remove_confirm": "Remove {name}?", + "common__pages__booking__participants__allergy_add": "+ Allergy", + "common__pages__booking__participants__allergy_none": "No allergies", + "common__pages__booking__participants__allergy_count": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=one": "{count} allergy", + "countPlural=other": "{count} allergies" + } + } + ], + "common__pages__booking__participants__allergy_remove_aria": "Remove allergy {allergen}", + "enum__severity__mild": "Mild", + "enum__severity__moderate": "Moderate", + "enum__severity__severe": "Severe", + "enum__severity__standard": "Standard", + "enum__severity__separate_prep": "Separate prep", + "common__pages__booking__invoices__empty": "No invoices yet — your invoice will appear once it has been issued.", + "common__pages__booking__invoices__cols__number": "Number", + "common__pages__booking__invoices__cols__kind": "Kind", + "common__pages__booking__invoices__cols__amount": "Amount", + "common__pages__booking__invoices__cols__status": "Status", + "common__pages__booking__invoices__cols__issued": "Issued", + "common__pages__booking__invoices__cols__due": "Due", + "common__pages__booking__invoices__cols__paid": "Paid", + "common__pages__admin__dashboard__title": "Dashboard", + "common__pages__admin__dashboard__needs_action": "Needs action", + "common__pages__admin__dashboard__upcoming": "Upcoming events", + "common__pages__admin__dashboard__upcoming_window": "Next 30 days", + "common__pages__admin__dashboard__all_clear": "Nothing needs your attention right now.", + "common__pages__admin__dashboard__no_upcoming": "No events in the next 30 days.", + "common__pages__admin__dashboard__more": "+{count} more", + "common__pages__admin__dashboard__waitlisted": "Waitlisted", + "common__pages__admin__dashboard__persons": "{count} guests", + "common__pages__admin__dashboard__cards__pending_enquiries": "Pending enquiries", + "common__pages__admin__dashboard__cards__contracts_to_send": "Contracts to send", + "common__pages__admin__dashboard__cards__contracts_awaiting": "Contracts awaiting response", + "common__pages__admin__dashboard__cards__deposits_outstanding": "Deposits outstanding", + "common__pages__admin__bookings__title": "Bookings", + "common__pages__admin__bookings__subtitle": "Replaces the Buchungsübersicht spreadsheet.", + "common__pages__admin__bookings__new_booking": "+ New booking", + "common__pages__admin__bookings__search_placeholder": "Search event or client…", + "common__pages__admin__bookings__filters__all": "All", + "common__pages__admin__bookings__filters__half_house_only": "Half house only", + "common__pages__admin__bookings__filters__year": "Year", + "common__pages__admin__bookings__filters__status": "Status", + "common__pages__admin__bookings__stats__showing": "{count} bookings", + "common__pages__admin__bookings__cols__dates": "Dates", + "common__pages__admin__bookings__cols__course": "Event", + "common__pages__admin__bookings__cols__client": "Client", + "common__pages__admin__bookings__cols__status": "Status", + "common__pages__admin__bookings__cols__persons": "Persons", + "common__pages__admin__bookings__cols__half_house": "½", + "common__pages__admin__bookings__cols__deposit": "Deposit", + "common__pages__admin__bookings__cols__final": "Final", + "common__pages__admin__bookings__cols__regular_group": "★", + "common__pages__admin__bookings__deposit__missing": "—", + "common__pages__admin__bookings__deposit__outstanding": "Issued", + "common__pages__admin__bookings__deposit__paid": "Paid", + "common__pages__admin__bookings__empty": "No bookings match the current filters.", + "common__pages__admin__enquiries__title": "Enquiries", + "common__pages__admin__enquiries__empty": "No enquiries.", + "common__pages__admin__enquiries__no_dates": "No dates yet", + "common__pages__admin__enquiries__transition_error": "The action could not be completed.", + "common__pages__admin__enquiries__filters__status": "Status", + "common__pages__admin__enquiries__stats__showing": "{count} enquiries", + "enum__enquiry_status__pending": "Pending", + "enum__enquiry_status__approved": "Approved", + "enum__enquiry_status__declined": "Declined", + "enum__enquiry_status__waitlisted": "Waitlisted", + "common__pages__admin__enquiries__cols__received": "Received", + "common__pages__admin__enquiries__cols__event": "Event", + "common__pages__admin__enquiries__cols__contact": "Contact", + "common__pages__admin__enquiries__cols__dates": "Dates", + "common__pages__admin__enquiries__cols__persons": "Persons", + "common__pages__admin__enquiries__cols__status": "Status", + "common__pages__admin__enquiries__cols__actions": "Actions", + "common__pages__admin__enquiries__actions__approve": "Approve", + "common__pages__admin__enquiries__actions__waitlist": "Waitlist", + "common__pages__admin__enquiries__actions__decline": "Decline", + "common__pages__admin__enquiries__actions__view_booking": "View booking", + "common__pages__admin__enquiries__approve_modal__title": "Approve enquiry", + "common__pages__admin__enquiries__approve_modal__start_date": "Start date", + "common__pages__admin__enquiries__approve_modal__end_date": "End date", + "common__pages__admin__enquiries__approve_modal__date_error": "Start date must be on or before the end date.", + "common__pages__admin__enquiries__approve_modal__existing_client": "Existing client", + "common__pages__admin__enquiries__approve_modal__new_client": "New client", + "common__pages__admin__enquiries__approve_modal__select_client": "Client", + "common__pages__admin__enquiries__approve_modal__search_client": "Search by name or email…", + "common__pages__admin__enquiries__approve_modal__no_clients": "No matching clients", + "common__pages__admin__enquiries__approve_modal__client_name": "Name", + "common__pages__admin__enquiries__approve_modal__client_email": "Email", + "common__pages__admin__enquiries__approve_modal__client_phone": "Phone", + "common__pages__admin__enquiries__approve_modal__client_website": "Website", + "common__pages__admin__enquiries__approve_modal__client_billing_address": "Billing address", + "common__pages__admin__enquiries__approve_modal__date_options": "Proposed dates — click to use", + "common__pages__admin__enquiries__waitlist_modal__title": "Waitlist enquiry", + "common__pages__admin__enquiries__waitlist_modal__intro": "Waitlisting keeps the enquiry pending but records preferred dates so it can be slotted in if dates free up.", + "common__pages__admin__enquiries__waitlist_modal__no_options": "This enquiry has no date options yet, so it cannot be waitlisted.", + "common__pages__admin__enquiries__decline_modal__title": "Decline enquiry", + "common__pages__admin__enquiries__decline_modal__confirm": "Decline the enquiry for “{event}” received {date}? This cannot be undone.", + "common__pages__admin_booking__title": "Booking · {course}", + "common__pages__admin_booking__tabs__details": "Details", + "common__pages__admin_booking__tabs__client": "Client", + "common__pages__admin_booking__tabs__rooms": "Rooms", + "common__pages__admin_booking__tabs__participants": "Participants", + "common__pages__admin_booking__tabs__invoices": "Invoices", + "common__pages__admin_booking__tabs__overview": "Overview", + "common__pages__admin_booking__tabs__audit": "Audit Log", + "common__pages__admin_booking__invoices__title": "Invoices", + "common__pages__admin_booking__invoices__empty": "No invoices yet for this booking.", + "common__pages__admin_booking__invoices__cols__number": "Number", + "common__pages__admin_booking__invoices__cols__kind": "Kind", + "common__pages__admin_booking__invoices__cols__amount": "Amount", + "common__pages__admin_booking__invoices__cols__issued": "Issued", + "common__pages__admin_booking__invoices__cols__due": "Due", + "common__pages__admin_booking__invoices__cols__paid": "Paid", + "common__pages__admin_booking__invoices__cols__status": "Status", + "common__pages__admin_booking__invoices__cols__action": "Action", + "enum__invoice_kind__deposit": "Deposit", + "enum__invoice_kind__final": "Final", + "enum__invoice_status__outstanding": "Outstanding", + "enum__invoice_status__paid": "Paid", + "enum__invoice_status__cancelled": "Cancelled", + "common__pages__admin_booking__invoices__create_title": "New invoice", + "common__pages__admin_booking__invoices__create": "Create invoice", + "common__pages__admin_booking__invoices__attach_pdf": "Attach PDF (optional)", + "common__pages__admin_booking__invoices__pdf_selected": "PDF selected", + "common__pages__admin_booking__invoices__upload_pdf": "Upload PDF", + "common__pages__admin_booking__invoices__replace_pdf": "Replace PDF", + "common__pages__admin_booking__invoices__open_pdf": "Open PDF", + "common__pages__admin_booking__invoices__mark_paid": "Mark paid", + "common__pages__admin_booking__invoices__cancel": "Cancel", + "common__pages__admin_booking__invoices__cancel_confirm": "Cancel this invoice?", + "common__pages__admin_booking__invoices__paid_on_prompt": "Payment date", + "common__pages__admin_booking__invoices__errors__create": "Could not create invoice.", + "common__pages__admin_booking__invoices__errors__upload_pdf": "Could not upload PDF.", + "common__pages__admin_booking__invoices__errors__pay": "Could not record payment.", + "common__pages__admin_booking__invoices__errors__cancel": "Could not cancel invoice.", + "common__pages__admin_booking__audit__empty": "No audit entries for this booking.", + "common__pages__admin_booking__audit__system": "System", + "common__pages__admin_booking__audit__email_sent": "Email sent", + "common__pages__admin_booking__audit__cols__when": "When", + "common__pages__admin_booking__audit__cols__who": "Who", + "common__pages__admin_booking__audit__cols__change": "Change", + "enum__email_kind__send_contract_and_deposit_email": "Contract & deposit invoice", + "enum__email_kind__send_deposit_reminder_email": "Deposit reminder", + "enum__email_kind__send_reservation_confirmed_email": "Reservation confirmed", + "enum__email_kind__send_booking_confirmed_email": "Booking confirmed", + "enum__email_kind__send_cancellation_email": "Cancellation", + "enum__email_kind__send_enquiry_declined_email": "Enquiry declined", + "common__pages__admin_booking__fields__event_name": "Event name", + "common__pages__admin_booking__fields__start_date": "Start date", + "common__pages__admin_booking__fields__end_date": "End date", + "common__pages__admin_booking__fields__expected_persons": "Expected participants", + "common__pages__admin_booking__fields__half_house": "Half house", + "common__pages__admin_booking__fields__online_ad": "Public listing", + "common__pages__admin_booking__fields__event_outline": "Event outline", + "common__pages__admin_booking__fields__description": "Event description", + "common__pages__admin_booking__fields__cover_image_url": "Cover image URL", + "common__pages__admin_booking__fields__organiser_website": "Organiser website", + "common__pages__admin_booking__fields__meal_time_breakfast": "Breakfast time", + "common__pages__admin_booking__fields__meal_time_lunch": "Lunch time", + "common__pages__admin_booking__fields__meal_time_dinner": "Dinner time", + "common__pages__admin_booking__fields__arrival_time": "Arrival time", + "common__pages__admin_booking__fields__departure_time": "Departure time", + "common__pages__admin_booking__fields__daily_plan": "Daily plan", + "common__pages__admin_booking__fields__notes": "Internal notes", + "common__pages__admin_booking__client__title": "Client", + "common__pages__admin_booking__client__name": "Name", + "common__pages__admin_booking__client__contact_email": "Email", + "common__pages__admin_booking__client__contact_phone": "Phone", + "common__pages__admin_booking__client__billing_address": "Billing address", + "common__pages__admin_booking__client__website": "Website", + "common__pages__admin_booking__client__save": "Save client", + "common__pages__admin_booking__client__saved": "Client saved", + "common__pages__admin_booking__client__save_error": "Could not save client.", + "common__pages__admin_booking__client__regular_group": "Regular group", + "common__pages__admin_booking__allergies__empty": "No allergies recorded.", + "common__pages__admin_booking__allergies__add_placeholder": "Allergen…", + "common__pages__admin_booking__allergies__add": "Add", + "common__pages__admin_booking__status__panel_title": "Booking lifecycle", + "common__pages__admin_booking__status__flagged": "Flagged for review", + "enum__flag_reason__deposit_overdue": "Deposit overdue — client has not paid within 14 days.", + "enum__flag_reason__short_window": "Start date is less than 14 days away — deposit window is very tight.", + "enum__flag_reason__contract_declined": "Client declined the contract — please cancel or discuss.", + "common__pages__admin_booking__status__milestones__enquiry": "Enquiry", + "common__pages__admin_booking__status__milestones__reserved": "Reserved", + "common__pages__admin_booking__status__milestones__contract_sent": "Contract sent", + "common__pages__admin_booking__status__milestones__contract_signed": "Contract signed", + "common__pages__admin_booking__status__milestones__deposit_paid": "Deposit paid", + "common__pages__admin_booking__status__milestones__confirmed": "Confirmed", + "common__pages__admin_booking__status__milestones__ended": "Ended", + "common__pages__admin_booking__status__milestones__cancelled": "Cancelled", + "common__pages__admin_booking__status__deposit_window": "Day {day} of 14", + "common__pages__admin_booking__status__deposit_overdue": "Deposit overdue", + "common__pages__admin_booking__status__reminder_sent": "Reminder sent", + "common__pages__admin_booking__status__due_on": "Due {date}", + "common__pages__admin_booking__status__contract_not_sent_yet": "Contract email scheduled ~{date}", + "common__pages__admin_booking__status__awaiting_signature": "Awaiting client signature", + "common__pages__admin_booking__status__awaiting_deposit": "Awaiting deposit", + "common__pages__admin_booking__status__actions__approve_enquiry": "Approve enquiry", + "common__pages__admin_booking__status__actions__decline_enquiry": "Decline enquiry", + "common__pages__admin_booking__status__actions__send_contract_now": "Send contract now", + "common__pages__admin_booking__status__actions__record_contract_approved": "Mark contract signed", + "common__pages__admin_booking__status__actions__record_contract_declined": "Mark contract declined", + "common__pages__admin_booking__status__actions__record_deposit_received": "Mark deposit received", + "common__pages__admin_booking__status__actions__mark_ended": "Mark ended", + "common__pages__admin_booking__status__actions__cancel_booking": "Cancel booking", + "common__pages__admin_booking__status__actions__create_form_link": "Create booking form link", + "common__pages__admin_booking__status__actions__confirm": "Confirm?", + "common__pages__admin_booking__status__form_link__label": "Magic link — copy and send to the client:", + "common__pages__admin_booking__status__party__admin": "Admin action", + "common__pages__admin_booking__status__party__client": "Client action", + "common__pages__admin_booking__status__party__system": "Automatic", + "common__pages__admin_booking__cancel": "Cancel booking", + "common__pages__admin_booking__cancel_confirm": "Cancel this booking? This cannot be undone.", + "common__pages__admin_booking__rooms__title": "Room map", + "common__pages__admin_booking__rooms__occupied_other": "Booked by another retreat", + "common__pages__admin_booking__rooms__assign_to_participant": "Assign to…", + "common__pages__admin_booking__rooms__unassign": "Remove", + "common__pages__admin_booking__rooms__beds": "beds", + "common__pages__admin_booking__rooms__cols__room": "Room", + "common__pages__admin_booking__rooms__cols__guests": "Guests", + "common__pages__admin_booking__rooms__cols__assign": "Assign", + "enum__building__gaestehaus": "Gästehaus", + "enum__building__haupthaus": "Haupthaus", + "enum__floor__eg": "Ground floor", + "enum__floor__og": "Upper floor", + "common__pages__admin_booking__rooms__flags__wheelchair": "♿", + "common__pages__admin_booking__rooms__flags__ground_floor": "GF", + "common__pages__admin_booking__rooms__flags__quiet": "🔇", + "common__pages__admin_booking__rooms__flags__shared_bath": "shared", + "common__pages__admin_booking__rooms__flags__dogs_allowed": "🐕", + "common__pages__admin_booking__rooms__flags__double_bed_140": "2-bed", + "common__pages__admin_booking__rooms__flags__allergy_friendly": "allergy", + "common__pages__admin_booking__save": "Save changes", + "common__pages__admin_booking__saved": "Saved", + "common__pages__admin_booking__save_error": "Failed to save changes.", + "common__pages__events__title": "Upcoming retreats", + "common__pages__events__subtitle": "Public events at Seminarhof Drawehn.", + "common__pages__events__search_placeholder": "Search event or organiser…", + "common__pages__events__filters__this_year": "This year", + "common__pages__events__filters__next_year": "Next year", + "common__pages__events__filters__all": "All", + "common__pages__events__empty": "No events listed at this moment — please check our retreat calendar for available dates.", + "common__pages__events__more_info": "More info →", + "common__pages__staff__today": "Today", + "common__pages__staff__prev_day": "Previous day", + "common__pages__staff__next_day": "Next day", + "enum__allergen__gluten_strict": "Gluten (strict)", + "enum__allergen__nuts": "Nuts", + "enum__allergen__soy": "Soy", + "enum__allergen__dairy": "Dairy", + "enum__allergen__egg": "Egg", + "enum__allergen__fish": "Fish", + "enum__allergen__shellfish": "Shellfish", + "enum__allergen__sesame": "Sesame", + "enum__allergen__celery": "Celery", + "enum__allergen__mustard": "Mustard", + "enum__allergen__other": "Other", + "common__pages__enquiry__title": "Make an enquiry", + "common__pages__enquiry__subtitle": "Tell us about your retreat — we'll come back to you within 2 business days.", + "common__pages__enquiry__sections__event": "Your event", + "common__pages__enquiry__sections__client": "Client", + "common__pages__enquiry__sections__billing": "Billing", + "common__pages__enquiry__sections__schedule": "Schedule", + "common__pages__enquiry__sections__public_listing": "Public listing", + "common__pages__enquiry__sections__notes": "Notes", + "common__pages__enquiry__sections__terms": "Terms", + "common__pages__enquiry__agb_accept": "I accept the terms and conditions", + "common__pages__enquiry__fields__event_name": "Event name", + "common__pages__enquiry__fields__event_outline": "Event outline", + "common__pages__enquiry__fields__description": "Event description", + "common__pages__enquiry__fields__start_date": "Arrival date", + "common__pages__enquiry__fields__end_date": "Departure date", + "common__pages__enquiry__fields__expected_persons": "Expected participants", + "common__pages__enquiry__fields__half_house": "Half house only", + "common__pages__enquiry__fields__half_house_yes": "Yes", + "common__pages__enquiry__fields__half_house_no": "No", + "common__pages__enquiry__fields__client_name": "Client name", + "common__pages__enquiry__fields__contact_email": "Contact email", + "common__pages__enquiry__fields__contact_phone": "Contact phone", + "common__pages__enquiry__fields__billing_address": "Billing address", + "common__pages__enquiry__fields__website": "Website", + "common__pages__enquiry__fields__arrival_time": "Arrival time", + "common__pages__enquiry__fields__departure_time": "Departure time", + "common__pages__enquiry__fields__meal_time_breakfast": "Breakfast time", + "common__pages__enquiry__fields__meal_time_lunch": "Lunch time", + "common__pages__enquiry__fields__meal_time_dinner": "Dinner time", + "common__pages__enquiry__fields__daily_plan": "Daily plan", + "common__pages__enquiry__fields__online_ad": "List this retreat publicly on our events page", + "common__pages__enquiry__fields__cover_image_url": "Cover image URL", + "common__pages__enquiry__fields__organiser_website": "Organiser website", + "common__pages__enquiry__fields__notes": "Additional notes", + "common__pages__enquiry__fields__privacy_accepted": "I accept the privacy policy", + "common__pages__enquiry__fields__date_options": "Preferred dates", + "common__pages__enquiry__hints__event_name": "The public title of your retreat or workshop — this is what guests and our team will see.", + "common__pages__enquiry__hints__event_outline": "A one-line summary of your event — the headline shown alongside the title on the public events page if you list it.", + "common__pages__enquiry__hints__description": "A few sentences describing your event — what it is, who it's for, and what participants can expect. Used on the public events page if you list it.", + "common__pages__enquiry__hints__start_date": "The day your group arrives at the venue.", + "common__pages__enquiry__hints__end_date": "The day your group departs. Same-day events can use the same date.", + "common__pages__enquiry__hints__expected_persons": "Your best estimate of total participants. You can refine this later.", + "common__pages__enquiry__hints__half_house": "Choose Yes if you only need half of the house, allowing us to host a second group at the same time.", + "common__pages__enquiry__hints__client_name": "The name of the group, association, or company making the booking.", + "common__pages__enquiry__hints__contact_email": "We'll send the confirmation and all follow-up here, so double-check it.", + "common__pages__enquiry__hints__contact_phone": "Optional — a number we can reach you on for quick questions.", + "common__pages__enquiry__hints__website": "Optional — your website, if you have one.", + "common__pages__enquiry__hints__notes": "Anything else we should know: special requests, accessibility needs, questions.", + "common__pages__enquiry__hints__privacy_accepted": "You must accept our privacy policy so we can process your enquiry.", + "common__pages__enquiry__hints__date_options": "List up to three preferred date ranges in priority order. Leave empty if your dates are flexible.", + "common__pages__enquiry__submit": "Submit enquiry", + "common__pages__enquiry__success__title": "Thank you — your enquiry has been received.", + "common__pages__enquiry__success__body": "Reference: {enquiryId}. We will respond by email within 2 business days.", + "common__pages__enquiry__error": "Something went wrong submitting your enquiry. Please try again.", + "common__pages__enquiry__date_options__none": "No dates yet — add up to 3 preferred ranges, or leave empty if you are flexible.", + "common__pages__enquiry__date_options__add": "Add date option", + "common__pages__enquiry__date_options__remove": "Remove date option", + "common__pages__enquiry__date_options__invalid": "Departure must be on or after arrival.", + "common__pages__booking_form__title": "Booking form", + "common__pages__booking_form__heading": "Your binding booking", + "common__pages__booking_form__link_invalid__title": "Link invalid or expired", + "common__pages__booking_form__link_invalid__body": "This booking-form link is no longer valid. Please ask Seminarhof to send you a new one.", + "common__pages__booking_form__sections__billing": "Billing details", + "common__pages__booking_form__sections__event": "Event details", + "common__pages__booking_form__sections__terms": "Confirmation", + "common__pages__booking_form__fields__name": "Name / company (invoice recipient)", + "common__pages__booking_form__fields__billing_address": "Billing address", + "common__pages__booking_form__fields__contact_phone": "Phone", + "common__pages__booking_form__fields__website": "Website", + "common__pages__booking_form__fields__payment_method": "Payment method", + "common__pages__booking_form__fields__expected_persons": "Expected participants", + "common__pages__booking_form__fields__arrival_time": "Arrival time", + "common__pages__booking_form__fields__departure_time": "Departure time", + "common__pages__booking_form__fields__daily_plan": "Daily schedule", + "common__pages__booking_form__fields__online_ad": "List this event publicly on the website", + "common__pages__booking_form__fields__notes": "Anything else we should know?", + "common__pages__booking_form__fields__accept_terms": "I accept the terms and conditions (AGB). Submitting this form is legally binding.", + "common__pages__booking_form__fields__accept_privacy": "I accept the privacy policy.", + "common__pages__booking_form__payment__cash": "Cash on site (collected together)", + "common__pages__booking_form__payment__transfer": "Bank transfer", + "common__pages__booking_form__submit": "Submit binding booking", + "common__pages__booking_form__success__title": "Thank you — your booking is confirmed.", + "common__pages__booking_form__success__body": "We have received your details. A €300 deposit is due within 14 days; you will receive the invoice by email.", + "common__pages__booking_form__error": "Something went wrong submitting the form. Please try again.", + "enum__booking_status__enquiry": "Enquiry", + "enum__booking_status__reserved": "Reserved", + "enum__booking_status__confirmed": "Confirmed", + "enum__booking_status__ended": "Ended", + "enum__booking_status__cancelled": "Cancelled", + "common__pages__booking_status__advance": "Change status", + "common__pages__booking_status__confirm_title": "Confirm status change", + "common__pages__booking_status__confirm_body": "Move booking to {status}?", + "common__pages__booking_status__no_transitions": "No further transitions available.", + "common__pages__availability__title": "Availability", + "common__pages__availability__venue_line": "{venue} · {from} → {to}", + "common__pages__availability__year_label": "Year", + "common__pages__availability__free_stat": "{free} free days of {total} ({percent}%)", + "common__pages__availability__free_month_badge": "{count} free", + "common__pages__availability__footnote": "Select a free start day, then a free end day to request that range — or pick a single day. Event names are only shown when the organiser has consented to public listing.", + "common__pages__availability__select_start_hint": "Select a free start day.", + "common__pages__availability__select_end_hint": "Now select a free end day, or create a single-day enquiry.", + "common__pages__availability__selected_range": "Selected: {range}", + "common__pages__availability__create_enquiry": "Create enquiry", + "common__pages__availability__clear_selection": "Clear", + "common__pages__availability__status__free": "Free", + "common__pages__availability__status__enquiry": "Enquiry", + "common__pages__availability__status__reserved": "Reserved", + "common__pages__availability__status__confirmed": "Confirmed", + "common__pages__availability__status__half_house": "Half house", + "common__actions__submit": "Submit", + "common__actions__cancel": "Cancel", + "common__actions__save": "Save", + "common__actions__delete": "Delete", + "common__actions__confirm": "Confirm", + "common__states__loading": "Loading…", + "common__states__empty": "No results.", + "common__states__error": "Something went wrong.", + "common__components__file_uploader__browse": "Browse", + "common__components__file_uploader__drop_now": "Drop file to upload", + "common__components__file_uploader__uploading": "Uploading...", + "common__components__file_uploader__uploaded": "Uploaded", + "common__components__file_uploader__pdf_only": "PDF only. Click to browse or drag and drop.", + "common__yes": "Yes", + "common__no": "No", + "auth__login__title": "Welcome back", + "auth__login__subtitle": "Sign in to continue", + "auth__login__email": "Email", + "auth__login__email_placeholder": "you@example.com", + "auth__login__password": "Password", + "auth__login__submit": "Sign in", + "auth__login__forgot_password": "Forgot password?", + "auth__login__no_account": "Don't have an account?", + "auth__login__signup_cta": "Create one", + "auth__login__or": "or", + "auth__login__providers__continue_with": "Continue with {provider}", + "auth__login__errors__invalid_credentials": "Email or password is incorrect.", + "auth__login__errors__account_locked": "Your account is locked. Contact support.", + "auth__login__errors__generic": "Sign in failed. Please try again.", + "auth__logout__title": "Signed out", + "auth__logout__subtitle": "You've been signed out. See you soon.", + "auth__logout__back_to_login": "Back to sign in", + "auth__providers__auth0": "Auth0", + "auth__providers__email": "Email", + "auth__providers__github": "GitHub", + "auth__providers__google": "Google", + "auth__providers__microsoft": "Microsoft", + "auth__providers__passwordless": "Magic link" +} diff --git a/apps/app/package.json b/apps/app/package.json new file mode 100644 index 0000000..086ad14 --- /dev/null +++ b/apps/app/package.json @@ -0,0 +1,58 @@ +{ + "name": "@project/app", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5001", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5001", + "tsc": "tsc --noEmit", + "lint": "eslint src", + "lint:css": "stylelint \"src/**/*.{css,ts,tsx}\"" + }, + "dependencies": { + "@mantine/core": "^9.2.1", + "@mantine/hooks": "^9.2.1", + "@pikku/core": "^0.12.57", + "@pikku/fetch": "^0.12.6", + "@pikku/mantine": "^0.12.6", + "@pikku/react": "^0.12.5", + "@project/components": "workspace:*", + "@project/functions": "workspace:*", + "@project/functions-sdk": "workspace:*", + "@project/mantine-theme": "workspace:*", + "@tanstack/react-form": "^0.43.0", + "@tanstack/react-query": "^5.90.10", + "@tanstack/react-router": "^1.100.0", + "@tanstack/react-start": "^1.100.0", + "@tanstack/react-table": "^8.20.0", + "better-auth": "^1.6.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.554.0", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@inlang/paraglide-js": "^2.20.0", + "@tanstack/router-generator": "^1.100.0", + "@tanstack/router-plugin": "^1.100.0", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-i18next": "^6.1.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.2", + "postcss": "^8.5.3", + "stylelint": "^16.10.0", + "stylelint-config-standard": "^36.0.1", + "stylelint-use-logical": "^2.1.2", + "typescript": "^5.9", + "vite": "npm:rolldown-vite@7.3.1" + } +} diff --git a/apps/app/postcss.config.js b/apps/app/postcss.config.js new file mode 100644 index 0000000..b6dc034 --- /dev/null +++ b/apps/app/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + autoprefixer: {}, + }, +} diff --git a/apps/app/project.inlang/settings.json b/apps/app/project.inlang/settings.json new file mode 100644 index 0000000..2fb4e6d --- /dev/null +++ b/apps/app/project.inlang/settings.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "de", + "locales": ["de", "en"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/apps/app/public/brand/logo-plum.svg b/apps/app/public/brand/logo-plum.svg new file mode 100644 index 0000000..09b14bb --- /dev/null +++ b/apps/app/public/brand/logo-plum.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/app/public/brand/logo-white.svg b/apps/app/public/brand/logo-white.svg new file mode 100644 index 0000000..3d9de9a --- /dev/null +++ b/apps/app/public/brand/logo-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/app/public/favicon.svg b/apps/app/public/favicon.svg new file mode 100644 index 0000000..100066f --- /dev/null +++ b/apps/app/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/app/src/auth.config.ts b/apps/app/src/auth.config.ts new file mode 100644 index 0000000..2e2bc3a --- /dev/null +++ b/apps/app/src/auth.config.ts @@ -0,0 +1,14 @@ +import { auth0 } from './auth/providers/auth0' +import type { AuthProvider } from './auth/providers/_types' + +/** + * Active auth providers for this app. + * + * Providers are copied from `src/auth/providers/*.ts.inactive` into the + * active set — the Fabric console's auth browser runs this as a plan. + * Each provider's backend handler reads its secrets via + * `secrets.getSecret()` (never from `process.env` on the client). + */ +export const activeProviders: AuthProvider[] = [auth0] + +export const primaryProvider = activeProviders[0] diff --git a/apps/app/src/auth.tsx b/apps/app/src/auth.tsx new file mode 100644 index 0000000..04a37a8 --- /dev/null +++ b/apps/app/src/auth.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext } from 'react' +import { Navigate } from '@tanstack/react-router' +import { Container, Text } from '@pikku/mantine/core' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' +import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen' +import { AppLayout } from './components/AppLayout' + +type Me = ReturnType>['data'] + +const AuthContext = createContext(null) + +export function useAuth(): NonNullable { + const ctx = useContext(AuthContext) + if (!ctx) { + throw new Error('useAuth must be used inside ') + } + return ctx +} + +export function RequireAuth({ + roles, + children, +}: { + roles?: Array<'client' | 'admin' | 'owner'> + children: React.ReactNode +}) { + useLocale() + const { data, isLoading, error } = usePikkuQuery('me', null, { + retry: false, + }) + + if (isLoading) { + return {m.common__states__loading()} + } + if (error || !data) { + return + } + if (roles && !roles.includes(data.role as 'client' | 'admin' | 'owner')) { + return + } + return ( + + {children} + + ) +} diff --git a/apps/app/src/auth/providers/_types.ts b/apps/app/src/auth/providers/_types.ts new file mode 100644 index 0000000..157662d --- /dev/null +++ b/apps/app/src/auth/providers/_types.ts @@ -0,0 +1,20 @@ +import type { ComponentType } from 'react' + +/** + * Shape every auth provider file exports. + * + * Providers are shadcn-style: copy from `providers/*.ts.inactive` into an + * active `.ts` file and register in `auth.config.ts`. Backend OAuth routes + * live in `@project/functions/src/auth/*`. + */ +export interface AuthProvider { + id: 'auth0' | 'email' | 'github' | 'google' | 'microsoft' | 'passwordless' + labelKey: string + Icon: ComponentType<{ size?: number | string }> + /** Variant tone used for provider button treatment. */ + tone: 'primary' | 'secondary' | 'default' + /** Required env var names — surfaced by the console's auth browser. */ + envVars: string[] + /** Kicks off the login flow. May redirect (OAuth) or open a modal (email). */ + login(): Promise | void +} diff --git a/apps/app/src/auth/providers/auth0.ts b/apps/app/src/auth/providers/auth0.ts new file mode 100644 index 0000000..1274428 --- /dev/null +++ b/apps/app/src/auth/providers/auth0.ts @@ -0,0 +1,22 @@ +import { Lock } from 'lucide-react' +import type { AuthProvider } from './_types' + +/** + * Auth0 — default provider. Universal login gives email + password out of + * the box; social providers (GitHub / Google / etc.) configured on the + * Auth0 tenant side, not in this file. + * + * Secrets (`AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`) are + * read by the backend via `secrets.getSecret()`. This client file never + * sees them. + */ +export const auth0: AuthProvider = { + id: 'auth0', + labelKey: 'auth__providers__auth0', + Icon: Lock, + tone: 'primary', + envVars: ['AUTH0_DOMAIN', 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET'], + login: () => { + window.location.href = '/auth/auth0/login' + }, +} diff --git a/apps/app/src/auth/providers/github.ts.inactive b/apps/app/src/auth/providers/github.ts.inactive new file mode 100644 index 0000000..2e497cc --- /dev/null +++ b/apps/app/src/auth/providers/github.ts.inactive @@ -0,0 +1,13 @@ +import { Github } from 'lucide-react' +import type { AuthProvider } from './_types' + +export const github: AuthProvider = { + id: 'github', + labelKey: 'auth:providers.github', + Icon: Github, + tone: 'default', + envVars: ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'], + login: () => { + window.location.href = '/auth/github/login' + }, +} diff --git a/apps/app/src/auth/providers/google.ts.inactive b/apps/app/src/auth/providers/google.ts.inactive new file mode 100644 index 0000000..9ff2714 --- /dev/null +++ b/apps/app/src/auth/providers/google.ts.inactive @@ -0,0 +1,13 @@ +import { Chrome } from 'lucide-react' +import type { AuthProvider } from './_types' + +export const google: AuthProvider = { + id: 'google', + labelKey: 'auth:providers.google', + Icon: Chrome, + tone: 'default', + envVars: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], + login: () => { + window.location.href = '/auth/google/login' + }, +} diff --git a/apps/app/src/auth/providers/microsoft.ts.inactive b/apps/app/src/auth/providers/microsoft.ts.inactive new file mode 100644 index 0000000..3941cb0 --- /dev/null +++ b/apps/app/src/auth/providers/microsoft.ts.inactive @@ -0,0 +1,13 @@ +import { KeyRound } from 'lucide-react' +import type { AuthProvider } from './_types' + +export const microsoft: AuthProvider = { + id: 'microsoft', + labelKey: 'auth:providers.microsoft', + Icon: KeyRound, + tone: 'default', + envVars: ['MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'MS_TENANT_ID'], + login: () => { + window.location.href = '/auth/microsoft/login' + }, +} diff --git a/apps/app/src/auth/providers/passwordless.ts.inactive b/apps/app/src/auth/providers/passwordless.ts.inactive new file mode 100644 index 0000000..1af18c5 --- /dev/null +++ b/apps/app/src/auth/providers/passwordless.ts.inactive @@ -0,0 +1,13 @@ +import { Fingerprint } from 'lucide-react' +import type { AuthProvider } from './_types' + +export const passwordless: AuthProvider = { + id: 'passwordless', + labelKey: 'auth:providers.passwordless', + Icon: Fingerprint, + tone: 'default', + envVars: ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS'], + login: () => { + window.location.href = '/auth/passwordless/request' + }, +} diff --git a/apps/app/src/components/AppLayout.tsx b/apps/app/src/components/AppLayout.tsx new file mode 100644 index 0000000..8716670 --- /dev/null +++ b/apps/app/src/components/AppLayout.tsx @@ -0,0 +1,271 @@ +import { Link, useLocation, useNavigate } from '@tanstack/react-router' +import { + Avatar, + AppShell, + Burger, + Divider, + Group, + Menu, + NavLink, + ScrollArea, + Stack, + Text, + Title, + UnstyledButton, +} from '@pikku/mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { useQueryClient } from '@tanstack/react-query' +import { m } from '@/i18n/messages' +import { role as roleLabel, type I18nMessage } from '@/i18n/i18n-enum.gen' +import { useLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { createContext, useContext, useEffect, useState } from 'react' +import { signOut } from '../lib/auth' +import { BrandMark } from './Brand' + +/** + * Per-page title shown in the app header next to the brand mark. Pages opt in + * via `usePageTitle(...)`; pages that don't call it leave the header showing + * only the brand, so existing pages keep working unchanged. + */ +const PageTitleContext = createContext< + ((title: string | undefined) => void) | null +>(null) + +export function PageTitle({ title }: { title: string }) { + usePageTitle(title) + return null +} + +export function usePageTitle(title: string | undefined) { + const setTitle = useContext(PageTitleContext) + useEffect(() => { + // No-op safely if used outside the provider (e.g. unauthenticated pages). + if (!setTitle) return + setTitle(title) + return () => setTitle(undefined) + }, [setTitle, title]) +} + +type Role = 'client' | 'admin' | 'owner' + +type NavItem = { + to: string + label: I18nMessage + roles: Role[] +} + +type Section = { + title: I18nMessage + items: NavItem[] +} + +const SECTIONS: Section[] = [ + { + title: m.common__nav__sections__workspace, + items: [ + { to: '/admin', label: m.common__nav__items__dashboard, roles: ['admin', 'owner'] }, + { to: '/admin/bookings', label: m.common__nav__items__bookings, roles: ['admin', 'owner'] }, + { to: '/admin/enquiries', label: m.common__nav__items__enquiries, roles: ['admin', 'owner'] }, + { to: '/kanban', label: m.common__nav__items__kanban, roles: ['admin', 'owner'] }, + ], + }, + { + title: m.common__nav__sections__public, + items: [ + { to: '/availability', label: m.common__nav__items__availability, roles: ['client', 'admin', 'owner'] }, + { to: '/enquiry', label: m.common__nav__items__enquiry, roles: ['admin', 'owner'] }, + { to: '/events', label: m.common__nav__items__events, roles: ['client', 'admin', 'owner'] }, + ], + }, +] + +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join('') + +export function AppLayout({ + user, + children, +}: { + user: { name: string; role: string; email: string } + children: React.ReactNode +}) { + useLocale() + const [opened, { toggle, close }] = useDisclosure() + const [pageTitle, setPageTitle] = useState(undefined) + const location = useLocation() + const navigate = useNavigate() + const qc = useQueryClient() + const role = user.role as Role + + const [logoutPending, setLogoutPending] = useState(false) + const handleLogout = async () => { + setLogoutPending(true) + try { + await signOut() + } finally { + await qc.invalidateQueries({ queryKey: ['me'] }) + navigate({ to: '/login' }) + } + } + + return ( + + + + + + + + + + + {m.common__brand__name()} + + + + {pageTitle && ( + <> + + + {asI18n(`${pageTitle}`)} + + + )} + + + + + + {asI18n(`${user.name}`)} + + {roleLabel[role]()} + + + + {initials(user.name)} + + + + + {asI18n(`${user.email}`)} + + + {m.common__nav__logout()} + + + + + + + + + + {SECTIONS.map((section, i) => { + const visible = section.items.filter((n) => n.roles.includes(role)) + if (visible.length === 0) return null + return ( + + + {section.title()} + + {visible.map((item) => { + // '/admin' is the dashboard landing — match it exactly so it + // doesn't stay highlighted across every /admin/* sub-page. + const active = + item.to === '/admin' + ? location.pathname === '/admin' || + location.pathname === '/admin/' + : location.pathname.startsWith(item.to) + return ( + + ) + })} + + ) + })} + + + + {m.common__brand__tagline()} + + + + {children} + + + ) +} diff --git a/apps/app/src/components/BookingActions.tsx b/apps/app/src/components/BookingActions.tsx new file mode 100644 index 0000000..2b9e684 --- /dev/null +++ b/apps/app/src/components/BookingActions.tsx @@ -0,0 +1,175 @@ +import { Alert, Anchor, Group, Stack } from '@pikku/mantine/core' +import { useState } from 'react' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen' +import { ConfirmButton } from './ConfirmButton' +import type { BookingStatus } from '../lib/status' + +/** + * The single hub for every booking state transition. Each action is a + * two-click {@link ConfirmButton}, gated by the booking's current state so + * only the relevant next steps appear. Lives in the detail-page header; + * {@link BookingStatusPanel} stays display-only. + */ +type ActionBooking = { + bookingId: string + status: BookingStatus + contractSentAt: Date | null + contractResponse: string | null +} + +export function BookingActions({ + booking, + depositPaid, + onMutated, +}: { + booking: ActionBooking + depositPaid: boolean + onMutated: () => Promise +}) { + useLocale() + const { bookingId, status, contractSentAt, contractResponse } = booking + const [isRefreshing, setIsRefreshing] = useState(false) + + const runAndRefresh = async (run: () => void) => { + setIsRefreshing(true) + run() + } + + const handleSettled = async () => { + await onMutated() + setIsRefreshing(false) + } + + const setStatus = usePikkuMutation('setBookingStatus', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) }) + const triggerContract = usePikkuMutation('triggerContractEmail', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) }) + const contractResp = usePikkuMutation('recordContractResponse', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) }) + const deposit = usePikkuMutation('recordDepositReceived', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) }) + const formLink = usePikkuMutation('createBookingFormLink') + const actionBusy = + isRefreshing || + setStatus.isPending || + triggerContract.isPending || + contractResp.isPending || + deposit.isPending + + return ( + + + {status === 'enquiry' && ( + void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'reserved' }))} + > + {m.common__pages__admin_booking__status__actions__approve_enquiry()} + + )} + + {status === 'reserved' && !contractSentAt && ( + void runAndRefresh(() => triggerContract.mutate({ bookingId }))} + > + {m.common__pages__admin_booking__status__actions__send_contract_now()} + + )} + + {status === 'reserved' && ( + formLink.mutate({ bookingId })} + > + {m.common__pages__admin_booking__status__actions__create_form_link()} + + )} + + {status === 'reserved' && contractSentAt && !contractResponse && ( + <> + void runAndRefresh(() => contractResp.mutate({ bookingId, response: 'approved' }))} + > + {m.common__pages__admin_booking__status__actions__record_contract_approved()} + + void runAndRefresh(() => contractResp.mutate({ bookingId, response: 'declined' }))} + > + {m.common__pages__admin_booking__status__actions__record_contract_declined()} + + + )} + + {status === 'reserved' && contractResponse === 'approved' && !depositPaid && ( + void runAndRefresh(() => deposit.mutate({ bookingId }))} + > + {m.common__pages__admin_booking__status__actions__record_deposit_received()} + + )} + + {status === 'confirmed' && ( + void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'ended' }))} + > + {m.common__pages__admin_booking__status__actions__mark_ended()} + + )} + + {status !== 'ended' && status !== 'cancelled' && ( + void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'cancelled' }))} + > + {m.common__pages__admin_booking__status__actions__cancel_booking()} + + )} + + + {formLink.data && ( + + {asI18n(`${m.common__pages__admin_booking__status__form_link__label()} `)} + + {asI18n(`${formLink.data.url}`)} + + + )} + + ) +} diff --git a/apps/app/src/components/BookingStatusPanel.tsx b/apps/app/src/components/BookingStatusPanel.tsx new file mode 100644 index 0000000..270720a --- /dev/null +++ b/apps/app/src/components/BookingStatusPanel.tsx @@ -0,0 +1,269 @@ +import { m } from '@/i18n/messages' +import { flagReason as flagReasonLabels } from '@/i18n/i18n-enum.gen' +import { useLocale, getLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { + Alert, + Group, + Progress, + Stack, + Stepper, + Text, +} from '@pikku/mantine/core' +import { AlertTriangle, Clock } from 'lucide-react' +import { fmtDate } from '../lib/dates' +import type { BookingStatus } from '../lib/status' + +// ─── Types ──────────────────────────────────────────────────────────────── + +type DepositInvoice = { + invoiceId: string + kind: string + dueOn: Date | null + paidOn: Date | null + status: string +} + +type BookingMilestones = { + bookingId: string + status: BookingStatus + startDate: Date | null + enquiryAt: Date + reservedAt: Date | null + confirmedAt: Date | null + endedAt: Date | null + cancelledAt: Date | null + contractSentAt: Date | null + contractResponse: string | null + contractResponseAt: Date | null + depositReminderSentAt: Date | null + flaggedAt: Date | null + flagReason: string | null +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function berlinToday(): string { + return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) +} + +function berlinDateStr(d: Date): string { + return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) +} + +function daysBetween(from: Date, toYmd: string): number { + return Math.round((Date.parse(toYmd) - from.getTime()) / 86_400_000) +} + +function addDaysToDate(d: Date, n: number): Date { + const result = new Date(d) + result.setUTCDate(result.getUTCDate() + n) + return result +} + +type NextAction = + | { kind: 'flagged'; reason: string } + | { kind: 'awaiting_1yr' } + | { kind: 'awaiting_signature' } + | { kind: 'awaiting_deposit'; dueOn: Date | null } + | { kind: 'contract_declined' } + | null + +function nextAction(booking: BookingMilestones, deposit: DepositInvoice | null): NextAction { + const { status, flaggedAt, flagReason, contractSentAt, contractResponse } = booking + if (status === 'cancelled' || status === 'ended' || status === 'confirmed' || status === 'enquiry') return null + if (flaggedAt && flagReason === 'contract_declined') return { kind: 'contract_declined' } + if (flaggedAt) return { kind: 'flagged', reason: flagReason ?? '' } + if (status === 'reserved') { + if (!contractSentAt) return { kind: 'awaiting_1yr' } + if (!contractResponse) return { kind: 'awaiting_signature' } + if (contractResponse === 'approved' && !deposit?.paidOn) return { kind: 'awaiting_deposit', dueOn: deposit?.dueOn ?? null } + } + return null +} + +// ─── Sub-components ─────────────────────────────────────────────────────── + +function DepositProgress({ contractSentAt, deposit, lng }: { + contractSentAt: Date + deposit: DepositInvoice | null + lng: string +}) { + useLocale() + const today = berlinToday() + const daysSent = daysBetween(contractSentAt, today) + const pct = Math.min(100, Math.round((daysSent / 14) * 100)) + const color = daysSent >= 14 ? 'red' : daysSent >= 7 ? 'yellow' : 'plum' + const dueDate = deposit?.dueOn ?? addDaysToDate(contractSentAt, 14) + const dueDateStr = berlinDateStr(dueDate) + const overdue = !deposit?.paidOn && dueDateStr < today + + return ( + + + + {deposit?.paidOn + ? m.common__pages__admin_booking__status__milestones__deposit_paid() + : overdue + ? m.common__pages__admin_booking__status__deposit_overdue() + : m.common__pages__admin_booking__status__deposit_window({ day: daysSent })} + + {deposit?.paidOn ? null : ( + + {m.common__pages__admin_booking__status__due_on({ date: fmtDate(dueDate, lng) })} + + )} + + {!deposit?.paidOn && ( + + )} + + ) +} + +// ─── Main component ─────────────────────────────────────────────────────── + +export function BookingStatusPanel({ + booking, + invoices, +}: { + booking: BookingMilestones + invoices: DepositInvoice[] +}) { + useLocale() + const lng = getLocale() + + const deposit = invoices.find(inv => inv.kind === 'deposit') ?? null + + const action = nextAction(booking, deposit) + + // ── Stepper steps ── + const steps = [ + { + key: 'enquiry', + label: m.common__pages__admin_booking__status__milestones__enquiry(), + description: fmtDate(booking.enquiryAt, lng), + done: true, + }, + { + key: 'reserved', + label: m.common__pages__admin_booking__status__milestones__reserved(), + description: booking.reservedAt ? fmtDate(booking.reservedAt, lng) : null, + done: ['reserved', 'confirmed', 'ended'].includes(booking.status), + }, + { + key: 'contractSent', + label: m.common__pages__admin_booking__status__milestones__contract_sent(), + description: booking.contractSentAt ? fmtDate(booking.contractSentAt, lng) : null, + done: !!booking.contractSentAt, + }, + { + key: 'contractSigned', + label: m.common__pages__admin_booking__status__milestones__contract_signed(), + description: booking.contractResponseAt && booking.contractResponse === 'approved' + ? fmtDate(booking.contractResponseAt, lng) : null, + done: booking.contractResponse === 'approved', + }, + { + key: 'depositPaid', + label: m.common__pages__admin_booking__status__milestones__deposit_paid(), + description: deposit?.paidOn ? fmtDate(deposit.paidOn, lng) : null, + done: !!deposit?.paidOn, + }, + { + key: 'confirmed', + label: m.common__pages__admin_booking__status__milestones__confirmed(), + description: booking.confirmedAt ? fmtDate(booking.confirmedAt, lng) : null, + done: ['confirmed', 'ended'].includes(booking.status), + }, + { + key: 'ended', + label: m.common__pages__admin_booking__status__milestones__ended(), + description: booking.endedAt ? fmtDate(booking.endedAt, lng) : null, + done: booking.status === 'ended', + }, + ] + + const activeStep = (() => { + switch (booking.status) { + case 'ended': return steps.length // all steps get checkmarks + case 'confirmed': return steps.length - 1 // "ended" step is current + case 'cancelled': return steps.reduce((acc, s, i) => (s.done ? i : acc), -1) + default: return steps.findIndex(s => !s.done) // enquiry/reserved: use milestone data + } + })() + + return ( + + {/* Flagged alert */} + {booking.flaggedAt && booking.flagReason && ( + } title={m.common__pages__admin_booking__status__flagged()}> + {booking.flagReason in flagReasonLabels + ? flagReasonLabels[booking.flagReason as keyof typeof flagReasonLabels]() + : asI18n(booking.flagReason)} + + )} + + {/* ── Horizontal stepper ── */} + + {steps.map((step) => ( + + ))} + {booking.status === 'cancelled' && ( + + )} + + + {/* Deposit progress bar (when contract sent but deposit not yet paid) */} + {booking.contractSentAt && !deposit?.paidOn && ( + + )} + + {/* ── Next action alert (informational; controls live in BookingActions) ── */} + {action && ( + } + > + {action.kind === 'flagged' && + (action.reason in flagReasonLabels + ? flagReasonLabels[action.reason as keyof typeof flagReasonLabels]() + : asI18n(action.reason))} + {action.kind === 'awaiting_1yr' && m.common__pages__admin_booking__status__contract_not_sent_yet({ + date: booking.startDate ? fmtDate(addDaysToDate(booking.startDate, -365), lng) : '—', + })} + {action.kind === 'awaiting_signature' && m.common__pages__admin_booking__status__awaiting_signature()} + {action.kind === 'awaiting_deposit' && ( + <> + {m.common__pages__admin_booking__status__awaiting_deposit()} + {action.dueOn && ( + + {m.common__pages__admin_booking__status__due_on({ date: fmtDate(action.dueOn, lng) })} + + )} + + )} + {action.kind === 'contract_declined' && flagReasonLabels.contract_declined()} + + )} + + ) +} diff --git a/apps/app/src/components/Brand.tsx b/apps/app/src/components/Brand.tsx new file mode 100644 index 0000000..fd6c1aa --- /dev/null +++ b/apps/app/src/components/Brand.tsx @@ -0,0 +1,34 @@ +import { Group, Stack, Text, Title } from '@pikku/mantine/core' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' + +type Variant = 'plum' | 'white' + +export function BrandMark({ + size = 48, + variant = 'plum', +}: { + size?: number + variant?: Variant +}) { + const src = variant === 'white' ? '/brand/logo-white.svg' : '/brand/logo-plum.svg' + return Seminarhof Drawehn +} + +export function BrandHeader({ variant = 'plum' }: { variant?: Variant }) { + useLocale() + const color = variant === 'white' ? 'white' : 'var(--brand-plum)' + return ( + + + + + {m.common__brand__name()} + + + {m.common__brand__tagline()} + + + + ) +} diff --git a/apps/app/src/components/ConfirmButton.tsx b/apps/app/src/components/ConfirmButton.tsx new file mode 100644 index 0000000..3b5fd9d --- /dev/null +++ b/apps/app/src/components/ConfirmButton.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react' +import { Button, type ButtonProps } from '@pikku/mantine/core' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' +import { type I18nString, type I18nNode } from '@pikku/react' + +/** + * A button that requires two clicks to fire, guarding against accidental + * state transitions. The first click "arms" it — the label swaps to a + * confirm prompt and the colour goes red; the second click commits and + * disarms. If left untouched it auto-reverts after `resetMs`. + * + * Disarming happens synchronously on the confirming click (not on mutation + * success), so a failed mutation leaves a normal, retryable button — the + * caller's `loading` prop drives the in-flight visual. + */ +type ConfirmButtonProps = ButtonProps & { + onConfirm: () => void + /** Label shown in the armed state. Defaults to the localized "Confirm?". */ + confirmLabel?: I18nString + /** Colour while armed. */ + confirmColor?: string + /** Auto-revert delay in ms. */ + resetMs?: number + children?: I18nNode + 'data-testid'?: string +} + +export function ConfirmButton({ + onConfirm, + confirmLabel, + confirmColor = 'red', + resetMs = 3000, + color, + variant, + children, + 'data-testid': testId, + ...rest +}: ConfirmButtonProps) { + useLocale() + const [armed, setArmed] = useState(false) + const timer = useRef | undefined>(undefined) + + useEffect(() => () => clearTimeout(timer.current), []) + + const handleClick = () => { + if (armed) { + clearTimeout(timer.current) + setArmed(false) + onConfirm() + return + } + setArmed(true) + timer.current = setTimeout(() => setArmed(false), resetMs) + } + + return ( + + ) +} diff --git a/apps/app/src/components/DateBar.tsx b/apps/app/src/components/DateBar.tsx new file mode 100644 index 0000000..4d22e5c --- /dev/null +++ b/apps/app/src/components/DateBar.tsx @@ -0,0 +1,69 @@ +import { ActionIcon, Button, Group, TextInput, Tooltip } from '@pikku/mantine/core' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' + +export const todayIso = () => new Date().toISOString().slice(0, 10) + +export const shiftDate = (iso: string, days: number) => { + const d = new Date(iso + 'T00:00:00Z') + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +export function DateBar({ + date, + onChange, +}: { + date: string + onChange: (next: string) => void +}) { + useLocale() + const isToday = date === todayIso() + return ( + + + onChange(shiftDate(date, -1))} + aria-label={m.common__pages__staff__prev_day()} + data-testid="date-bar-prev" + > + ‹ + + + { + const v = e.currentTarget.value + if (v) onChange(v) + }} + data-testid="date-bar-input" + styles={{ input: { minWidth: 160 } }} + /> + + onChange(shiftDate(date, 1))} + aria-label={m.common__pages__staff__next_day()} + data-testid="date-bar-next" + > + › + + + + + ) +} diff --git a/apps/app/src/components/EventCard.tsx b/apps/app/src/components/EventCard.tsx new file mode 100644 index 0000000..d4dde7d --- /dev/null +++ b/apps/app/src/components/EventCard.tsx @@ -0,0 +1,86 @@ +import { m } from '@/i18n/messages' +import { useLocale, getLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { Box, Button, Card, Flex, Image, Stack, Text, Title } from '@pikku/mantine/core' +import { ImageOff } from 'lucide-react' + +type EventCardProps = { + eventName: string + startDate: Date | null + endDate: Date | null + clientName?: string | null + coverImageUrl?: string | null + eventOutline?: string | null + description?: string | null + organiserWebsite?: string | null +} + +function formatDateRange(start: Date | null, end: Date | null, lng: string): string { + const locale = lng.startsWith('de') ? 'de-DE' : 'en-GB' + const fmt = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }) + // An enquiry may have no dates yet; show whatever we have, or a placeholder. + if (!start && !end) return '—' + if (!start || !end) return fmt.format((start ?? end) as Date) + return start.toISOString().slice(0, 10) === end.toISOString().slice(0, 10) + ? fmt.format(start) + : fmt.formatRange(start, end) +} + +export function EventCard({ + eventName, + startDate, + endDate, + clientName, + coverImageUrl, + eventOutline, + description, + organiserWebsite, +}: EventCardProps) { + useLocale() + + return ( + + + + {coverImageUrl ? ( + {eventName} + ) : ( + + + + )} + + + {asI18n(`${eventName}`)} + + {asI18n(`${formatDateRange(startDate, endDate, getLocale())}`)} + + {clientName && ( + + {asI18n(`${clientName}`)} + + )} + {(eventOutline || description) && ( + + {asI18n(`${eventOutline || description}`)} + + )} + {organiserWebsite && ( + + )} + + + + ) +} diff --git a/apps/app/src/components/MarketingShell.tsx b/apps/app/src/components/MarketingShell.tsx new file mode 100644 index 0000000..f6a6302 --- /dev/null +++ b/apps/app/src/components/MarketingShell.tsx @@ -0,0 +1,34 @@ +import { Box } from '@pikku/mantine/core' +import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen' +import { AppLayout } from './AppLayout' +import { SeminarhofHeader, SeminarhofFooter } from './SeminarhofChrome' + +/** + * Shell for the public marketing-facing pages (availability / events / enquiry). + * Logged-in users keep the authenticated AppLayout (sidebar) so the admin + * experience is consistent. Anonymous visitors get the seminarhof-drawehn.de + * header + footer so the app and the marketing site feel like one website. + * + * Deliberately a sibling of PublicShell (not a variant) — pages opt into the + * marketing chrome explicitly, so a future public route (e.g. kanban) can't + * inherit it by accident. + */ +export function MarketingShell({ children }: { children: React.ReactNode }) { + const { data, isLoading } = usePikkuQuery('me', null, { retry: false }) + + if (isLoading) { + return + } + + if (data) { + return {children} + } + + return ( + + + {children} + + + ) +} diff --git a/apps/app/src/components/ParticipantsTab.tsx b/apps/app/src/components/ParticipantsTab.tsx new file mode 100644 index 0000000..3c10c81 --- /dev/null +++ b/apps/app/src/components/ParticipantsTab.tsx @@ -0,0 +1,609 @@ +import { useMemo, useState } from 'react' +import { m } from '@/i18n/messages' +import { + dietary as dietaryLabels, + participantKind as kindLabels, + severity as severityLabels, + allergen as allergenLabels, +} from '@/i18n/i18n-enum.gen' +import { useLocale } from '@/i18n/config' +import { asI18n, type I18nString } from '@pikku/react' +import { + ActionIcon, + Badge, + Box, + Button, + Group, + Paper, + Select, + Stack, + Text, + TextInput, + Tooltip, +} from '@pikku/mantine/core' + +type SeverityKey = 'mild' | 'moderate' | 'severe' | 'standard' + +type Allergy = { + allergen: string + severity: string + note?: string | null +} + +type Participant = { + participantId: string + name: string + kind: string + dietaryTag: string | null + roomId: string | null + roomNumber: number | null + allergies: Allergy[] +} + +type DietaryTag = + | 'omnivore' + | 'vegetarian' + | 'vegan' + | 'gluten_free' + | 'lactose_free' + | 'pescatarian' + | 'unknown' +type Kind = 'overnight' | 'day_guest' + +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join('') + +const TINTS = [ + '#88557F', + '#A05C8E', + '#BC7AA6', + '#D3A7C4', + '#FFBC7D', + '#F37E15', + '#FFCE00', + '#806600', + '#4E3149', + '#583651', +] +const tintFor = (id: string) => { + let h = 0 + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0 + return TINTS[Math.abs(h) % TINTS.length] +} + +const SEVERITY_STYLE: Record< + SeverityKey, + { bg: string; border: string; fg: string } +> = { + mild: { bg: '#F1EEF0', border: '#D9D2D7', fg: '#5A4F56' }, + moderate: { bg: '#FFE7CF', border: '#FFBC7D', fg: '#7A4A18' }, + severe: { bg: '#4E3149', border: '#4E3149', fg: '#FFFFFF' }, + standard: { bg: '#F5EAF1', border: '#E0CCD9', fg: '#4E3149' }, +} + +const severityKey = (s: string): SeverityKey => + s === 'mild' || s === 'moderate' || s === 'severe' || s === 'standard' + ? s + : 'standard' + +const DIETARY_VALUES: DietaryTag[] = [ + 'omnivore', + 'vegetarian', + 'vegan', + 'gluten_free', + 'lactose_free', + 'pescatarian', + 'unknown', +] + +export function ParticipantsTab({ + participants, + bookingId, + addParticipant, + updateParticipant, + removeParticipant, + addAllergy, + removeAllergy, + isAddingParticipant, +}: { + participants: Participant[] + bookingId: string + addParticipant: (args: { + bookingId: string + name: string + kind: Kind + email: null + age: null + notes: null + }, opts?: { onSuccess?: () => void }) => void + updateParticipant: (args: { + participantId: string + dietaryTag: DietaryTag | null + }) => void + removeParticipant: (args: { participantId: string }) => void + addAllergy: ( + args: { + participantId: string + allergen: string + severity: 'standard' | 'separate_prep' | 'severe' + }, + opts?: { onSuccess?: () => void }, + ) => void + removeAllergy: (args: { participantId: string; allergen: string }) => void + isAddingParticipant: boolean +}) { + useLocale() + const [newName, setNewName] = useState('') + const [newKind, setNewKind] = useState('overnight') + const [newAllergyByPid, setNewAllergyByPid] = useState>({}) + const [allergyOpenByPid, setAllergyOpenByPid] = useState>({}) + + const groups = useMemo(() => { + const overnight = participants.filter((p) => p.kind === 'overnight') + const day = participants.filter((p) => p.kind === 'day_guest') + return [ + { kind: 'overnight' as const, items: overnight }, + { kind: 'day_guest' as const, items: day }, + ].filter((g) => g.items.length > 0) + }, [participants]) + + const dietaryOptions = DIETARY_VALUES.map((v) => ({ + value: v, + label: dietaryLabels[v](), + })) + + const submitAdd = () => { + const name = newName.trim() + if (!name) return + addParticipant( + { bookingId, name, kind: newKind, email: null, age: null, notes: null }, + { + onSuccess: () => { + setNewName('') + setNewKind('overnight') + }, + }, + ) + } + + return ( + + {/* Inline compact add-participant bar -------------------------------- */} + + + + {m.common__pages__booking__participants__add_modal__title()} + + setNewName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitAdd() + }} + style={{ flex: 1, minWidth: 160 }} + aria-label={m.common__pages__booking__participants__add_modal__name()} + /> + onDietChange((v as DietaryTag | null) ?? null)} + data={dietaryOptions} + clearable + placeholder={dietaryLabels.unknown()} + /> + + {/* Allergy summary chip */} + + + {/* Delete */} + + + × + + + + + {/* Allergy chips + editor */} + + {p.allergies.length === 0 && !allergyEditorOpen ? ( + + ) : ( + + {p.allergies.map((a) => ( + onRemoveAllergy(a.allergen)} + testId={`remove-allergy-${p.participantId}-${a.allergen}`} + removeLabel={m.common__pages__booking__participants__allergy_remove_aria({ + allergen: a.allergen, + })} + /> + ))} + {!allergyEditorOpen ? ( + + ) : null} + + )} + + {allergyEditorOpen && ( + + onAllergyDraftChange(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onAddAllergy() + }} + style={{ flex: 1, maxWidth: 280 }} + /> + + + + )} + + + + ) +} + +function AllergySummary({ count }: { count: number }) { + useLocale() + if (count === 0) { + return ( + + {m.common__pages__booking__participants__allergy_none()} + + ) + } + return ( + + {m.common__pages__booking__participants__allergy_count({ count })} + + ) +} + +function AllergyChip({ + allergy, + onRemove, + testId, + removeLabel, +}: { + allergy: Allergy + onRemove: () => void + testId: string + removeLabel: I18nString +}) { + useLocale() + const sev = severityKey(allergy.severity) + const style = SEVERITY_STYLE[sev] + const sevLabel = + sev === 'standard' + ? null + : severityLabels[sev]() + + return ( + + {allergy.allergen in allergenLabels + ? allergenLabels[allergy.allergen as keyof typeof allergenLabels]() + : asI18n(allergy.allergen)} + {sevLabel ? ( + + · {sevLabel} + + ) : null} + + × + + + ) +} diff --git a/apps/app/src/components/PublicShell.tsx b/apps/app/src/components/PublicShell.tsx new file mode 100644 index 0000000..48decd2 --- /dev/null +++ b/apps/app/src/components/PublicShell.tsx @@ -0,0 +1,62 @@ +import { Link } from '@tanstack/react-router' +import { Box, Button, Container, Group, Title } from '@pikku/mantine/core' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' +import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen' +import { BrandMark } from './Brand' +import { AppLayout } from './AppLayout' + +/** + * Shell for routes that are accessible to anonymous visitors. If the user is + * logged in, we render them inside the authenticated AppLayout so the sidebar + * stays consistent across the app. Otherwise we render a slim public header. + */ +export function PublicShell({ children }: { children: React.ReactNode }) { + useLocale() + const { data, isLoading } = usePikkuQuery('me', null, { retry: false }) + + if (isLoading) { + return + } + + if (data) { + return {children} + } + + return ( + + + + + + + + + {m.common__brand__name()} + + + + + + + + {children} + + ) +} diff --git a/apps/app/src/components/RoomsTab.tsx b/apps/app/src/components/RoomsTab.tsx new file mode 100644 index 0000000..998f37e --- /dev/null +++ b/apps/app/src/components/RoomsTab.tsx @@ -0,0 +1,493 @@ +import { useMemo, useState } from 'react' +import { m } from '@/i18n/messages' +import { building as buildingLabels, floor as floorLabels } from '@/i18n/i18n-enum.gen' +import { useLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { + Badge, + Box, + Button, + Group, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, +} from '@pikku/mantine/core' + +type Room = { + roomId: string + number: number + beds: number + roomType: string + building: string + floor: string + wheelchair: number + groundFloor: number + quiet: number + sharedBath: number + dogsAllowed: number + doubleBed_140: number + allergyFriendly: number + occupiedByOtherBooking: boolean + assignedParticipants: { participantId: string; name: string }[] +} + +type Participant = { participantId: string; name: string; roomId: string | null } + +const PALETTE = { + empty: '#FBF8FA', + emptyBorder: '#E5DDE3', + filled: '#E9D4E2', + filledBorder: '#88557F', + partial: '#F0E4EC', + full: '#88557F', + fullText: '#FFFFFF', + occupiedOther: '#EDEDED', + occupiedOtherText: '#9B9499', + hover: '#FFCE00', +} + +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join('') + +// Stable, soft per-participant tint so each guest reads as a distinct chip +// in the floor plan without leaving the brand palette. +const TINTS = [ + '#88557F', + '#A05C8E', + '#BC7AA6', + '#D3A7C4', + '#FFBC7D', + '#F37E15', + '#FFCE00', + '#806600', + '#4E3149', + '#583651', +] +const tintFor = (id: string) => { + let h = 0 + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0 + return TINTS[Math.abs(h) % TINTS.length] +} + +type Group = { building: string; floor: string; rooms: Room[] } + +function groupRooms(rooms: Room[]): Group[] { + const map = new Map() + for (const r of rooms) { + const key = `${r.building}::${r.floor}` + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(r) + } + const order = ['gaestehaus::eg', 'gaestehaus::og', 'haupthaus::eg', 'haupthaus::og'] + return Array.from(map.entries()) + .map(([k, rs]) => { + const [building, floor] = k.split('::') + return { building, floor, rooms: rs.sort((a, b) => a.number - b.number) } + }) + .sort( + (a, b) => + order.indexOf(`${a.building}::${a.floor}`) - order.indexOf(`${b.building}::${b.floor}`), + ) +} + +function buildingLabel(building: string, floor: string) { + const b = building in buildingLabels ? buildingLabels[building as keyof typeof buildingLabels]() : asI18n(building) + const f = floor in floorLabels ? floorLabels[floor as keyof typeof floorLabels]() : asI18n(floor) + return `${b} · ${f}` +} + +export function RoomsTab({ + rooms, + participants, + bookingId, + assign, + unassign, +}: { + rooms: Room[] + participants: Participant[] + bookingId: string + assign: (args: { bookingId: string; participantId: string; roomId: string }) => void + unassign: (args: { participantId: string }) => void +}) { + useLocale() + const [hovered, setHovered] = useState(null) + const groups = useMemo(() => groupRooms(rooms), [rooms]) + const unassigned = participants.filter((p) => !p.roomId) + + return ( + + {/* Left pane: room list ----------------------------------------- */} + + + + + {m.common__pages__admin_booking__rooms__cols__room()} + {m.common__pages__admin_booking__rooms__cols__guests()} + {m.common__pages__admin_booking__rooms__cols__assign()} + + + + {groups.map((g) => ( + + ))} + +
+
+ + {/* Right pane: SVG floorplan -------------------------------------- */} + + {groups.map((g) => ( + + + + {asI18n(`${buildingLabel(g.building, g.floor)}`)} + + + + + ))} + +
+ ) +} + +function RoomGroupRows({ + group, + hovered, + onHover, + unassigned, + assign, + unassign, + bookingId, +}: { + group: Group + hovered: string | null + onHover: (id: string | null) => void + unassigned: Participant[] + assign: (args: { bookingId: string; participantId: string; roomId: string }) => void + unassign: (args: { participantId: string }) => void + bookingId: string +}) { + useLocale() + return ( + <> + + + + {asI18n(`${buildingLabel(group.building, group.floor)}`)} + + + + {group.rooms.map((r) => ( + onHover(r.roomId)} + onMouseLeave={() => onHover(null)} + style={{ + background: hovered === r.roomId ? 'rgba(255, 206, 0, 0.12)' : undefined, + cursor: 'default', + }} + > + + + {r.number} + + {asI18n(`${r.roomType}`)} + + + {asI18n(`${r.beds} ${m.common__pages__admin_booking__rooms__beds()}`)} + + + {r.occupiedByOtherBooking ? ( + + {m.common__pages__admin_booking__rooms__occupied_other()} + + ) : r.assignedParticipants.length === 0 ? ( + {asI18n('—')} + ) : ( + + {r.assignedParticipants.map((p) => ( + + + + {initials(p.name)} + + {asI18n(`${p.name}`)} + + + + ))} + + )} + + + {!r.occupiedByOtherBooking && unassigned.length > 0 && ( +