chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-11 07:59:25 +02:00
commit 465dd7a41a
188 changed files with 20779 additions and 0 deletions

10
apps/app/.gitignore vendored Normal file
View File

@@ -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.

View File

@@ -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"
]
}

49
apps/app/README.md Normal file
View File

@@ -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 <name>` (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/<name>/` 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` | `<title>`, 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.

49
apps/app/eslint.config.js Normal file
View File

@@ -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,
},
},
]

13
apps/app/index.html Normal file
View File

@@ -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</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

507
apps/app/messages/de.json Normal file
View File

@@ -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 <b>{status}</b> 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"
}

507
apps/app/messages/en.json Normal file
View File

@@ -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 <b>{status}</b>?",
"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"
}

58
apps/app/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="199" height="195" viewBox="0 0 199 195" fill="none"><circle cx="10.2883" cy="10.2883" r="9.24585" transform="matrix(0.511213 0.859454 0.859454 -0.511213 77.7305 95.269)" stroke="#88557F" stroke-width="2.08481"></circle><path d="M162.413 57.2493C162.832 56.9253 162.909 56.323 162.585 55.904C162.261 55.485 161.658 55.4079 161.239 55.7318L162.413 57.2493ZM161.239 55.7318L96.0818 106.104L97.2549 107.622L162.413 57.2493L161.239 55.7318Z" fill="#88557F"></path><path d="M166.273 99.36L93.0414 108.621" stroke="#88557F" stroke-width="0.833922"></path><path d="M151.299 137.003L87.5439 107.672" stroke="#88557F" stroke-width="0.833922"></path><path d="M119.5 160.132L83.6558 104.33" stroke="#88557F" stroke-width="0.833922"></path><path d="M84.3789 163.729L81.9481 99.0737" stroke="#88557F" stroke-width="0.833922"></path><path d="M50.9256 147.255L83.5057 93.5732" stroke="#88557F" stroke-width="0.833922"></path><path d="M32.6526 119.731L87.011 90.2538" stroke="#88557F" stroke-width="0.833922"></path><path d="M29.807 90.3188L91.0514 89.021" stroke="#88557F" stroke-width="0.833922"></path><path d="M40.8925 61.2712L95.4613 89.6849" stroke="#88557F" stroke-width="0.833922"></path><path d="M65.346 42.1662L99.6228 92.7828" stroke="#88557F" stroke-width="0.833922"></path><path d="M93.6945 37.1288L101.548 97.1136" stroke="#88557F" stroke-width="0.833922"></path><path d="M122.319 49.5729L100.701 103.145" stroke="#88557F" stroke-width="0.833922"></path><path d="M162.655 55.9957C162.383 55.5415 161.794 55.3942 161.34 55.6667C160.885 55.9391 160.738 56.5282 161.01 56.9824L162.655 55.9957ZM130.106 159.005L130.562 159.849L130.106 159.005ZM33.5967 132.942L34.4254 132.46L33.5967 132.942ZM142.957 69.4146L143.395 70.2674L145.101 69.3903L144.663 68.5375L142.957 69.4146ZM161.01 56.9824C168.852 70.0547 171.755 89.5477 167.296 108.706C162.845 127.835 151.071 146.56 129.649 158.162L130.562 159.849C152.526 147.953 164.604 128.737 169.164 109.141C173.718 89.5741 170.791 69.5578 162.655 55.9957L161.01 56.9824ZM129.649 158.162C105.281 171.36 84.1564 169.461 67.7924 162.032C51.378 154.58 39.714 141.542 34.4254 132.46L32.7679 133.425C38.2246 142.796 50.1688 156.137 66.9995 163.778C83.8804 171.442 105.638 173.348 130.562 159.849L129.649 158.162ZM34.4254 132.46C28.8154 122.826 24.7835 105.759 27.2801 88.3181C29.7702 70.9215 38.7411 53.2247 59.0569 42.1545L58.1391 40.4703C37.1974 51.8816 27.9409 70.1656 25.3814 88.0464C22.8283 105.883 26.9245 123.39 32.7679 133.425L34.4254 132.46ZM59.0569 42.1545C79.3827 31.0787 97.8582 33.1138 112.508 40.2907C127.205 47.4909 138.05 59.8711 142.957 69.4146L144.663 68.5375C139.582 58.6568 128.449 45.9644 113.352 38.5683C98.2072 31.149 79.0708 29.0644 58.1391 40.4703L59.0569 42.1545Z" fill="#88557F"></path><path d="M159.132 58.7172C173.456 84.3944 169.809 133.067 128.709 155.327C82.0137 180.617 47.1081 148.427 36.9289 130.947C26.0789 112.315 21.5292 65.4417 60.613 44.1445C99.6969 22.8474 131.451 52.6548 140.913 71.0556" stroke="#88557F" stroke-width="0.833922" stroke-linecap="square" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="199" height="195" viewBox="0 0 199 195" fill="none"><circle cx="10.2883" cy="10.2883" r="9.24585" transform="matrix(0.511213 0.859454 0.859454 -0.511213 77.7305 95.269)" stroke="white" stroke-width="2.08481"></circle><path d="M162.413 57.2493C162.832 56.9253 162.909 56.323 162.585 55.904C162.261 55.485 161.658 55.4079 161.239 55.7318L162.413 57.2493ZM161.239 55.7318L96.0818 106.104L97.2549 107.622L162.413 57.2493L161.239 55.7318Z" fill="white"></path><path d="M166.273 99.36L93.0414 108.621" stroke="white" stroke-width="0.833922"></path><path d="M151.299 137.003L87.5439 107.672" stroke="white" stroke-width="0.833922"></path><path d="M119.5 160.132L83.6558 104.33" stroke="white" stroke-width="0.833922"></path><path d="M84.3789 163.729L81.9481 99.0737" stroke="white" stroke-width="0.833922"></path><path d="M50.9256 147.255L83.5057 93.5732" stroke="white" stroke-width="0.833922"></path><path d="M32.6526 119.731L87.011 90.2538" stroke="white" stroke-width="0.833922"></path><path d="M29.807 90.3188L91.0514 89.021" stroke="white" stroke-width="0.833922"></path><path d="M40.8925 61.2712L95.4613 89.6849" stroke="white" stroke-width="0.833922"></path><path d="M65.346 42.1662L99.6228 92.7828" stroke="white" stroke-width="0.833922"></path><path d="M93.6945 37.1288L101.548 97.1136" stroke="white" stroke-width="0.833922"></path><path d="M122.319 49.5729L100.701 103.145" stroke="white" stroke-width="0.833922"></path><path d="M162.655 55.9957C162.383 55.5415 161.794 55.3942 161.34 55.6667C160.885 55.9391 160.738 56.5282 161.01 56.9824L162.655 55.9957ZM130.106 159.005L130.562 159.849L130.106 159.005ZM33.5967 132.942L34.4254 132.46L33.5967 132.942ZM142.957 69.4146L143.395 70.2674L145.101 69.3903L144.663 68.5375L142.957 69.4146ZM161.01 56.9824C168.852 70.0547 171.755 89.5477 167.296 108.706C162.845 127.835 151.071 146.56 129.649 158.162L130.562 159.849C152.526 147.953 164.604 128.737 169.164 109.141C173.718 89.5741 170.791 69.5578 162.655 55.9957L161.01 56.9824ZM129.649 158.162C105.281 171.36 84.1564 169.461 67.7924 162.032C51.378 154.58 39.714 141.542 34.4254 132.46L32.7679 133.425C38.2246 142.796 50.1688 156.137 66.9995 163.778C83.8804 171.442 105.638 173.348 130.562 159.849L129.649 158.162ZM34.4254 132.46C28.8154 122.826 24.7835 105.759 27.2801 88.3181C29.7702 70.9215 38.7411 53.2247 59.0569 42.1545L58.1391 40.4703C37.1974 51.8816 27.9409 70.1656 25.3814 88.0464C22.8283 105.883 26.9245 123.39 32.7679 133.425L34.4254 132.46ZM59.0569 42.1545C79.3827 31.0787 97.8582 33.1138 112.508 40.2907C127.205 47.4909 138.05 59.8711 142.957 69.4146L144.663 68.5375C139.582 58.6568 128.449 45.9644 113.352 38.5683C98.2072 31.149 79.0708 29.0644 58.1391 40.4703L59.0569 42.1545Z" fill="white"></path><path d="M159.132 58.7172C173.456 84.3944 169.809 133.067 128.709 155.327C82.0137 180.617 47.1081 148.427 36.9289 130.947C26.0789 112.315 21.5292 65.4417 60.613 44.1445C99.6969 22.8474 131.451 52.6548 140.913 71.0556" stroke="white" stroke-width="0.833922" stroke-linecap="square" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 199 195" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;"> <g transform="matrix(0.687244,1.1554,1.1554,-0.687244,72.0302,89.8157)"> <circle cx="10.288" cy="10.288" r="9.246" style="fill:none;stroke:rgb(136,85,127);stroke-width:3.32px;"></circle> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M162.522,57.249C162.941,56.925 163.018,56.323 162.694,55.904C162.37,55.485 161.768,55.408 161.349,55.732L162.522,57.249ZM161.349,55.732L96.191,106.104L97.364,107.622L162.522,57.249L161.349,55.732Z" style="fill:rgb(136,85,127);fill-rule:nonzero;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M166.383,99.36L93.151,108.621" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M151.409,137.003L87.653,107.672" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M119.609,160.132L83.765,104.33" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M84.488,163.729L82.058,99.074" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M51.035,147.255L83.615,93.573" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M32.762,119.731L87.12,90.254" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M29.916,90.319L91.161,89.021" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M41.002,61.271L95.571,89.685" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M65.455,42.166L99.732,92.783" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M93.804,37.129L101.657,97.114" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M122.428,49.573L100.81,103.145" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M162.765,55.996C162.492,55.542 161.903,55.394 161.449,55.667C160.995,55.939 160.847,56.528 161.12,56.982L162.765,55.996ZM130.215,159.005L130.672,159.849L130.215,159.005ZM33.706,132.942L34.535,132.46L33.706,132.942ZM143.066,69.415L143.505,70.267L145.211,69.39L144.772,68.538L143.066,69.415ZM161.12,56.982C168.962,70.055 171.864,89.548 167.406,108.706C162.954,127.835 151.18,146.56 129.758,158.162L130.672,159.849C152.635,147.953 164.714,128.737 169.274,109.141C173.827,89.574 170.9,69.558 162.765,55.996L161.12,56.982ZM129.758,158.162C105.39,171.36 84.266,169.461 67.902,162.032C51.487,154.58 39.823,141.542 34.535,132.46L32.877,133.425C38.334,142.796 50.278,156.137 67.109,163.778C83.99,171.442 105.747,173.348 130.672,159.849L129.758,158.162ZM34.535,132.46C28.925,122.826 24.893,105.759 27.389,88.318C29.88,70.922 38.85,53.225 59.166,42.155L58.249,40.47C37.307,51.882 28.05,70.166 25.491,88.046C22.938,105.883 27.034,123.39 32.877,133.425L34.535,132.46ZM59.166,42.155C79.492,31.079 97.968,33.114 112.617,40.291C127.315,47.491 138.159,59.871 143.066,69.415L144.772,68.538C139.691,58.657 128.558,45.964 113.461,38.568C98.317,31.149 79.18,29.064 58.249,40.47L59.166,42.155Z" style="fill:rgb(136,85,127);fill-rule:nonzero;"></path> </g> <g transform="matrix(1.34434,0,0,1.34434,-32.613,-38.2583)"> <path d="M159.242,58.717C173.566,84.394 169.918,133.067 128.819,155.327C82.123,180.617 47.217,148.427 37.038,130.947C26.188,112.315 21.639,65.442 60.722,44.145C99.806,22.847 131.561,52.655 141.023,71.056" style="fill:none;fill-rule:nonzero;stroke:rgb(136,85,127);stroke-width:1.49px;stroke-linecap:square;stroke-linejoin:round;"></path> </g></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -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]

47
apps/app/src/auth.tsx Normal file
View File

@@ -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<typeof usePikkuQuery<'me'>>['data']
const AuthContext = createContext<Me | null>(null)
export function useAuth(): NonNullable<Me> {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth must be used inside <RequireAuth>')
}
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 <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
}
if (error || !data) {
return <Navigate to="/login" />
}
if (roles && !roles.includes(data.role as 'client' | 'admin' | 'owner')) {
return <Navigate to="/" />
}
return (
<AuthContext.Provider value={data}>
<AppLayout user={data}>{children}</AppLayout>
</AuthContext.Provider>
)
}

View File

@@ -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> | void
}

View File

@@ -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'
},
}

View File

@@ -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'
},
}

View File

@@ -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'
},
}

View File

@@ -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'
},
}

View File

@@ -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'
},
}

View File

@@ -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<string | undefined>(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 (
<PageTitleContext.Provider value={setPageTitle}>
<AppShell
header={{ height: 72 }}
navbar={{ width: 248, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding={{ base: 'md', md: 'xl' }}
styles={{
header: {
backgroundColor: '#ffffff',
borderBottom: '1px solid var(--mantine-color-gray-2)',
},
navbar: {
backgroundColor: '#FBF8FA',
borderRight: '1px solid var(--mantine-color-gray-2)',
},
main: { backgroundColor: 'var(--brand-bg)' },
}}
>
<AppShell.Header>
<Group h="100%" px="md" gap="md" wrap="nowrap" justify="space-between">
<Group gap="sm" wrap="nowrap">
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
color="var(--brand-plum)"
aria-label={m.common__nav__toggle()}
/>
<Link to="/" style={{ textDecoration: 'none' }} onClick={close}>
<Group gap="sm" wrap="nowrap" align="center">
<BrandMark size={32} />
<Title
order={5}
c="var(--brand-plum-dark)"
fw={600}
style={{ letterSpacing: '0.01em', lineHeight: 1 }}
>
{m.common__brand__name()}
</Title>
</Group>
</Link>
{pageTitle && (
<>
<Divider orientation="vertical" my={8} visibleFrom="sm" />
<Text
fw={600}
size="md"
c="var(--brand-plum-dark)"
visibleFrom="sm"
style={{ lineHeight: 1 }}
>
{asI18n(`${pageTitle}`)}
</Text>
</>
)}
</Group>
<Menu position="bottom-end" withArrow shadow="sm">
<Menu.Target>
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '4px 8px',
borderRadius: 8,
}}
>
<Stack gap={0} align="flex-end" visibleFrom="sm">
<Text size="sm" fw={600} style={{ lineHeight: 1.2 }}>{asI18n(`${user.name}`)}</Text>
<Text size="xs" c="dimmed" tt="capitalize" style={{ lineHeight: 1.2 }}>
{roleLabel[role]()}
</Text>
</Stack>
<Avatar
radius="xl"
color="plum"
variant="light"
size={36}
>
{initials(user.name)}
</Avatar>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{asI18n(`${user.email}`)}</Menu.Label>
<Menu.Divider />
<Menu.Item
color="red"
onClick={handleLogout}
disabled={logoutPending}
>
{m.common__nav__logout()}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<AppShell.Section grow component={ScrollArea}>
<Stack gap="lg">
{SECTIONS.map((section, i) => {
const visible = section.items.filter((n) => n.roles.includes(role))
if (visible.length === 0) return null
return (
<Stack key={i} gap={4}>
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={600}
px="sm"
mb={4}
style={{ letterSpacing: '0.08em' }}
>
{section.title()}
</Text>
{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 (
<NavLink
key={item.to}
component={Link}
to={item.to}
label={item.label()}
active={active}
onClick={close}
/>
)
})}
</Stack>
)
})}
</Stack>
</AppShell.Section>
<AppShell.Section
pt="md"
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
>
<Text size="xs" c="dimmed">{m.common__brand__tagline()}</Text>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
</PageTitleContext.Provider>
)
}

View File

@@ -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<unknown>
}) {
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 (
<Stack
gap="xs"
mt={36}
data-testid={actionBusy ? 'booking-actions-pending' : 'booking-actions-ready'}
aria-busy={actionBusy}
>
<Group gap="xs">
{status === 'enquiry' && (
<ConfirmButton
size="xs"
color="plum"
data-testid="action-approve-enquiry"
loading={setStatus.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'reserved' }))}
>
{m.common__pages__admin_booking__status__actions__approve_enquiry()}
</ConfirmButton>
)}
{status === 'reserved' && !contractSentAt && (
<ConfirmButton
size="xs"
variant="light"
data-testid="action-send-contract"
loading={triggerContract.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => triggerContract.mutate({ bookingId }))}
>
{m.common__pages__admin_booking__status__actions__send_contract_now()}
</ConfirmButton>
)}
{status === 'reserved' && (
<ConfirmButton
size="xs"
variant="light"
color="plum"
data-testid="action-create-form-link"
loading={formLink.isPending}
onConfirm={() => formLink.mutate({ bookingId })}
>
{m.common__pages__admin_booking__status__actions__create_form_link()}
</ConfirmButton>
)}
{status === 'reserved' && contractSentAt && !contractResponse && (
<>
<ConfirmButton
size="xs"
color="plum"
data-testid="action-contract-approved"
loading={contractResp.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => contractResp.mutate({ bookingId, response: 'approved' }))}
>
{m.common__pages__admin_booking__status__actions__record_contract_approved()}
</ConfirmButton>
<ConfirmButton
size="xs"
variant="light"
color="red"
data-testid="action-contract-declined"
loading={contractResp.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => contractResp.mutate({ bookingId, response: 'declined' }))}
>
{m.common__pages__admin_booking__status__actions__record_contract_declined()}
</ConfirmButton>
</>
)}
{status === 'reserved' && contractResponse === 'approved' && !depositPaid && (
<ConfirmButton
size="xs"
color="plum"
data-testid="action-deposit-received"
loading={deposit.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => deposit.mutate({ bookingId }))}
>
{m.common__pages__admin_booking__status__actions__record_deposit_received()}
</ConfirmButton>
)}
{status === 'confirmed' && (
<ConfirmButton
size="xs"
variant="light"
data-testid="action-mark-ended"
loading={setStatus.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'ended' }))}
>
{m.common__pages__admin_booking__status__actions__mark_ended()}
</ConfirmButton>
)}
{status !== 'ended' && status !== 'cancelled' && (
<ConfirmButton
size="xs"
variant="outline"
color="red"
data-testid="action-cancel-booking"
loading={setStatus.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'cancelled' }))}
>
{m.common__pages__admin_booking__status__actions__cancel_booking()}
</ConfirmButton>
)}
</Group>
{formLink.data && (
<Alert color="plum" variant="light" data-testid="form-link-result">
{asI18n(`${m.common__pages__admin_booking__status__form_link__label()} `)}
<Anchor href={formLink.data.url} target="_blank" rel="noreferrer" size="sm">
{asI18n(`${formLink.data.url}`)}
</Anchor>
</Alert>
)}
</Stack>
)
}

View File

@@ -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 (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" fw={500}>
{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 })}
</Text>
{deposit?.paidOn ? null : (
<Text size="xs" c="dimmed">
{m.common__pages__admin_booking__status__due_on({ date: fmtDate(dueDate, lng) })}
</Text>
)}
</Group>
{!deposit?.paidOn && (
<Progress value={pct} color={color} size="sm" radius="xl" />
)}
</Stack>
)
}
// ─── 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 (
<Stack gap="md">
{/* Flagged alert */}
{booking.flaggedAt && booking.flagReason && (
<Alert color="red" icon={<AlertTriangle size={16} />} title={m.common__pages__admin_booking__status__flagged()}>
{booking.flagReason in flagReasonLabels
? flagReasonLabels[booking.flagReason as keyof typeof flagReasonLabels]()
: asI18n(booking.flagReason)}
</Alert>
)}
{/* ── Horizontal stepper ── */}
<Stepper
active={activeStep}
color={booking.status === 'cancelled' ? 'red' : 'plum'}
size="xs"
styles={{ stepLabel: { fontSize: 'var(--mantine-font-size-xs)' }, stepDescription: { fontSize: 'var(--mantine-font-size-xs)' } }}
>
{steps.map((step) => (
<Stepper.Step
key={step.key}
label={step.label}
description={asI18n(step.description ?? 'N/A')}
/>
))}
{booking.status === 'cancelled' && (
<Stepper.Step
key="cancelled"
color="red"
label={m.common__pages__admin_booking__status__milestones__cancelled()}
description={booking.cancelledAt ? asI18n(fmtDate(booking.cancelledAt, lng)) : undefined}
/>
)}
</Stepper>
{/* Deposit progress bar (when contract sent but deposit not yet paid) */}
{booking.contractSentAt && !deposit?.paidOn && (
<DepositProgress contractSentAt={booking.contractSentAt} deposit={deposit} lng={lng} />
)}
{/* ── Next action alert (informational; controls live in BookingActions) ── */}
{action && (
<Alert
color={
action.kind === 'flagged' || action.kind === 'contract_declined' ? 'red'
: action.kind === 'awaiting_deposit' ? 'orange'
: 'blue'
}
icon={<Clock size={16} />}
>
{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 && (
<Text size="xs" mt={2}>
{m.common__pages__admin_booking__status__due_on({ date: fmtDate(action.dueOn, lng) })}
</Text>
)}
</>
)}
{action.kind === 'contract_declined' && flagReasonLabels.contract_declined()}
</Alert>
)}
</Stack>
)
}

View File

@@ -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 <img src={src} alt="Seminarhof Drawehn" width={size} height={size} />
}
export function BrandHeader({ variant = 'plum' }: { variant?: Variant }) {
useLocale()
const color = variant === 'white' ? 'white' : 'var(--brand-plum)'
return (
<Group gap="md" align="center" wrap="nowrap">
<BrandMark variant={variant} size={56} />
<Stack gap={2}>
<Title order={2} c={color} style={{ letterSpacing: '0.02em' }}>
{m.common__brand__name()}
</Title>
<Text size="sm" c={variant === 'white' ? 'white' : 'dimmed'} fs="italic">
{m.common__brand__tagline()}
</Text>
</Stack>
</Group>
)
}

View File

@@ -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<ReturnType<typeof setTimeout> | 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 (
<Button
{...rest}
key={armed ? 'armed' : 'idle'}
data-testid={armed && testId ? `${testId}-confirm` : testId}
data-confirm-state={armed ? 'armed' : 'idle'}
color={armed ? confirmColor : color}
variant={armed ? 'filled' : variant}
onClick={handleClick}
>
{armed
? confirmLabel ?? m.common__pages__admin_booking__status__actions__confirm()
: children}
</Button>
)
}

View File

@@ -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 (
<Group gap="xs" wrap="nowrap" align="center" data-testid="date-bar">
<Tooltip label={m.common__pages__staff__prev_day()}>
<ActionIcon
variant="default"
size="lg"
onClick={() => onChange(shiftDate(date, -1))}
aria-label={m.common__pages__staff__prev_day()}
data-testid="date-bar-prev"
>
</ActionIcon>
</Tooltip>
<TextInput
type="date"
size="sm"
value={date}
onChange={(e) => {
const v = e.currentTarget.value
if (v) onChange(v)
}}
data-testid="date-bar-input"
styles={{ input: { minWidth: 160 } }}
/>
<Tooltip label={m.common__pages__staff__next_day()}>
<ActionIcon
variant="default"
size="lg"
onClick={() => onChange(shiftDate(date, 1))}
aria-label={m.common__pages__staff__next_day()}
data-testid="date-bar-next"
>
</ActionIcon>
</Tooltip>
<Button
size="xs"
variant={isToday ? 'filled' : 'light'}
color="plum"
onClick={() => onChange(todayIso())}
disabled={isToday}
data-testid="date-bar-today"
>
{m.common__pages__staff__today()}
</Button>
</Group>
)
}

View File

@@ -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 (
<Card withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
<Flex>
<Box w={{ base: 120, sm: 220 }} style={{ flexShrink: 0 }}>
{coverImageUrl ? (
<Image src={coverImageUrl} alt={eventName} h="100%" w="100%" fit="cover" />
) : (
<Flex h="100%" mih={140} align="center" justify="center" bg="gray.1">
<ImageOff size={28} color="var(--mantine-color-gray-5)" />
</Flex>
)}
</Box>
<Stack gap="xs" p="md" style={{ flexGrow: 1 }}>
<Title order={4}>{asI18n(`${eventName}`)}</Title>
<Text size="sm" fw={500}>
{asI18n(`${formatDateRange(startDate, endDate, getLocale())}`)}
</Text>
{clientName && (
<Text size="sm" c="dimmed">
{asI18n(`${clientName}`)}
</Text>
)}
{(eventOutline || description) && (
<Text size="sm" c="dimmed" lineClamp={3}>
{asI18n(`${eventOutline || description}`)}
</Text>
)}
{organiserWebsite && (
<Button
component="a"
href={organiserWebsite}
target="_blank"
rel="noopener noreferrer"
size="xs"
variant="light"
mt="auto"
w="fit-content"
>
{m.common__pages__events__more_info()}
</Button>
)}
</Stack>
</Flex>
</Card>
)
}

View File

@@ -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 <Box mih="100vh" />
}
if (data) {
return <AppLayout user={data}>{children}</AppLayout>
}
return (
<Box mih="100vh" style={{ display: 'flex', flexDirection: 'column', background: 'var(--brand-bg)' }}>
<SeminarhofHeader />
<Box style={{ flex: 1 }}>{children}</Box>
<SeminarhofFooter />
</Box>
)
}

View File

@@ -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<Kind>('overnight')
const [newAllergyByPid, setNewAllergyByPid] = useState<Record<string, string>>({})
const [allergyOpenByPid, setAllergyOpenByPid] = useState<Record<string, boolean>>({})
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 (
<Stack gap="md">
{/* Inline compact add-participant bar -------------------------------- */}
<Paper
withBorder
radius="md"
p="sm"
data-testid="add-participant-form"
style={{ background: '#FBF8FA', borderColor: '#E5DDE3' }}
>
<Group gap="sm" wrap="nowrap" align="center">
<Text
size="xs"
tt="uppercase"
fw={600}
c="dimmed"
style={{ letterSpacing: '0.08em', whiteSpace: 'nowrap' }}
>
{m.common__pages__booking__participants__add_modal__title()}
</Text>
<TextInput
size="sm"
data-testid="new-participant-name"
placeholder={m.common__pages__booking__participants__add_modal__name()}
value={newName}
onChange={(e) => 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()}
/>
<Select
size="sm"
data-testid="new-participant-kind"
value={newKind}
onChange={(v) => setNewKind(((v as Kind) ?? 'overnight'))}
data={[
{
value: 'overnight',
label: kindLabels.overnight(),
},
{
value: 'day_guest',
label: kindLabels.day_guest(),
},
]}
allowDeselect={false}
style={{ width: 160 }}
aria-label={m.common__pages__booking__participants__add_modal__kind()}
/>
<Button
size="sm"
data-testid="add-participant-submit"
disabled={!newName.trim() || isAddingParticipant}
loading={isAddingParticipant}
onClick={submitAdd}
color="plum"
>
{m.common__pages__booking__participants__add_modal__add()}
</Button>
</Group>
</Paper>
{participants.length === 0 ? (
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__booking__participants__empty()}
</Text>
) : (
<Stack gap="lg">
{groups.map((g) => (
<Stack key={g.kind} gap="xs">
<Group justify="space-between" align="baseline">
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={600}
style={{ letterSpacing: '0.08em' }}
>
{kindLabels[g.kind]()}
</Text>
<Text size="xs" c="dimmed">
{m.common__pages__booking__participants__total_participants({
count: g.items.length,
})}
</Text>
</Group>
<Stack gap="xs">
{g.items.map((p) => (
<ParticipantRow
key={p.participantId}
p={p}
dietaryOptions={dietaryOptions}
onDietChange={(v) =>
updateParticipant({
participantId: p.participantId,
dietaryTag: v,
})
}
onRemove={() => {
if (
window.confirm(
m.common__pages__booking__participants__remove_confirm({
name: p.name,
}),
)
) {
removeParticipant({ participantId: p.participantId })
}
}}
allergyDraft={newAllergyByPid[p.participantId] ?? ''}
onAllergyDraftChange={(v) =>
setNewAllergyByPid((m) => ({
...m,
[p.participantId]: v,
}))
}
allergyEditorOpen={allergyOpenByPid[p.participantId] ?? true}
onToggleAllergyEditor={() =>
setAllergyOpenByPid((m) => ({
...m,
[p.participantId]: !m[p.participantId],
}))
}
onAddAllergy={() => {
const val = (newAllergyByPid[p.participantId] ?? '').trim()
if (!val) return
addAllergy(
{
participantId: p.participantId,
allergen: val,
severity: 'standard',
},
{
onSuccess: () =>
setNewAllergyByPid((m) => ({
...m,
[p.participantId]: '',
})),
},
)
}}
onRemoveAllergy={(allergen) =>
removeAllergy({
participantId: p.participantId,
allergen,
})
}
/>
))}
</Stack>
</Stack>
))}
</Stack>
)}
</Stack>
)
}
function ParticipantRow({
p,
dietaryOptions,
onDietChange,
onRemove,
allergyDraft,
onAllergyDraftChange,
allergyEditorOpen,
onToggleAllergyEditor,
onAddAllergy,
onRemoveAllergy,
}: {
p: Participant
dietaryOptions: { value: string; label: string }[]
onDietChange: (v: DietaryTag | null) => void
onRemove: () => void
allergyDraft: string
onAllergyDraftChange: (v: string) => void
allergyEditorOpen: boolean
onToggleAllergyEditor: () => void
onAddAllergy: () => void
onRemoveAllergy: (allergen: string) => void
}) {
useLocale()
const tint = tintFor(p.participantId)
return (
<Paper
withBorder
radius="md"
p="md"
data-testid={`participant-row-${p.participantId}`}
style={{ borderColor: '#E5DDE3' }}
>
<Stack gap="sm">
<Box
style={{
display: 'grid',
gridTemplateColumns:
'auto minmax(160px, 1.4fr) minmax(140px, 1fr) auto auto',
gap: 'var(--mantine-spacing-md)',
alignItems: 'center',
}}
>
{/* Avatar */}
<Box
style={{
width: 36,
height: 36,
borderRadius: 18,
background: tint,
color: '#fff',
fontSize: 13,
fontWeight: 700,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{initials(p.name) || '·'}
</Box>
{/* Name + room */}
<Stack gap={2}>
<Text fw={600} size="sm" style={{ lineHeight: 1.2 }}>
{asI18n(`${p.name}`)}
</Text>
{p.roomNumber != null ? (
<Text size="xs" c="dimmed">
{asI18n(`${m.common__pages__booking__participants__cols__room()} #${p.roomNumber}`)}
</Text>
) : (
<Text size="xs" c="dimmed" fs="italic">
{asI18n('—')}
</Text>
)}
</Stack>
{/* Diet pill (select) */}
<Select
size="xs"
data-testid={`participant-diet-${p.participantId}`}
aria-label={m.common__pages__booking__participants__cols__diet()}
value={p.dietaryTag}
onChange={(v) => onDietChange((v as DietaryTag | null) ?? null)}
data={dietaryOptions}
clearable
placeholder={dietaryLabels.unknown()}
/>
{/* Allergy summary chip */}
<AllergySummary count={p.allergies.length} />
{/* Delete */}
<Tooltip label={m.common__actions__delete()} withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="md"
data-testid={`remove-participant-${p.participantId}`}
onClick={onRemove}
aria-label={m.common__actions__delete()}
>
<span aria-hidden style={{ fontSize: 16, lineHeight: 1 }}>×</span>
</ActionIcon>
</Tooltip>
</Box>
{/* Allergy chips + editor */}
<Box style={{ paddingLeft: 52 }}>
{p.allergies.length === 0 && !allergyEditorOpen ? (
<Button
size="compact-xs"
variant="subtle"
color="plum"
onClick={onToggleAllergyEditor}
styles={{ root: { paddingLeft: 0, paddingRight: 0 } }}
>
{m.common__pages__booking__participants__allergy_add()}
</Button>
) : (
<Group gap="xs" wrap="wrap" align="center">
{p.allergies.map((a) => (
<AllergyChip
key={a.allergen}
allergy={a}
onRemove={() => onRemoveAllergy(a.allergen)}
testId={`remove-allergy-${p.participantId}-${a.allergen}`}
removeLabel={m.common__pages__booking__participants__allergy_remove_aria({
allergen: a.allergen,
})}
/>
))}
{!allergyEditorOpen ? (
<Button
size="compact-xs"
variant="subtle"
color="plum"
onClick={onToggleAllergyEditor}
>
{m.common__pages__booking__participants__allergy_add()}
</Button>
) : null}
</Group>
)}
{allergyEditorOpen && (
<Group gap="xs" mt="xs" wrap="nowrap">
<TextInput
size="xs"
placeholder={m.common__pages__admin_booking__allergies__add_placeholder()}
data-testid={`new-allergy-${p.participantId}`}
value={allergyDraft}
onChange={(e) => onAllergyDraftChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onAddAllergy()
}}
style={{ flex: 1, maxWidth: 280 }}
/>
<Button
size="compact-xs"
color="plum"
data-testid={`add-allergy-${p.participantId}`}
disabled={!allergyDraft.trim()}
onClick={onAddAllergy}
>
{m.common__pages__admin_booking__allergies__add()}
</Button>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={onToggleAllergyEditor}
>
{m.common__actions__cancel({ defaultValue: 'Cancel' })}
</Button>
</Group>
)}
</Box>
</Stack>
</Paper>
)
}
function AllergySummary({ count }: { count: number }) {
useLocale()
if (count === 0) {
return (
<Text size="xs" c="dimmed">
{m.common__pages__booking__participants__allergy_none()}
</Text>
)
}
return (
<Badge
variant="light"
color="peach"
size="sm"
styles={{
root: {
background: '#FFE7CF',
color: '#7A4A18',
textTransform: 'none',
fontWeight: 600,
},
}}
>
{m.common__pages__booking__participants__allergy_count({ count })}
</Badge>
)
}
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 (
<Box
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: style.bg,
border: `1px solid ${style.border}`,
color: style.fg,
borderRadius: 999,
padding: '2px 4px 2px 10px',
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
}}
>
<span>{allergy.allergen in allergenLabels
? allergenLabels[allergy.allergen as keyof typeof allergenLabels]()
: asI18n(allergy.allergen)}</span>
{sevLabel ? (
<span
style={{
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: '0.06em',
opacity: 0.8,
}}
>
· {sevLabel}
</span>
) : null}
<ActionIcon
size="xs"
variant="transparent"
data-testid={testId}
onClick={onRemove}
aria-label={removeLabel}
style={{ color: style.fg }}
>
<span aria-hidden style={{ fontSize: 12, lineHeight: 1 }}>×</span>
</ActionIcon>
</Box>
)
}

View File

@@ -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 <Box mih="100vh" />
}
if (data) {
return <AppLayout user={data}>{children}</AppLayout>
}
return (
<Box mih="100vh" style={{ background: 'var(--brand-bg)' }}>
<Box
component="header"
style={{
background: '#ffffff',
borderBottom: '1px solid var(--mantine-color-gray-2)',
height: 72,
display: 'flex',
alignItems: 'center',
}}
>
<Container size="xl" w="100%">
<Group justify="space-between" wrap="nowrap">
<Link to="/" style={{ textDecoration: 'none' }}>
<Group gap="sm" wrap="nowrap" align="center">
<BrandMark size={32} />
<Title
order={5}
c="var(--brand-plum-dark)"
fw={600}
style={{ letterSpacing: '0.01em', lineHeight: 1 }}
>
{m.common__brand__name()}
</Title>
</Group>
</Link>
<Button component={Link} to="/login" variant="light" color="plum" size="sm">
{m.common__nav__login()}
</Button>
</Group>
</Container>
</Box>
<Box>{children}</Box>
</Box>
)
}

View File

@@ -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<string, Room[]>()
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<string | null>(null)
const groups = useMemo(() => groupRooms(rooms), [rooms])
const unassigned = participants.filter((p) => !p.roomId)
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" data-testid="rooms-tab">
{/* Left pane: room list ----------------------------------------- */}
<Paper withBorder radius="md" p={0}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 64 }}>{m.common__pages__admin_booking__rooms__cols__room()}</Table.Th>
<Table.Th>{m.common__pages__admin_booking__rooms__cols__guests()}</Table.Th>
<Table.Th style={{ width: 200 }}>{m.common__pages__admin_booking__rooms__cols__assign()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.map((g) => (
<RoomGroupRows
key={`${g.building}-${g.floor}`}
group={g}
hovered={hovered}
onHover={setHovered}
unassigned={unassigned}
assign={assign}
unassign={unassign}
bookingId={bookingId}
/>
))}
</Table.Tbody>
</Table>
</Paper>
{/* Right pane: SVG floorplan -------------------------------------- */}
<Stack gap="md">
{groups.map((g) => (
<Paper key={`${g.building}-${g.floor}`} withBorder radius="md" p="md">
<Stack gap="sm">
<Text size="xs" tt="uppercase" c="dimmed" fw={600} style={{ letterSpacing: '0.08em' }}>
{asI18n(`${buildingLabel(g.building, g.floor)}`)}
</Text>
<FloorPlan
rooms={g.rooms}
hovered={hovered}
onHover={setHovered}
/>
</Stack>
</Paper>
))}
</Stack>
</SimpleGrid>
)
}
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 (
<>
<Table.Tr>
<Table.Td colSpan={3} style={{ background: '#FBF8FA' }}>
<Text size="xs" tt="uppercase" c="dimmed" fw={600} style={{ letterSpacing: '0.08em' }}>
{asI18n(`${buildingLabel(group.building, group.floor)}`)}
</Text>
</Table.Td>
</Table.Tr>
{group.rooms.map((r) => (
<Table.Tr
key={r.roomId}
onMouseEnter={() => onHover(r.roomId)}
onMouseLeave={() => onHover(null)}
style={{
background: hovered === r.roomId ? 'rgba(255, 206, 0, 0.12)' : undefined,
cursor: 'default',
}}
>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text fw={700}>{r.number}</Text>
<Badge size="xs" variant="light" color="gray">
{asI18n(`${r.roomType}`)}
</Badge>
</Group>
<Text size="xs" c="dimmed">{asI18n(`${r.beds} ${m.common__pages__admin_booking__rooms__beds()}`)}</Text>
</Table.Td>
<Table.Td>
{r.occupiedByOtherBooking ? (
<Text size="xs" c="dimmed" fs="italic">
{m.common__pages__admin_booking__rooms__occupied_other()}
</Text>
) : r.assignedParticipants.length === 0 ? (
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
) : (
<Stack gap={4}>
{r.assignedParticipants.map((p) => (
<Group key={p.participantId} gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 18,
height: 18,
borderRadius: 9,
background: tintFor(p.participantId),
color: '#fff',
fontSize: 9,
fontWeight: 700,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{initials(p.name)}
</Box>
<Text size="sm">{asI18n(`${p.name}`)}</Text>
</Group>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={() => unassign({ participantId: p.participantId })}
>
{m.common__pages__admin_booking__rooms__unassign()}
</Button>
</Group>
))}
</Stack>
)}
</Table.Td>
<Table.Td>
{!r.occupiedByOtherBooking && unassigned.length > 0 && (
<Select
size="xs"
data-testid={`assign-room-${r.roomId}`}
placeholder={m.common__pages__admin_booking__rooms__assign_to_participant()}
data={unassigned.map((p) => ({ value: p.participantId, label: p.name }))}
value={null}
onChange={(v) =>
v && assign({ bookingId, participantId: v, roomId: r.roomId })
}
clearable={false}
/>
)}
</Table.Td>
</Table.Tr>
))}
</>
)
}
// ─── SVG floor plan ─────────────────────────────────────────────────────
// Schematic only — not architecturally accurate. Rooms are laid out as a
// grid with their real number; layout is responsive to the room count per
// floor section.
function FloorPlan({
rooms,
hovered,
onHover,
}: {
rooms: Room[]
hovered: string | null
onHover: (id: string | null) => void
}) {
const cols = Math.min(6, Math.max(2, Math.ceil(Math.sqrt(rooms.length * 1.6))))
const rows = Math.ceil(rooms.length / cols)
const W = 480
const cellGap = 6
const padX = 12
const padY = 12
const cellW = (W - padX * 2 - cellGap * (cols - 1)) / cols
const cellH = 84
const H = padY * 2 + rows * cellH + (rows - 1) * cellGap
return (
<Box style={{ width: '100%' }}>
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
style={{ display: 'block', borderRadius: 8 }}
>
<rect x="0" y="0" width={W} height={H} fill="#ffffff" stroke="#E5DDE3" rx="8" />
{rooms.map((r, i) => {
const c = i % cols
const row = Math.floor(i / cols)
const x = padX + c * (cellW + cellGap)
const y = padY + row * (cellH + cellGap)
return (
<RoomCell
key={r.roomId}
room={r}
x={x}
y={y}
w={cellW}
h={cellH}
hovered={hovered === r.roomId}
onHover={onHover}
/>
)
})}
</svg>
</Box>
)
}
function RoomCell({
room: r,
x,
y,
w,
h,
hovered,
onHover,
}: {
room: Room
x: number
y: number
w: number
h: number
hovered: boolean
onHover: (id: string | null) => void
}) {
const isOther = r.occupiedByOtherBooking
const count = r.assignedParticipants.length
const isFull = count >= r.beds && r.beds > 0
const isPartial = count > 0 && !isFull
const fill = isOther
? PALETTE.occupiedOther
: isFull
? PALETTE.full
: isPartial
? PALETTE.filled
: PALETTE.empty
const stroke = hovered
? PALETTE.hover
: isOther
? PALETTE.occupiedOtherText
: count > 0
? PALETTE.filledBorder
: PALETTE.emptyBorder
const numberColor = isFull ? PALETTE.fullText : '#4E3149'
// Avatar dots up to 4 per room; rest collapses to "+N".
const visible = r.assignedParticipants.slice(0, 4)
const overflow = count - visible.length
const avatarR = 11
const avatarGap = 2
const totalW = visible.length * (avatarR * 2) + Math.max(0, visible.length - 1) * avatarGap
const startX = x + w / 2 - totalW / 2 + avatarR
const avatarY = y + h - avatarR - 8
return (
<g
onMouseEnter={() => onHover(r.roomId)}
onMouseLeave={() => onHover(null)}
style={{ cursor: 'default' }}
>
<rect
x={x}
y={y}
width={w}
height={h}
fill={fill}
stroke={stroke}
strokeWidth={hovered ? 2 : 1}
rx={6}
/>
<text
x={x + 8}
y={y + 18}
fontSize="12"
fontWeight="700"
fill={numberColor}
fontFamily="Open Sans, system-ui, sans-serif"
>
{r.number}
</text>
<text
x={x + w - 8}
y={y + 18}
fontSize="9"
textAnchor="end"
fill={isFull ? 'rgba(255,255,255,0.85)' : '#9B9499'}
fontFamily="Open Sans, system-ui, sans-serif"
>
{r.roomType}
</text>
{isOther ? (
<text
x={x + w / 2}
y={y + h / 2 + 4}
fontSize="10"
textAnchor="middle"
fill={PALETTE.occupiedOtherText}
fontStyle="italic"
fontFamily="Open Sans, system-ui, sans-serif"
>
</text>
) : (
<>
{visible.map((p, idx) => {
const cx = startX + idx * (avatarR * 2 + avatarGap)
return (
<g key={p.participantId}>
<title>{p.name}</title>
<circle cx={cx} cy={avatarY} r={avatarR} fill={tintFor(p.participantId)} stroke="#fff" strokeWidth={1.5} />
<text
x={cx}
y={avatarY + 3}
fontSize="9"
textAnchor="middle"
fill="#fff"
fontWeight="700"
fontFamily="Open Sans, system-ui, sans-serif"
>
{initials(p.name)}
</text>
</g>
)
})}
{overflow > 0 && (
<g>
<circle
cx={startX + visible.length * (avatarR * 2 + avatarGap)}
cy={avatarY}
r={avatarR}
fill="#fff"
stroke={PALETTE.filledBorder}
strokeWidth={1}
/>
<text
x={startX + visible.length * (avatarR * 2 + avatarGap)}
y={avatarY + 3}
fontSize="9"
textAnchor="middle"
fill={PALETTE.filledBorder}
fontWeight="700"
fontFamily="Open Sans, system-ui, sans-serif"
>
+{overflow}
</text>
</g>
)}
</>
)}
<text
x={x + 8}
y={y + 34}
fontSize="9"
fill={isFull ? 'rgba(255,255,255,0.85)' : '#9B9499'}
fontFamily="Open Sans, system-ui, sans-serif"
>
{count}/{r.beds}
</text>
</g>
)
}

View File

@@ -0,0 +1,469 @@
import { Link } from '@tanstack/react-router'
import {
Box,
Burger,
Container,
Drawer,
Group,
Menu,
SimpleGrid,
Stack,
Text,
UnstyledButton,
} from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { m } from '@/i18n/messages'
import { useLocale, getLocale, type Locale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { ChevronDown, Facebook, Instagram, Mail, Phone } from 'lucide-react'
import { BrandMark } from './Brand'
/**
* Public-site chrome that mirrors seminarhof-drawehn.de (header + footer) so the
* app's anonymous public pages (availability / events / enquiry) feel like one
* cohesive website with the marketing site. Marketing destinations link out to
* the live WordPress site; the app's own pages (events) stay internal.
*
* Nav labels are kept in German on purpose — they're the marketing site's
* canonical labels (brand chrome), not app content. The DE/EN switcher toggles
* the app's i18n language, which drives the page body, not these labels.
*
* Social/contact glyphs use lucide-react (the app's icon set) — the closest
* equivalent to the live site's Font Awesome icons, not pixel-identical.
*/
const SITE = 'https://seminarhof-drawehn.de'
const PHONE_HREF = 'tel:+491724695132'
const PHONE_LABEL = '+49 172 4695132'
const EMAIL = 'info@seminarhof-drawehn.de'
const TAGLINE = 'Retreats · Fortbildungen · Workation'
type NavChild = { label: string; href: string; internal?: boolean }
type NavEntry = { label: string; href: string; internal?: boolean; children?: NavChild[] }
const ext = (path: string) => `${SITE}${path}`
const LEFT_NAV: NavEntry[] = [
{
label: 'Unser Seminarhof',
href: ext('/seminarhof/'),
children: [
{ label: 'Seminarräume', href: ext('/seminarhof/') },
{ label: 'Verpflegung', href: ext('/verpflegung/') },
{ label: 'Unterkunft', href: ext('/unterkunft/') },
{ label: 'Holistic Bodywork', href: ext('/holistic-bodywork/') },
],
},
{ label: 'Anreise', href: ext('/anreise/') },
{ label: 'Galerie', href: ext('/galerie/') },
{ label: 'FAQ', href: ext('/faq/') },
]
const RIGHT_NAV: NavEntry[] = [
{ label: 'Veranstaltungen', href: '/events', internal: true },
{
label: 'Kontakt',
href: ext('/kontakt/'),
children: [
{ label: 'Kontakt', href: ext('/kontakt/') },
{ label: 'Über uns', href: ext('/ueber-uns/') },
{ label: 'Gästebuch', href: ext('/gaestebuch/') },
{ label: 'Jobs', href: ext('/jobs/') },
],
},
{ label: 'Downloads', href: ext('/downloads/') },
]
const FOOTER_COLUMNS: { heading: string; links: NavChild[] }[] = [
{
heading: 'Unser Seminarhof',
links: [
{ label: 'Seminarräume', href: ext('/seminarhof/') },
{ label: 'Verpflegung', href: ext('/verpflegung/') },
{ label: 'Unterkunft', href: ext('/unterkunft/') },
],
},
{
heading: 'Angebote',
links: [
{ label: 'Veranstaltungen', href: '/events', internal: true },
{ label: 'Holistic Bodywork', href: ext('/holistic-bodywork/') },
{ label: 'Jobs', href: ext('/jobs/') },
],
},
{
heading: 'Info',
links: [
{ label: 'FAQ', href: ext('/faq/') },
{ label: 'Kontakt', href: ext('/kontakt/') },
{ label: 'Downloads', href: ext('/downloads/') },
],
},
{
heading: 'Rechtliches',
links: [
{ label: 'Impressum', href: ext('/impressum/') },
{ label: 'Datenschutz', href: ext('/datenschutz/') },
],
},
]
const NAV_ITEM_STYLE: React.CSSProperties = {
color: '#ffffff',
fontFamily: "'Open Sans', sans-serif",
fontSize: '14.4px',
fontWeight: 400,
textDecoration: 'none',
lineHeight: 1,
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
}
/** A single top-level nav entry: plain external/internal link, or a hover menu. */
function NavTopItem({ entry, onNavigate }: { entry: NavEntry; onNavigate?: () => void }) {
if (entry.children) {
return (
<Menu trigger="hover" position="bottom-start" offset={14} withinPortal shadow="md" radius="sm">
<Menu.Target>
<Group gap={4} wrap="nowrap" style={{ ...NAV_ITEM_STYLE }}>
<span>{entry.label}</span>
<ChevronDown size={14} strokeWidth={2.5} />
</Group>
</Menu.Target>
<Menu.Dropdown
style={{ background: 'var(--brand-plum)', border: 'none', padding: 4 }}
>
{entry.children.map((c) => {
const itemStyle = { color: '#ffffff', fontFamily: "'Open Sans', sans-serif", fontSize: '14px' }
return c.internal ? (
<Menu.Item key={c.label} component={Link} to={c.href} onClick={onNavigate} style={itemStyle}>
{asI18n(`${c.label}`)}
</Menu.Item>
) : (
<Menu.Item key={c.label} component="a" href={c.href} onClick={onNavigate} style={itemStyle}>
{asI18n(`${c.label}`)}
</Menu.Item>
)
})}
</Menu.Dropdown>
</Menu>
)
}
if (entry.internal) {
return (
<Link to={entry.href} onClick={onNavigate} style={NAV_ITEM_STYLE}>
{entry.label}
</Link>
)
}
return (
<a href={entry.href} onClick={onNavigate} style={NAV_ITEM_STYLE}>
{entry.label}
</a>
)
}
function LanguageSwitcher() {
const { setLocale } = useLocale()
const current = (getLocale() || 'de').slice(0, 2).toUpperCase()
return (
<Menu trigger="hover" position="bottom-end" offset={14} withinPortal shadow="md" radius="sm">
<Menu.Target>
<Group gap={4} wrap="nowrap" style={NAV_ITEM_STYLE}>
<span>{current}</span>
<ChevronDown size={14} strokeWidth={2.5} />
</Group>
</Menu.Target>
<Menu.Dropdown style={{ background: 'var(--brand-plum)', border: 'none', padding: 4 }}>
{['de', 'en'].map((lng) => (
<Menu.Item
key={lng}
onClick={() => setLocale(lng as Locale)}
style={{ color: '#ffffff', fontFamily: "'Open Sans', sans-serif", fontSize: '14px' }}
>
{asI18n(`${lng.toUpperCase()}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)
}
function SocialIcons({ color = '#ffffff', size = 18 }: { color?: string; size?: number }) {
const link = { color, display: 'flex' } as React.CSSProperties
return (
<Group gap="md" wrap="nowrap">
<a href={`mailto:${EMAIL}`} aria-label="E-Mail" style={link}>
<Mail size={size} />
</a>
<a href="https://www.facebook.com/seminarhofdrawehn" aria-label="Facebook" target="_blank" rel="noreferrer" style={link}>
<Facebook size={size} />
</a>
<a href="https://www.instagram.com/seminarhof_drawehn" aria-label="Instagram" target="_blank" rel="noreferrer" style={link}>
<Instagram size={size} />
</a>
</Group>
)
}
const BRAND_STYLE: React.CSSProperties = {
fontFamily: "'Nunito', sans-serif",
fontWeight: 500,
fontSize: '32px',
color: '#ffffff',
textDecoration: 'none',
lineHeight: 1,
whiteSpace: 'nowrap',
}
export function SeminarhofHeader() {
useLocale()
const [opened, { toggle, close }] = useDisclosure(false)
return (
// display:contents so the sticky nav row below isn't trapped in this short
// header's containing block — its sticky context becomes the full-height
// MarketingShell column, giving page-wide sticky behaviour.
<Box component="header" style={{ display: 'contents' }}>
{/* Top utility bar */}
<Box style={{ background: 'var(--brand-plum-dark)' }} py={6} visibleFrom="md">
<Container size="xl" w="100%">
<Group justify="space-between" wrap="nowrap">
<Text style={{ color: 'var(--brand-pink)', fontFamily: "'Open Sans', sans-serif", fontSize: 12 }}>
{asI18n(`${TAGLINE}`)}
</Text>
<Group gap="lg" wrap="nowrap" style={{ color: 'var(--brand-pink)' }}>
<a href={PHONE_HREF} style={{ color: 'var(--brand-pink)', display: 'flex', alignItems: 'center', gap: 6, fontFamily: "'Open Sans', sans-serif", fontSize: 12, textDecoration: 'none' }}>
<Phone size={13} /> {PHONE_LABEL}
</a>
<a href={`mailto:${EMAIL}`} style={{ color: 'var(--brand-pink)', display: 'flex', alignItems: 'center', gap: 6, fontFamily: "'Open Sans', sans-serif", fontSize: 12, textDecoration: 'none' }}>
<Mail size={13} /> {EMAIL}
</a>
<SocialIcons color="var(--brand-pink)" size={14} />
</Group>
</Group>
</Container>
</Box>
{/* Sticky nav row */}
<Box
style={{
background: 'rgba(136, 85, 127, 0.95)',
position: 'sticky',
top: 0,
zIndex: 100,
backdropFilter: 'blur(2px)',
}}
>
<Container size="xl" w="100%" style={{ position: 'relative' }}>
<Group h={74} justify="space-between" wrap="nowrap">
{/* Left group (desktop) */}
<Group gap="lg" wrap="nowrap" visibleFrom="md">
{LEFT_NAV.map((e) => (
<NavTopItem key={e.label} entry={e} />
))}
</Group>
{/* Mobile burger */}
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="md"
color="#ffffff"
size="sm"
aria-label="Menu"
/>
{/* Mobile brand — in-flow so it can't overlap the burger / switcher */}
<Box hiddenFrom="md" style={{ flex: 1, minWidth: 0, padding: '0 8px' }}>
<Link
to="/"
style={{
...BRAND_STYLE,
fontSize: 20,
display: 'block',
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
Seminarhof Drawehn
</Link>
</Box>
{/* Right group (desktop) */}
<Group gap="lg" wrap="nowrap" visibleFrom="md">
{RIGHT_NAV.map((e) => (
<NavTopItem key={e.label} entry={e} />
))}
<LanguageSwitcher />
</Group>
{/* Mobile: language switcher stays visible on the right */}
<Box hiddenFrom="md">
<LanguageSwitcher />
</Box>
</Group>
{/* Centered brand (desktop) — absolutely positioned so it stays
centred regardless of the left/right group widths. On mobile the
in-flow brand above is used instead, to avoid overlap. */}
<Box
visibleFrom="md"
style={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
height: 74,
display: 'flex',
alignItems: 'center',
pointerEvents: 'none',
}}
>
<Link to="/" style={{ ...BRAND_STYLE, pointerEvents: 'auto' }}>
Seminarhof Drawehn
</Link>
</Box>
</Container>
</Box>
{/* Mobile drawer */}
<Drawer
opened={opened}
onClose={close}
size="80%"
position="right"
title={m.common__brand__name()}
styles={{
header: { background: 'var(--brand-plum)' },
title: { color: '#ffffff', fontFamily: "'Nunito', sans-serif", fontWeight: 600 },
close: { color: '#ffffff' },
content: { background: 'var(--brand-plum)' },
body: { background: 'var(--brand-plum)' },
}}
>
<Stack gap="md" pt="md">
{[...LEFT_NAV, ...RIGHT_NAV].map((e) =>
e.children ? (
<Stack key={e.label} gap={6}>
<Text style={{ color: 'var(--brand-pink)', fontFamily: "'Open Sans', sans-serif", fontSize: 13, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{asI18n(`${e.label}`)}
</Text>
{e.children.map((c) => (
<DrawerLink key={c.label} child={c} onNavigate={close} indent />
))}
</Stack>
) : (
<DrawerLink key={e.label} child={e} onNavigate={close} />
)
)}
</Stack>
</Drawer>
</Box>
)
}
function DrawerLink({
child,
onNavigate,
indent,
}: {
child: NavChild
onNavigate: () => void
indent?: boolean
}) {
const style: React.CSSProperties = {
color: '#ffffff',
fontFamily: "'Open Sans', sans-serif",
fontSize: 16,
textDecoration: 'none',
paddingLeft: indent ? 12 : 0,
}
return child.internal ? (
<Link to={child.href} onClick={onNavigate} style={style}>
{child.label}
</Link>
) : (
<a href={child.href} onClick={onNavigate} style={style}>
{child.label}
</a>
)
}
export function SeminarhofFooter() {
useLocale()
const year = new Date().getFullYear()
const footerLinkStyle: React.CSSProperties = {
color: '#ffffff',
fontFamily: "'Open Sans', sans-serif",
fontSize: 15,
textDecoration: 'none',
}
return (
<Box component="footer" mt="auto">
{/* Link columns band */}
<Box style={{ background: 'var(--brand-plum)' }} py={48}>
<Container size="xl" w="100%">
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={40}>
{FOOTER_COLUMNS.map((col) => (
<Stack key={col.heading} gap="sm">
<Text
style={{
color: 'var(--brand-pink)',
fontFamily: "'Open Sans', sans-serif",
fontSize: 14.4,
fontWeight: 400,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{asI18n(`${col.heading}`)}
</Text>
{col.links.map((l) =>
l.internal ? (
<Link key={l.label} to={l.href} style={footerLinkStyle}>
{l.label}
</Link>
) : (
<a key={l.label} href={l.href} style={footerLinkStyle}>
{l.label}
</a>
)
)}
</Stack>
))}
</SimpleGrid>
</Container>
</Box>
{/* Copyright band */}
<Box style={{ background: 'var(--brand-plum-dark)' }} py={20}>
<Container size="xl" w="100%" style={{ position: 'relative' }}>
<Group justify="space-between" wrap="nowrap">
<Text style={{ color: '#ffffff', fontFamily: "'Nunito', sans-serif", fontSize: 22, fontWeight: 400 }}>
{asI18n(`© ${year} `)}{m.common__brand__name()}
</Text>
<SocialIcons color="#ffffff" size={20} />
</Group>
{/* Centered wheel logo */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
alignItems: 'center',
}}
visibleFrom="sm"
>
<BrandMark variant="white" size={56} />
</Box>
</Container>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,57 @@
import { Box, Group, Text } from '@pikku/mantine/core'
import { bookingStatus, invoiceStatus } from '@/i18n/i18n-enum.gen'
import { useLocale } from '@/i18n/config'
import {
BOOKING_STATUS,
INVOICE_STATUS,
type BookingStatus,
type InvoiceStatus,
} from '../lib/status'
type Variant =
| { kind: 'booking'; status: BookingStatus }
| { kind: 'invoice'; status: InvoiceStatus }
export function StatusPill(props: Variant & { size?: 'sm' | 'xs' }) {
useLocale()
const size = props.size ?? 'xs'
const palette =
props.kind === 'booking'
? BOOKING_STATUS[props.status]
: INVOICE_STATUS[props.status]
const label =
props.kind === 'booking'
? bookingStatus[props.status]()
: invoiceStatus[props.status]()
return (
<Group
gap={6}
wrap="nowrap"
style={{
background: palette.bg,
color: palette.fg,
padding: size === 'xs' ? '2px 8px' : '4px 10px',
borderRadius: 999,
fontSize: size === 'xs' ? 11 : 12,
fontWeight: 600,
letterSpacing: '0.02em',
whiteSpace: 'nowrap',
display: 'inline-flex',
lineHeight: 1.2,
}}
>
<Box
component="span"
style={{
width: 6,
height: 6,
borderRadius: 999,
background: palette.dot,
flexShrink: 0,
}}
/>
<Text component="span" inherit>{label}</Text>
</Group>
)
}

40
apps/app/src/direction.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Document direction (LTR / RTL).
*
* v1 is EN-only but every widget and page must render correctly under
* RTL — the plumbing is always active. Precedence:
*
* 1. `?dir=rtl` query param (dev override — test any route in RTL)
* 2. localStorage `fabric-dir` (user preference across reloads)
* 3. `document.documentElement.dir`
* 4. 'ltr' fallback
*
* Locale-bound direction (Arabic / Hebrew / Persian → RTL) is post-v1.
* Shape supports it — call `setDirection('rtl')` when a locale loads.
*/
export type Direction = 'ltr' | 'rtl'
const STORAGE_KEY = 'fabric-dir'
export function readInitialDirection(): Direction {
if (typeof window === 'undefined') return 'ltr'
const query = new URLSearchParams(window.location.search).get('dir')
if (query === 'rtl' || query === 'ltr') return query
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'rtl' || stored === 'ltr') return stored
const docDir = window.document.documentElement.dir
if (docDir === 'rtl' || docDir === 'ltr') return docDir
return 'ltr'
}
export function applyDirection(dir: Direction) {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, dir)
window.document.documentElement.dir = dir
}
}

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

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

View File

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

View File

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

18
apps/app/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createAuthClient } from 'better-auth/client'
import { apiUrl } from './env'
// Better Auth mounts at /api/auth (default basePath); the client appends the
// endpoint verbatim to baseURL, so the base must include /auth — otherwise it
// emits /api/sign-in/email and 404s on both sandbox and deploy.
export const authClient = createAuthClient({
baseURL: `${apiUrl()}/auth`,
})
export async function signIn(email: string, password: string): Promise<void> {
const { error } = await authClient.signIn.email({ email, password })
if (error) throw new Error('Invalid credentials')
}
export async function signOut(): Promise<void> {
await authClient.signOut()
}

24
apps/app/src/lib/dates.ts Normal file
View File

@@ -0,0 +1,24 @@
// Placeholder shown for missing dates (an enquiry may not have dates yet).
const NO_DATE = '—'
export function fmtDate(d: Date | null | undefined, locale: string): string {
if (!d) return NO_DATE
return d.toLocaleDateString(locale.startsWith('de') ? 'de-DE' : 'en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
export function fmtDateShort(d: Date | null | undefined, locale: string): string {
if (!d) return NO_DATE
return d.toLocaleDateString(locale.startsWith('de') ? 'de-DE' : 'en-GB', {
day: 'numeric',
month: 'short',
})
}
export function toDateInputStr(d: Date | null | undefined): string {
if (!d) return ''
return d.toISOString().slice(0, 10)
}

24
apps/app/src/lib/env.ts Normal file
View File

@@ -0,0 +1,24 @@
// Endpoints come from env, never hardcoded.
//
// In local dev VITE_API_URL is set at build time and Vite replaces the
// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is
// a runtime Worker binding — invisible to Vite at build time — so it's undefined
// in the bundle. We fall back to the current origin + /api, which is correct
// because the dispatcher routes every /api/* path to the API on the same host.
export function apiUrl(): string {
if (typeof window !== 'undefined' && (window as any).__E2E_API_URL) {
// E2E talks directly to the backend (no vite proxy). Better Auth is mounted
// at /api/auth there, while pikku routes live at the root — so the auth
// client must add the /api prefix that __E2E_API_URL (bare host) lacks.
return `${(window as any).__E2E_API_URL as string}/api`
}
if (import.meta.env.SSR) {
// SSR context: the auth client is only consumed in the browser. Return the
// build-time var when available or a placeholder so SSR never throws.
return import.meta.env.VITE_API_URL ?? '/__api'
}
// Client: build-time var (local dev) or derive from the current origin.
// Better Auth's client requires an absolute base URL, so never return a bare
// relative '/api' here — '/api/auth' throws "Invalid base URL".
return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`
}

View File

@@ -0,0 +1,35 @@
// Brand-aligned status palettes. Single source of truth for booking, invoice
// and room state colours. Each entry: bg (badge background), fg (text colour)
// and dot (8 px leading dot). Hex codes are checked for ≥4.5:1 on bg.
export type BookingStatus =
| 'enquiry'
| 'reserved'
| 'confirmed'
| 'ended'
| 'cancelled'
export const BOOKING_STATUS: Record<BookingStatus, { bg: string; fg: string; dot: string }> = {
enquiry: { bg: '#F7EEF5', fg: '#583651', dot: '#BC7AA6' },
reserved: { bg: '#FFEDDC', fg: '#8A4A1A', dot: '#FFBC7D' },
confirmed: { bg: '#E9D4E2', fg: '#4E3149', dot: '#88557F' },
ended: { bg: '#EDEDED', fg: '#4A4A4A', dot: '#989499' },
cancelled: { bg: '#F2F2F2', fg: '#6B6B6B', dot: '#B5B5B5' },
}
/** Valid transitions per status (client-side, for CTA buttons). Enforced server-side too. */
export const BOOKING_TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
enquiry: ['reserved', 'cancelled'],
reserved: ['confirmed', 'cancelled'],
confirmed: ['ended', 'cancelled'],
ended: [],
cancelled: [],
}
export type InvoiceStatus = 'outstanding' | 'paid' | 'cancelled'
export const INVOICE_STATUS: Record<InvoiceStatus, { bg: string; fg: string; dot: string }> = {
outstanding: { bg: '#FFF8E0', fg: '#806600', dot: '#FFCE00' },
paid: { bg: '#E9D4E2', fg: '#4E3149', dot: '#88557F' },
cancelled: { bg: '#F2F2F2', fg: '#6B6B6B', dot: '#B5B5B5' },
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MantineProvider, Box } from '@pikku/mantine/core'
import { PikkuProvider, createPikku } from '@pikku/react'
import { useLocale, getLocale } from '@/i18n/config'
import '@mantine/core/styles.css'
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
import { theme } from '../theme'
import '../theme.css'
import '../i18n/config'
import { readInitialDirection } from '../direction'
declare global {
interface Window {
__E2E_API_URL?: string
}
}
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Seminarhof Drawehn' },
],
}),
component: RootDocument,
})
function RootDocument() {
useLocale()
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: { retry: 0 },
},
}),
)
const runtimeApiUrl =
typeof window !== 'undefined' ? window.__E2E_API_URL : undefined
const [pikku] = useState(() =>
createPikku(PikkuFetch, PikkuRPC, {
transformDate: true,
serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
credentials: 'include',
}),
)
return (
<html
lang={(getLocale() ?? 'de').slice(0, 2)}
dir={readInitialDirection()}
data-app-hydrated="false"
>
<head>
<HeadContent />
</head>
<body>
<HydrationReady />
<QueryClientProvider client={queryClient}>
<MantineProvider theme={theme}>
<PikkuProvider pikku={pikku}>
<Box
mih="100vh"
bg="var(--mantine-color-body)"
c="var(--mantine-color-text)"
>
<Outlet />
</Box>
</PikkuProvider>
</MantineProvider>
</QueryClientProvider>
<Scripts />
</body>
</html>
)
}
function HydrationReady() {
useEffect(() => {
window.document.documentElement.dataset.appHydrated = 'true'
}, [])
return null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { bookingStatus } from '@/i18n/i18n-enum.gen'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { keepPreviousData } from '@tanstack/react-query'
import {
Badge,
Box,
Button,
Checkbox,
Container,
Group,
Loader,
MultiSelect,
Paper,
Stack,
Table,
Text,
TextInput,
} from '@pikku/mantine/core'
import { Search } from 'lucide-react'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { StatusPill } from '../components/StatusPill'
import { usePageTitle } from '../components/AppLayout'
import { BOOKING_STATUS, type BookingStatus } from '../lib/status'
import { fmtDateShort } from '../lib/dates'
const STATUS_KEYS: BookingStatus[] = [
'enquiry',
'reserved',
'confirmed',
'ended',
'cancelled',
]
function StatusDot({ color }: { color: string }) {
return (
<Box
component="span"
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 4,
background: color,
flex: '0 0 auto',
}}
/>
)
}
function AdminBookingsInner() {
useLocale()
const navigate = useNavigate()
usePageTitle(m.common__pages__admin__bookings__title())
const [years, setYears] = useState<string[]>([])
const [statusFilter, setStatusFilter] = useState<string[]>([])
const [search, setSearch] = useState('')
const [halfHouseOnly, setHalfHouseOnly] = useState(false)
const queryArgs = useMemo(
() => ({
venueSlug: 'drawehn',
years: years.length ? years.map(Number) : undefined,
status: statusFilter.length ? statusFilter : undefined,
search: search.trim() || undefined,
halfHouseOnly: halfHouseOnly || undefined,
}),
[years, statusFilter, search, halfHouseOnly],
)
const normalizedSearch = search.trim().toLocaleLowerCase(getLocale())
const { data, isLoading, isFetching, error } = usePikkuQuery(
'getAdminBookings',
queryArgs,
{ placeholderData: keepPreviousData },
)
const statusOptions = useMemo(
() =>
STATUS_KEYS.map((s) => ({
value: s,
label: bookingStatus[s](),
})),
[getLocale()],
)
if (isLoading && !data) {
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
}
if (error && !data) {
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
}
if (!data) return null
const bookings = [...data.bookings].sort((a, b) => {
if (!normalizedSearch) return 0
const rank = (value: string | null | undefined) => {
const text = value?.toLocaleLowerCase(getLocale()) ?? ''
if (text === normalizedSearch) return 0
if (text.startsWith(normalizedSearch)) return 1
if (text.includes(normalizedSearch)) return 2
return 3
}
const aRank = Math.min(rank(a.eventName), rank(a.clientName))
const bRank = Math.min(rank(b.eventName), rank(b.clientName))
if (aRank !== bRank) return aRank - bRank
return a.eventName.localeCompare(b.eventName, getLocale())
})
return (
<Container size="xl" py="md">
<Stack gap="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
flex="1 1 220px"
leftSection={<Search size={16} />}
placeholder={m.common__pages__admin__bookings__search_placeholder()}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<MultiSelect
flex="0 1 200px"
clearable
placeholder={
years.length ? undefined : m.common__pages__admin__bookings__filters__year()
}
data={data.yearOptions.map((y) => String(y))}
value={years}
onChange={setYears}
/>
<MultiSelect
flex="1 1 240px"
clearable
placeholder={
statusFilter.length
? undefined
: m.common__pages__admin__bookings__filters__status()
}
data={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
renderOption={({ option }) => (
<Group gap="xs" wrap="nowrap">
<StatusDot color={BOOKING_STATUS[option.value as BookingStatus].dot} />
<span>{option.label}</span>
</Group>
)}
/>
<Checkbox
color="plum"
label={m.common__pages__admin__bookings__filters__half_house_only()}
checked={halfHouseOnly}
onChange={(e) => setHalfHouseOnly(e.currentTarget.checked)}
styles={{ root: { alignSelf: 'center' } }}
/>
<Button
component={Link}
to="/enquiry"
color="plum"
variant="filled"
ml="auto"
>
{m.common__pages__admin__bookings__new_booking()}
</Button>
</Group>
<Group gap="xs" align="center">
<Text size="sm" c="dimmed">
{m.common__pages__admin__bookings__stats__showing({ count: data.totalCount })}
</Text>
{isFetching && <Loader size="xs" color="plum" />}
</Group>
<Paper withBorder radius="md" p={0}>
{bookings.length === 0 ? (
<Box p="xl">
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__bookings__empty()}
</Text>
</Box>
) : (
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common__pages__admin__bookings__cols__dates()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__course()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__client()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__status()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__persons()}</Table.Th>
<Table.Th ta="center">{m.common__pages__admin__bookings__cols__half_house()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__deposit()}</Table.Th>
<Table.Th>{m.common__pages__admin__bookings__cols__final()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookings.map((b) => (
<Table.Tr
key={b.bookingId}
style={{ cursor: 'pointer' }}
onClick={() =>
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId: b.bookingId } })
}
>
<Table.Td>
<Text size="sm">{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}</Text>
<Text size="xs" c="dimmed">{asI18n(`${fmtDateShort(b.endDate, getLocale())}`)}</Text>
</Table.Td>
<Table.Td>{asI18n(`${b.eventName}`)}</Table.Td>
<Table.Td>
<Group gap={4}>
{b.isStammgruppe && (
<Text size="xs" c="yellow" title={m.common__pages__admin__bookings__cols__regular_group()}>{asI18n('★')}</Text>
)}
{asI18n(`${b.clientName}`)}
</Group>
</Table.Td>
<Table.Td>
<StatusPill kind="booking" status={b.status as BookingStatus} />
</Table.Td>
<Table.Td>
{b.participantCount} / {b.expectedPersons ?? '?'}
</Table.Td>
<Table.Td ta="center">{b.halfHouse ? '½' : ''}</Table.Td>
<Table.Td>
{!b.hasDeposit
? <Text size="xs" c="dimmed">{m.common__pages__admin__bookings__deposit__missing()}</Text>
: <Badge size="xs" color={b.depositPaid ? 'green' : 'orange'} variant="light">
{(b.depositPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
</Badge>}
</Table.Td>
<Table.Td>
{!b.hasFinal
? <Text size="xs" c="dimmed">{m.common__pages__admin__bookings__deposit__missing()}</Text>
: <Badge size="xs" color={b.finalPaid ? 'green' : 'orange'} variant="light">
{(b.finalPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
</Badge>}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
</Container>
)
}
export const Route = createFileRoute('/admin/bookings/')({
component: AdminBookingsInner,
})

View File

@@ -0,0 +1,10 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { RequireAuth } from '../auth'
export const Route = createFileRoute('/admin/bookings')({
component: () => (
<RequireAuth roles={['admin', 'owner']}>
<Outlet />
</RequireAuth>
),
})

View File

@@ -0,0 +1,564 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { enquiryStatus } from '@/i18n/i18n-enum.gen'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { useDebouncedValue } from '@mantine/hooks'
import {
Alert,
Anchor,
Badge,
Box,
Button,
Container,
Group,
Loader,
Modal,
MultiSelect,
Paper,
SegmentedControl,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from '@pikku/mantine/core'
import { Check, Clock, X } from 'lucide-react'
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { usePageTitle } from '../components/AppLayout'
import { RequireAuth } from '../auth'
import { fmtDate, fmtDateShort } from '../lib/dates'
const STATUS_KEYS = ['pending', 'approved', 'declined'] as const
type EnquiryStatus = (typeof STATUS_KEYS)[number]
type EnquiryDateOption = {
optionId: string
startDate: string
endDate: string
priority: number
}
type Enquiry = {
enquiryId: string
contactName: string | null
contactEmail: string
contactPhone: string | null
website: string | null
eventName: string
eventOutline: string | null
description: string | null
dateOptions: EnquiryDateOption[]
expectedPersons: number | null
halfHouse: boolean
notes: string | null
status: string
waitlistedAt: Date | null
approvedAt: Date | null
declinedAt: Date | null
createdAt: Date
bookingId: string | null
}
// The SDK's date reviver (transform-date.js) turns any 'YYYY-MM-DD…' response
// string into a Date — including these option dates, despite their string type.
// Normalize back to a plain 'YYYY-MM-DD' string so the date inputs and equality
// checks below work, and parse at local midnight for display.
const toYmd = (v: string | Date): string =>
v instanceof Date ? v.toISOString().slice(0, 10) : v
const optDate = (s: string) => new Date(`${s}T00:00:00`)
function StatusBadge({ enquiry }: { enquiry: Enquiry }) {
useLocale()
const color =
enquiry.status === 'approved' ? 'green' : enquiry.status === 'declined' ? 'red' : 'blue'
return (
<Group gap={6} wrap="nowrap">
<Badge color={color} variant="light" size="sm">
{enquiryStatus[enquiry.status as keyof typeof enquiryStatus]()}
</Badge>
{enquiry.status === 'pending' && enquiry.waitlistedAt && (
<Badge color="orange" variant="light" size="sm">
{enquiryStatus.waitlisted()}
</Badge>
)}
</Group>
)
}
function AdminEnquiriesInner() {
useLocale()
const queryClient = useQueryClient()
const navigate = useNavigate()
usePageTitle(m.common__pages__admin__enquiries__title())
const [statusFilter, setStatusFilter] = useState<string[]>(['pending'])
const queryArgs = useMemo(
() => ({
venueSlug: 'drawehn',
status: statusFilter.length ? statusFilter : undefined,
}),
[statusFilter],
)
const { data, isLoading, isFetching, error } = usePikkuQuery('getEnquiries', queryArgs, {
placeholderData: keepPreviousData,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['getEnquiries'] })
// Which enquiry has an open transition modal, and which kind.
const [modal, setModal] = useState<{ kind: 'approve' | 'waitlist' | 'decline'; enquiry: Enquiry } | null>(null)
const statusOptions = useMemo(
() =>
STATUS_KEYS.map((s) => ({
value: s,
label: enquiryStatus[s](),
})),
[getLocale()],
)
if (isLoading && !data) {
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
}
if (error && !data) {
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
}
if (!data) return null
const enquiries = (data.enquiries as Enquiry[]).map((e) => ({
...e,
dateOptions: e.dateOptions.map((o) => ({
...o,
startDate: toYmd(o.startDate),
endDate: toYmd(o.endDate),
})),
}))
return (
<Container size="xl" py="md">
<Stack gap="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<MultiSelect
flex="1 1 240px"
clearable
placeholder={statusFilter.length ? undefined : m.common__pages__admin__enquiries__filters__status()}
data={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
/>
</Group>
<Group gap="xs" align="center">
<Text size="sm" c="dimmed">
{m.common__pages__admin__enquiries__stats__showing({ count: enquiries.length })}
</Text>
{isFetching && <Loader size="xs" color="plum" />}
</Group>
<Paper withBorder radius="md" p={0}>
{enquiries.length === 0 ? (
<Box p="xl">
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__enquiries__empty()}
</Text>
</Box>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common__pages__admin__enquiries__cols__received()}</Table.Th>
<Table.Th>{m.common__pages__admin__enquiries__cols__event()}</Table.Th>
<Table.Th>{m.common__pages__admin__enquiries__cols__contact()}</Table.Th>
<Table.Th>{m.common__pages__admin__enquiries__cols__dates()}</Table.Th>
<Table.Th>{m.common__pages__admin__enquiries__cols__persons()}</Table.Th>
<Table.Th>{m.common__pages__admin__enquiries__cols__status()}</Table.Th>
<Table.Th>{m.common__pages__admin__enquiries__cols__actions()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{enquiries.map((e) => (
<Table.Tr key={e.enquiryId}>
<Table.Td>
<Text size="sm">{asI18n(`${fmtDateShort(e.createdAt, getLocale())}`)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{asI18n(`${e.eventName}`)}</Text>
{e.eventOutline && (
<Text size="xs" c="dimmed" lineClamp={1}>{asI18n(`${e.eventOutline}`)}</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{asI18n(`${e.contactName ?? '—'}`)}</Text>
<Text size="xs" c="dimmed">{asI18n(`${e.contactEmail}`)}</Text>
</Table.Td>
<Table.Td>
{e.dateOptions.length === 0 ? (
<Text size="xs" c="dimmed">{m.common__pages__admin__enquiries__no_dates()}</Text>
) : (
<Stack gap={2}>
{e.dateOptions.map((o, i) => (
<Text key={o.optionId} size="sm" c={i === 0 ? undefined : 'dimmed'}>
{asI18n(`${fmtDateShort(optDate(o.startDate), getLocale())}${fmtDateShort(optDate(o.endDate), getLocale())}`)}
</Text>
))}
</Stack>
)}
</Table.Td>
<Table.Td>{e.expectedPersons ?? '—'}</Table.Td>
<Table.Td><StatusBadge enquiry={e} /></Table.Td>
<Table.Td>
{e.status === 'pending' ? (
<Group gap={4} wrap="nowrap">
<Button
size="compact-xs"
color="green"
variant="light"
leftSection={<Check size={14} />}
onClick={() => setModal({ kind: 'approve', enquiry: e })}
>
{m.common__pages__admin__enquiries__actions__approve()}
</Button>
<Button
size="compact-xs"
color="orange"
variant="light"
leftSection={<Clock size={14} />}
onClick={() => setModal({ kind: 'waitlist', enquiry: e })}
>
{m.common__pages__admin__enquiries__actions__waitlist()}
</Button>
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<X size={14} />}
onClick={() => setModal({ kind: 'decline', enquiry: e })}
>
{m.common__pages__admin__enquiries__actions__decline()}
</Button>
</Group>
) : e.status === 'approved' && e.bookingId ? (
<Anchor
size="sm"
onClick={() =>
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId: e.bookingId! } })
}
>
{m.common__pages__admin__enquiries__actions__view_booking()}
</Anchor>
) : (
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
{modal?.kind === 'approve' && (
<ApproveModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
)}
{modal?.kind === 'waitlist' && (
<WaitlistModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
)}
{modal?.kind === 'decline' && (
<DeclineModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
)}
</Container>
)
}
// ─── Approve modal ──────────────────────────────────────────────────────────
function ApproveModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
useLocale()
const [mode, setMode] = useState<'existing' | 'new'>('existing')
// Prefill from the visitor's preferred (first) date option; the admin can
// pick a different option or override the inputs entirely.
const topOption = enquiry.dateOptions[0]
const [startDate, setStartDate] = useState(topOption?.startDate ?? '')
const [endDate, setEndDate] = useState(topOption?.endDate ?? '')
// Existing-client search (server-side via getClients).
const [clientId, setClientId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [debouncedSearch] = useDebouncedValue(search, 250)
const { data: clientData, isFetching: clientsFetching } = usePikkuQuery(
'getClients',
{ venueSlug: 'drawehn', q: debouncedSearch.trim() || undefined },
{ placeholderData: keepPreviousData, enabled: mode === 'existing' },
)
const clientOptions = useMemo(
() =>
(clientData?.clients ?? []).map((c) => ({
value: c.clientId,
label: c.name ? `${c.name}${c.contactEmail ? ` · ${c.contactEmail}` : ''}` : (c.contactEmail ?? c.clientId),
})),
[clientData],
)
// New-client fields, pre-filled from the enquiry contact.
const [name, setName] = useState(enquiry.contactName ?? '')
const [email, setEmail] = useState(enquiry.contactEmail)
const [phone, setPhone] = useState(enquiry.contactPhone ?? '')
const [website, setWebsite] = useState(enquiry.website ?? '')
const [billingAddress, setBillingAddress] = useState('')
const mutation = usePikkuMutation('approveEnquiry', { onSuccess: onDone })
const datesValid = !!startDate && !!endDate && startDate <= endDate
const clientValid = mode === 'existing' ? !!clientId : !!email.trim()
const canSubmit = datesValid && clientValid
const submit = () => {
mutation.mutate({
enquiryId: enquiry.enquiryId,
startDate,
endDate,
client:
mode === 'existing'
? { clientId: clientId! }
: {
name: name.trim() || undefined,
contactEmail: email.trim() || undefined,
contactPhone: phone.trim() || undefined,
website: website.trim() || undefined,
billingAddress: billingAddress.trim() || undefined,
},
})
}
return (
<Modal opened onClose={onClose} title={m.common__pages__admin__enquiries__approve_modal__title()} size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">{asI18n(`${enquiry.eventName}`)}</Text>
{enquiry.dateOptions.length > 0 && (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" mb={6}>
{m.common__pages__admin__enquiries__approve_modal__date_options()}
</Text>
<Group gap="xs">
{enquiry.dateOptions.map((o, i) => {
const selected = o.startDate === startDate && o.endDate === endDate
return (
<Badge
key={o.optionId}
variant={selected ? 'filled' : 'light'}
color={selected ? 'plum' : 'gray'}
style={{ cursor: 'pointer', textTransform: 'none' }}
onClick={() => {
setStartDate(o.startDate)
setEndDate(o.endDate)
}}
>
{asI18n(`${i + 1}. ${fmtDateShort(optDate(o.startDate), getLocale())}${fmtDateShort(optDate(o.endDate), getLocale())}`)}
</Badge>
)
})}
</Group>
</Box>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
data-testid="approve-start-date"
type="date"
label={m.common__pages__admin__enquiries__approve_modal__start_date()}
required
value={startDate}
onChange={(e) => setStartDate(e.currentTarget.value)}
/>
<TextInput
data-testid="approve-end-date"
type="date"
label={m.common__pages__admin__enquiries__approve_modal__end_date()}
required
value={endDate}
onChange={(e) => setEndDate(e.currentTarget.value)}
error={startDate && endDate && startDate > endDate ? m.common__pages__admin__enquiries__approve_modal__date_error() : undefined}
/>
</SimpleGrid>
<SegmentedControl
fullWidth
value={mode}
onChange={(v) => setMode(v as 'existing' | 'new')}
data={[
{ value: 'existing', label: m.common__pages__admin__enquiries__approve_modal__existing_client() },
{ value: 'new', label: m.common__pages__admin__enquiries__approve_modal__new_client() },
]}
/>
{mode === 'existing' ? (
<Select
data-testid="approve-client-select"
label={m.common__pages__admin__enquiries__approve_modal__select_client()}
placeholder={m.common__pages__admin__enquiries__approve_modal__search_client()}
searchable
data={clientOptions}
value={clientId}
onChange={setClientId}
searchValue={search}
onSearchChange={setSearch}
rightSection={clientsFetching ? <Loader size="xs" /> : undefined}
nothingFoundMessage={m.common__pages__admin__enquiries__approve_modal__no_clients()}
filter={({ options }) => options}
/>
) : (
<Stack gap="sm">
<TextInput
data-testid="approve-new-name"
label={m.common__pages__admin__enquiries__approve_modal__client_name()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
data-testid="approve-new-email"
type="email"
label={m.common__pages__admin__enquiries__approve_modal__client_email()}
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
label={m.common__pages__admin__enquiries__approve_modal__client_phone()}
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
/>
</SimpleGrid>
<TextInput
label={m.common__pages__admin__enquiries__approve_modal__client_website()}
value={website}
onChange={(e) => setWebsite(e.currentTarget.value)}
/>
<TextInput
label={m.common__pages__admin__enquiries__approve_modal__client_billing_address()}
value={billingAddress}
onChange={(e) => setBillingAddress(e.currentTarget.value)}
/>
</Stack>
)}
{mutation.error && (
<Alert color="red">{asI18n(`${mutation.error.message || m.common__pages__admin__enquiries__transition_error()}`)}</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>{m.common__actions__cancel()}</Button>
<Button
data-testid="approve-confirm"
color="green"
onClick={submit}
disabled={!canSubmit || mutation.isPending}
loading={mutation.isPending}
>
{m.common__pages__admin__enquiries__actions__approve()}
</Button>
</Group>
</Stack>
</Modal>
)
}
// ─── Waitlist modal ─────────────────────────────────────────────────────────
function WaitlistModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
useLocale()
const mutation = usePikkuMutation('waitlistEnquiry', { onSuccess: onDone })
const hasOptions = enquiry.dateOptions.length > 0
return (
<Modal opened onClose={onClose} title={m.common__pages__admin__enquiries__waitlist_modal__title()} size="md">
<Stack gap="md">
<Text size="sm" c="dimmed">{m.common__pages__admin__enquiries__waitlist_modal__intro()}</Text>
{hasOptions ? (
<Stack gap={4}>
{enquiry.dateOptions.map((o, i) => (
<Text key={o.optionId} size="sm">
{asI18n(`${i + 1}. ${fmtDateShort(optDate(o.startDate), getLocale())}${fmtDateShort(optDate(o.endDate), getLocale())}`)}
</Text>
))}
</Stack>
) : (
<Alert color="orange">{m.common__pages__admin__enquiries__waitlist_modal__no_options()}</Alert>
)}
{mutation.error && (
<Alert color="red">{asI18n(`${mutation.error.message || m.common__pages__admin__enquiries__transition_error()}`)}</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>{m.common__actions__cancel()}</Button>
<Button
data-testid="waitlist-confirm"
color="orange"
onClick={() => mutation.mutate({ enquiryId: enquiry.enquiryId })}
disabled={!hasOptions || mutation.isPending}
loading={mutation.isPending}
>
{m.common__pages__admin__enquiries__actions__waitlist()}
</Button>
</Group>
</Stack>
</Modal>
)
}
// ─── Decline modal ──────────────────────────────────────────────────────────
function DeclineModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
useLocale()
const mutation = usePikkuMutation('declineEnquiry', { onSuccess: onDone })
return (
<Modal opened onClose={onClose} title={m.common__pages__admin__enquiries__decline_modal__title()} size="md">
<Stack gap="md">
<Text size="sm">
{m.common__pages__admin__enquiries__decline_modal__confirm({
event: enquiry.eventName,
date: enquiry.createdAt ? fmtDate(enquiry.createdAt, getLocale()) : '',
})}
</Text>
{mutation.error && (
<Alert color="red">{asI18n(`${mutation.error.message || m.common__pages__admin__enquiries__transition_error()}`)}</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>{m.common__actions__cancel()}</Button>
<Button
data-testid="decline-confirm"
color="red"
onClick={() => mutation.mutate({ enquiryId: enquiry.enquiryId })}
disabled={mutation.isPending}
loading={mutation.isPending}
>
{m.common__pages__admin__enquiries__actions__decline()}
</Button>
</Group>
</Stack>
</Modal>
)
}
function AdminEnquiriesPage() {
return (
<RequireAuth roles={['admin', 'owner']}>
<AdminEnquiriesInner />
</RequireAuth>
)
}
export const Route = createFileRoute('/admin/enquiries')({
component: AdminEnquiriesPage,
})

View File

@@ -0,0 +1,309 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Container,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@pikku/mantine/core'
import {
CalendarDays,
FileClock,
FileText,
Inbox,
Wallet,
type LucideIcon,
} from 'lucide-react'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { RequireAuth } from '../auth'
import { usePageTitle } from '../components/AppLayout'
import { fmtDateShort } from '../lib/dates'
// One row inside an action card: clickable, navigates to the relevant workspace.
function Row({
primary,
secondary,
date,
trailing,
onClick,
}: {
primary: string
secondary?: string | null
date: string
trailing?: React.ReactNode
onClick: () => void
}) {
return (
<Group
justify="space-between"
wrap="nowrap"
gap="sm"
px="xs"
py={6}
onClick={onClick}
style={{ cursor: 'pointer', borderRadius: 8 }}
className="dash-row"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{asI18n(`${primary}`)}
</Text>
{secondary && (
<Text size="xs" c="dimmed" truncate>
{asI18n(`${secondary}`)}
</Text>
)}
</Box>
<Group gap={8} wrap="nowrap" style={{ flex: '0 0 auto' }}>
{trailing}
<Text size="xs" c="dimmed">
{asI18n(`${date}`)}
</Text>
</Group>
</Group>
)
}
function ActionCard({
icon: Icon,
label,
count,
children,
}: {
icon: LucideIcon
label: string
count: number
children: React.ReactNode
}) {
const empty = count === 0
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb={children ? 'xs' : 0}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon
variant="light"
color={empty ? 'gray' : 'plum'}
size="md"
radius="md"
>
<Icon size={16} />
</ThemeIcon>
<Text size="sm" fw={600}>
{asI18n(`${label}`)}
</Text>
</Group>
<Badge
color={empty ? 'gray' : 'plum'}
variant={empty ? 'light' : 'filled'}
radius="sm"
>
{count}
</Badge>
</Group>
{!empty && <Stack gap={2}>{children}</Stack>}
</Paper>
)
}
function DashboardInner() {
useLocale()
const navigate = useNavigate()
usePageTitle(m.common__pages__admin__dashboard__title())
const { data, isLoading, error } = usePikkuQuery('getAdminDashboard', {
venueSlug: 'drawehn',
})
if (isLoading && !data) {
return (
<Container py="xl">
<Text>{m.common__states__loading()}</Text>
</Container>
)
}
if (error && !data) {
return (
<Container py="xl">
<Text c="red">{m.common__states__error()}</Text>
</Container>
)
}
if (!data) return null
const goEnquiries = () => navigate({ to: '/admin/enquiries' })
const goBooking = (bookingId: string) =>
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId } })
const more = (bucket: { count: number; items: unknown[] }) =>
bucket.count > bucket.items.length ? (
<Text size="xs" c="dimmed" px="xs" pt={4}>
{m.common__pages__admin__dashboard__more({ count: bucket.count - bucket.items.length })}
</Text>
) : null
const noAction =
data.pendingEnquiries.count === 0 &&
data.contractsToSend.count === 0 &&
data.contractsAwaitingResponse.count === 0 &&
data.depositsOutstanding.count === 0
// A "needs action" hub should only surface what needs action — zero-count
// cards are noise, so we drop them and let `noAction` cover the empty state.
const bookingCard = (
key: string,
icon: LucideIcon,
label: string,
bucket: typeof data.contractsToSend,
) =>
bucket.count === 0 ? null : (
<ActionCard key={key} icon={icon} label={label} count={bucket.count}>
{bucket.items.map((b) => (
<Row
key={b.bookingId}
primary={b.eventName}
secondary={b.clientName}
date={fmtDateShort(b.startDate, getLocale())}
onClick={() => goBooking(b.bookingId)}
/>
))}
{more(bucket)}
</ActionCard>
)
const actionCards = [
data.pendingEnquiries.count === 0 ? null : (
<ActionCard
key="enq"
icon={Inbox}
label={m.common__pages__admin__dashboard__cards__pending_enquiries()}
count={data.pendingEnquiries.count}
>
{data.pendingEnquiries.items.map((e) => (
<Row
key={e.enquiryId}
primary={e.eventName}
secondary={e.contactName}
date={fmtDateShort(e.startDate, getLocale())}
trailing={
e.waitlisted ? (
<Badge size="xs" color="orange" variant="light">
{m.common__pages__admin__dashboard__waitlisted()}
</Badge>
) : null
}
onClick={goEnquiries}
/>
))}
{more(data.pendingEnquiries)}
</ActionCard>
),
bookingCard('send', FileText, m.common__pages__admin__dashboard__cards__contracts_to_send(), data.contractsToSend),
bookingCard(
'await',
FileClock,
m.common__pages__admin__dashboard__cards__contracts_awaiting(),
data.contractsAwaitingResponse,
),
bookingCard('dep', Wallet, m.common__pages__admin__dashboard__cards__deposits_outstanding(), data.depositsOutstanding),
].filter(Boolean)
return (
<Container size="xl" py="md">
<Grid gap="lg">
{/* Needs action */}
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="sm">
<Title order={4}>{m.common__pages__admin__dashboard__needs_action()}</Title>
{noAction ? (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__dashboard__all_clear()}
</Text>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{actionCards}
</SimpleGrid>
)}
</Stack>
</Grid.Col>
{/* Upcoming events */}
<Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="sm">
<Group gap="xs" align="baseline">
<Title order={4}>{m.common__pages__admin__dashboard__upcoming()}</Title>
<Text size="xs" c="dimmed">
{m.common__pages__admin__dashboard__upcoming_window()}
</Text>
</Group>
<Paper withBorder radius="md" p={data.upcomingEvents.count ? 'xs' : 'lg'}>
{data.upcomingEvents.count === 0 ? (
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__dashboard__no_upcoming()}
</Text>
) : (
<Stack gap={2}>
{data.upcomingEvents.items.map((b) => (
<Group
key={b.bookingId}
justify="space-between"
wrap="nowrap"
gap="sm"
px="xs"
py={8}
onClick={() => goBooking(b.bookingId)}
style={{ cursor: 'pointer', borderRadius: 8 }}
className="dash-row"
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color="plum"
size="lg"
radius="md"
>
<CalendarDays size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{asI18n(`${b.eventName}`)}
</Text>
<Text size="xs" c="dimmed" truncate>
{asI18n(
`${b.clientName}${b.expectedPersons != null ? ` · ${m.common__pages__admin__dashboard__persons({ count: b.expectedPersons })}` : ''}`,
)}
</Text>
</Box>
</Group>
<Text size="xs" c="dimmed" style={{ flex: '0 0 auto' }}>
{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}
</Text>
</Group>
))}
</Stack>
)}
</Paper>
</Stack>
</Grid.Col>
</Grid>
</Container>
)
}
export const Route = createFileRoute('/admin/')({
component: () => (
<RequireAuth roles={['admin', 'owner']}>
<DashboardInner />
</RequireAuth>
),
})

View File

@@ -0,0 +1,402 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Container,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
// Brand-aligned: free is the inviting state (cream + plum text, clickable),
// confirmed is fully booked (filled plum), enquiry/reserved sit between.
const STATUS_BG: Record<string, { bg: string; fg: string }> = {
free: { bg: '#FBF8FA', fg: '#4E3149' },
enquiry: { bg: '#FFF8E0', fg: '#806600' },
reserved: { bg: '#FFEDDC', fg: '#8A4A1A' },
confirmed: { bg: '#88557F', fg: '#FFFFFF' },
}
const STATUS_KEYS = ['free', 'enquiry', 'reserved', 'confirmed'] as const
type DayBooking = {
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
}
type DayCell = {
date: Date
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
bookings: DayBooking[]
}
// Berlin-local YYYY-MM-DD string from a Date
const berlinStr = (d: Date) => d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
function groupByMonth(days: DayCell[]) {
const months: Record<string, DayCell[]> = {}
for (const d of days) {
const key = berlinStr(d.date).slice(0, 7)
;(months[key] ??= []).push(d)
}
return Object.entries(months).map(([key, ds]) => ({ key, days: ds }))
}
function monthLabel(yyyymm: string, locale: string) {
const [y, m] = yyyymm.split('-')
const d = new Date(Number(y), Number(m) - 1, 1)
return d.toLocaleString(locale, { month: 'long', year: 'numeric' })
}
function dowMonStart(d: Date) {
return (d.getUTCDay() + 6) % 7
}
// Inclusive list of YYYY-MM-DD strings between two date strings (UTC day steps).
function rangeDates(start: string, end: string): string[] {
const out: string[] = []
const cur = new Date(`${start}T00:00:00Z`)
const last = new Date(`${end}T00:00:00Z`)
while (cur <= last) {
out.push(cur.toISOString().slice(0, 10))
cur.setUTCDate(cur.getUTCDate() + 1)
}
return out
}
const SELECT_BG = { bg: '#88557F', fg: '#FFFFFF' } // endpoint
const SELECT_MID = { bg: '#EFE3ED', fg: '#4E3149' } // in-between
function MonthGrid({
days,
dowLabels,
statusLabels,
halfHouseLabel,
selected,
endpoints,
onDayClick,
}: {
days: DayCell[]
dowLabels: string[]
statusLabels: Record<string, string>
halfHouseLabel: string
selected: Set<string>
endpoints: Set<string>
onDayClick: (dateStr: string) => void
}) {
const first = days[0]
if (!first) return null
const leadingBlanks = dowMonStart(first.date)
return (
<Box>
<SimpleGrid cols={7} spacing={4}>
{dowLabels.map((d, i) => (
<Text key={i} size="xs" c="dimmed" ta="center">{asI18n(`${d}`)}</Text>
))}
{Array.from({ length: leadingBlanks }).map((_, i) => (
<Box key={`blank-${i}`} />
))}
{days.map((d) => {
const dateStr = berlinStr(d.date)
const isEndpoint = endpoints.has(dateStr)
const isSelected = selected.has(dateStr)
const style = isEndpoint ? SELECT_BG : isSelected ? SELECT_MID : STATUS_BG[d.status]
const dayNum = Number(dateStr.slice(8, 10))
// Two half-house bookings sharing a day → split the cell with a
// diagonal line (top-left = first booking, bottom-right = second).
// 3+ half-house bookings on one day (data error) intentionally fall
// back to the single collapsed colour — don't "fix" that into an
// N-way split without a real requirement.
const isSplit =
!isEndpoint && !isSelected &&
d.bookings.length === 2 && d.bookings.every((b) => b.halfHouse)
const splitBg = isSplit
? `linear-gradient(135deg, ${STATUS_BG[d.bookings[0].status].bg} 0 49%, #FFFFFF 49% 51%, ${STATUS_BG[d.bookings[1].status].bg} 51% 100%)`
: undefined
const bookingLabel = (b: DayBooking) =>
`${b.eventName ?? statusLabels[b.status]}${b.halfHouse ? ` (${halfHouseLabel})` : ''}`
const tooltipLabel = isSplit ? (
<Stack gap={0}>
<Text size="xs" fw={600}>{asI18n(`${dateStr}`)}</Text>
{d.bookings.map((b, i) => (
<Text key={i} size="xs">{asI18n(`${bookingLabel(b)}`)}</Text>
))}
</Stack>
) : d.eventName
? asI18n(`${dateStr}${d.eventName}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
: asI18n(`${dateStr}${statusLabels[d.status]}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
return (
<Tooltip key={dateStr} label={tooltipLabel} withArrow>
<Box
style={{
background: splitBg ?? style.bg,
color: isSplit ? '#4E3149' : style.fg,
borderRadius: 4,
textAlign: 'center',
padding: '6px 0',
fontSize: 13,
fontWeight: isEndpoint ? 700 : undefined,
boxShadow: isSelected && !isEndpoint ? 'inset 0 0 0 1px #88557F' : undefined,
cursor: d.status === 'free' ? 'pointer' : 'default',
position: 'relative',
}}
onClick={() => {
if (d.status === 'free') onDayClick(dateStr)
}}
>
<span style={isSplit ? { textShadow: '0 0 3px rgba(255,255,255,0.95)' } : undefined}>
{dayNum}
</span>
{/* Single half-house booking keeps the corner dot; a split cell
already communicates the half-house state visually. */}
{d.halfHouse && !isSplit && (
<Box
style={{
position: 'absolute',
top: 2,
right: 4,
width: 6,
height: 6,
borderRadius: 3,
background: 'currentColor',
opacity: 0.5,
}}
/>
)}
</Box>
</Tooltip>
)
})}
</SimpleGrid>
</Box>
)
}
const THIS_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [THIS_YEAR, THIS_YEAR + 1, THIS_YEAR + 2].map((y) => ({
value: String(y),
label: String(y),
}))
function AvailabilityPage() {
useLocale()
const navigate = useNavigate()
const [year, setYear] = useState(THIS_YEAR)
// Two-click range selection: first free day sets `start`, second sets `end`.
const [sel, setSel] = useState<{ start: string; end: string | null } | null>(null)
const from = `${year}-01-01`
const to = `${year}-12-31`
const { data, isLoading, error } = usePikkuQuery('getAvailability', { venueSlug: 'drawehn', from, to })
// Lookup of day status by Berlin-local date string, for validating ranges.
const statusByDate = useMemo(() => {
const m = new Map<string, DayCell['status']>()
for (const d of (data?.days as DayCell[] | undefined) ?? []) {
m.set(berlinStr(d.date), d.status)
}
return m
}, [data])
const handleDayClick = (ds: string) => {
setSel((prev) => {
// No selection yet, or a completed range → start fresh.
if (!prev || prev.end) return { start: ds, end: null }
// Re-click the start → deselect.
if (ds === prev.start) return null
// Earlier than start → move the start.
if (ds < prev.start) return { start: ds, end: null }
// Later than start → close the range only if every day in it is free.
const allFree = rangeDates(prev.start, ds).every((x) => statusByDate.get(x) === 'free')
return allFree ? { start: prev.start, end: ds } : { start: ds, end: null }
})
}
const selectedDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set(rangeDates(sel.start, sel.end ?? sel.start))
}, [sel])
const endpointDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set([sel.start, sel.end ?? sel.start])
}, [sel])
const months = useMemo(() => (data ? groupByMonth(data.days as DayCell[]) : []), [data])
const stats = useMemo(() => {
if (!data) return { free: 0, total: 0 }
const total = data.days.length
const free = data.days.filter((d) => d.status === 'free').length
return { free, total }
}, [data])
// Localised mon-start day-of-week labels.
const dowLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(getLocale(), { weekday: 'short' })
// 2024-01-01 was a Monday — convenient anchor.
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 1 + i))))
}, [getLocale()])
if (isLoading) return <MarketingShell><Container py="xl"><Text>{m.common__states__loading()}</Text></Container></MarketingShell>
if (error) return <MarketingShell><Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container></MarketingShell>
if (!data) return null
const statusLabels: Record<string, string> = {
free: m.common__pages__availability__status__free(),
enquiry: m.common__pages__availability__status__enquiry(),
reserved: m.common__pages__availability__status__reserved(),
confirmed: m.common__pages__availability__status__confirmed(),
}
const halfHouseLabel = m.common__pages__availability__status__half_house()
// `enquiry` is only surfaced to staff (the backend hides it from anonymous
// viewers), so only show its legend swatch when such a day is actually
// present in the data.
const hasEnquiry = (data.days as DayCell[]).some((d) => d.status === 'enquiry')
const legendKeys = hasEnquiry ? STATUS_KEYS : STATUS_KEYS.filter((k) => k !== 'enquiry')
const fmtDate = (ds: string) =>
new Date(`${ds}T00:00:00Z`).toLocaleDateString(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
const selStart = sel?.start ?? null
const selEnd = sel?.end ?? null
const rangeLabel = selStart
? selEnd && selEnd !== selStart
? `${fmtDate(selStart)} ${fmtDate(selEnd)}`
: fmtDate(selStart)
: ''
const createEnquiry = () => {
if (!selStart) return
navigate({ to: '/enquiry', search: { from: selStart, to: selEnd ?? selStart } })
}
return (
<MarketingShell>
<Container size="lg" py="xl">
<Stack gap="lg">
<PageTitle title={m.common__pages__availability__title()} />
<Group align="flex-end" gap="md">
<Select
label={m.common__pages__availability__year_label()}
data={YEAR_OPTIONS}
value={String(year)}
onChange={(v) => {
if (v) {
setYear(Number(v))
setSel(null)
}
}}
allowDeselect={false}
w={120}
/>
<Text size="sm" pb={6}>
{m.common__pages__availability__free_stat({
free: stats.free,
total: stats.total,
percent: Math.round((stats.free / stats.total) * 100),
})}
</Text>
</Group>
<Group gap="md">
{legendKeys.map((key) => (
<Group key={key} gap={6} align="center">
<Box w={14} h={14} style={{ background: STATUS_BG[key].bg, borderRadius: 3 }} />
<Text size="xs">{asI18n(`${statusLabels[key]}`)}</Text>
</Group>
))}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{months.map((month) => (
<Paper key={month.key} withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4} tt="capitalize">{asI18n(`${monthLabel(month.key, getLocale())}`)}</Title>
<Badge variant="light">
{m.common__pages__availability__free_month_badge({
count: month.days.filter((d) => d.status === 'free').length,
})}
</Badge>
</Group>
<MonthGrid
days={month.days}
dowLabels={dowLabels}
statusLabels={statusLabels}
halfHouseLabel={halfHouseLabel}
selected={selectedDates}
endpoints={endpointDates}
onDayClick={handleDayClick}
/>
</Stack>
</Paper>
))}
</SimpleGrid>
{selStart && (
<Paper
withBorder
shadow="md"
radius="md"
p="md"
pos="sticky"
bottom={16}
style={{ zIndex: 2 }}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Stack gap={2}>
<Text fw={600}>
{m.common__pages__availability__selected_range({ range: rangeLabel })}
</Text>
{!selEnd && (
<Text size="xs" c="dimmed">
{m.common__pages__availability__select_end_hint()}
</Text>
)}
</Stack>
<Group gap="xs">
<Button variant="subtle" color="gray" onClick={() => setSel(null)}>
{m.common__pages__availability__clear_selection()}
</Button>
<Button data-testid="create-enquiry" onClick={createEnquiry}>
{m.common__pages__availability__create_enquiry()}
</Button>
</Group>
</Group>
</Paper>
)}
<Text size="xs" c="dimmed" ta="center">
{m.common__pages__availability__footnote()}
</Text>
</Stack>
</Container>
</MarketingShell>
)
}
export const Route = createFileRoute('/availability')({
component: AvailabilityPage,
})

View File

@@ -0,0 +1,334 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import {
Alert,
Box,
Button,
Checkbox,
Container,
Divider,
Group,
Loader,
NumberInput,
Paper,
Radio,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@pikku/mantine/core'
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { PageTitle } from '../components/AppLayout'
import { fmtDate } from '../lib/dates'
type FormState = {
name: string
billingAddress: string
contactPhone: string
website: string
paymentMethod: 'cash' | 'transfer'
expectedPersons: string
arrivalTime: string
departureTime: string
dailyPlan: string
onlineAd: boolean
notes: string
acceptTerms: boolean
acceptPrivacy: boolean
}
/**
* Public landing for the passwordless booking-form magic link. Consumes the
* token (which issues a client session cookie), then renders the binding form.
* The token IS the auth — this route is intentionally not behind RequireAuth.
*/
function BookingFormLanding() {
const { token } = Route.useParams()
useLocale()
const qc = useQueryClient()
const [bookingId, setBookingId] = useState<string | null>(null)
// Outcome is tracked in component state (not the mutation observer) so it
// survives StrictMode's mount→unmount→mount and the consumedRef guard below.
const [phase, setPhase] = useState<'pending' | 'ready' | 'error'>('pending')
const consumedRef = useRef(false)
const consume = usePikkuMutation('consumeBookingFormLink', {
onSuccess: async (data) => {
await qc.invalidateQueries({ queryKey: ['me'] })
setBookingId(data.bookingId)
setPhase('ready')
},
onError: () => setPhase('error'),
})
useEffect(() => {
if (consumedRef.current) return
consumedRef.current = true
consume.mutate({ token })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
if (phase === 'error') {
return (
<Container size="sm" py="xl">
<PageTitle title={m.common__pages__booking_form__title()} />
<Alert color="red" title={m.common__pages__booking_form__link_invalid__title()}>
{m.common__pages__booking_form__link_invalid__body()}
</Alert>
</Container>
)
}
if (!bookingId) {
return (
<Container size="sm" py="xl">
<Group justify="center" py="xl">
<Loader color="plum" />
<Text c="dimmed">{m.common__states__loading()}</Text>
</Group>
</Container>
)
}
return <BookingFormInner bookingId={bookingId} />
}
function BookingFormInner({ bookingId }: { bookingId: string }) {
useLocale()
const [form, setForm] = useState<FormState | null>(null)
const [success, setSuccess] = useState(false)
const { data, isLoading, error } = usePikkuQuery('getBookingFormData', { bookingId })
const submit = usePikkuMutation('submitBookingForm', {
onSuccess: () => setSuccess(true),
})
// Seed editable state once the prefill data arrives.
useEffect(() => {
if (data && !form) {
setForm({
name: data.client.name ?? '',
billingAddress: data.client.billingAddress ?? '',
contactPhone: data.client.contactPhone ?? '',
website: data.client.website ?? '',
paymentMethod: (data.paymentMethod as 'cash' | 'transfer') ?? 'cash',
expectedPersons: data.expectedPersons != null ? String(data.expectedPersons) : '',
arrivalTime: data.arrivalTime ?? '',
departureTime: data.departureTime ?? '',
dailyPlan: data.dailyPlan ?? '',
onlineAd: data.onlineAd,
notes: data.notes ?? '',
acceptTerms: false,
acceptPrivacy: false,
})
}
}, [data, form])
if (isLoading || !form) {
if (error) {
return (
<Container size="sm" py="xl">
<Alert color="red">{m.common__states__error()}</Alert>
</Container>
)
}
return (
<Container size="sm" py="xl">
<Group justify="center" py="xl">
<Loader color="plum" />
</Group>
</Container>
)
}
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => (f ? { ...f, [key]: value } : f))
if (success) {
return (
<Container size="sm" py="xl">
<PageTitle title={m.common__pages__booking_form__title()} />
<Alert color="green" title={m.common__pages__booking_form__success__title()}>
{m.common__pages__booking_form__success__body()}
</Alert>
</Container>
)
}
const canSubmit =
form.name.trim() &&
form.billingAddress.trim() &&
form.acceptTerms &&
form.acceptPrivacy
const submitForm = () => {
submit.mutate({
bookingId,
name: form.name.trim(),
billingAddress: form.billingAddress.trim(),
contactPhone: form.contactPhone || undefined,
website: form.website || undefined,
paymentMethod: form.paymentMethod,
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
arrivalTime: form.arrivalTime || undefined,
departureTime: form.departureTime || undefined,
dailyPlan: form.dailyPlan || undefined,
onlineAd: form.onlineAd,
notes: form.notes || undefined,
acceptTerms: form.acceptTerms,
acceptPrivacy: form.acceptPrivacy,
})
}
return (
<Container size="md" py="xl">
<PageTitle title={m.common__pages__booking_form__title()} />
<Stack gap="lg">
<Box>
<Title order={2}>{m.common__pages__booking_form__heading()}</Title>
<Text c="dimmed" mt={4}>
{asI18n(
`${data!.eventName}${data!.startDate ? ` · ${fmtDate(new Date(data!.startDate), getLocale())}` : ''}${data!.endDate ? `${fmtDate(new Date(data!.endDate), getLocale())}` : ''}`,
)}
</Text>
</Box>
<Paper withBorder radius="md" p="lg">
<Stack gap="lg">
<Section title={m.common__pages__booking_form__sections__billing()}>
<TextInput
label={m.common__pages__booking_form__fields__name()}
required
value={form.name}
onChange={(e) => set('name', e.currentTarget.value)}
/>
<Textarea
label={m.common__pages__booking_form__fields__billing_address()}
required
autosize
minRows={3}
value={form.billingAddress}
onChange={(e) => set('billingAddress', e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={m.common__pages__booking_form__fields__contact_phone()}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={m.common__pages__booking_form__fields__website()}
value={form.website}
onChange={(e) => set('website', e.currentTarget.value)}
/>
</SimpleGrid>
<Radio.Group
label={m.common__pages__booking_form__fields__payment_method()}
value={form.paymentMethod}
onChange={(v) => set('paymentMethod', v as 'cash' | 'transfer')}
required
>
<Group mt="xs" gap="lg">
<Radio value="cash" label={m.common__pages__booking_form__payment__cash()} />
<Radio value="transfer" label={m.common__pages__booking_form__payment__transfer()} />
</Group>
</Radio.Group>
</Section>
<Divider />
<Section title={m.common__pages__booking_form__sections__event()}>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<NumberInput
label={m.common__pages__booking_form__fields__expected_persons()}
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
min={1}
/>
<TextInput
label={m.common__pages__booking_form__fields__arrival_time()}
value={form.arrivalTime}
onChange={(e) => set('arrivalTime', e.currentTarget.value)}
/>
<TextInput
label={m.common__pages__booking_form__fields__departure_time()}
value={form.departureTime}
onChange={(e) => set('departureTime', e.currentTarget.value)}
/>
</SimpleGrid>
<Textarea
label={m.common__pages__booking_form__fields__daily_plan()}
autosize
minRows={2}
value={form.dailyPlan}
onChange={(e) => set('dailyPlan', e.currentTarget.value)}
/>
<Checkbox
label={m.common__pages__booking_form__fields__online_ad()}
checked={form.onlineAd}
onChange={(e) => set('onlineAd', e.currentTarget.checked)}
/>
<Textarea
label={m.common__pages__booking_form__fields__notes()}
autosize
minRows={2}
value={form.notes}
onChange={(e) => set('notes', e.currentTarget.value)}
/>
</Section>
<Divider />
<Section title={m.common__pages__booking_form__sections__terms()}>
<Checkbox
label={m.common__pages__booking_form__fields__accept_terms()}
checked={form.acceptTerms}
onChange={(e) => set('acceptTerms', e.currentTarget.checked)}
/>
<Checkbox
label={m.common__pages__booking_form__fields__accept_privacy()}
checked={form.acceptPrivacy}
onChange={(e) => set('acceptPrivacy', e.currentTarget.checked)}
/>
</Section>
{submit.error && <Alert color="red">{m.common__pages__booking_form__error()}</Alert>}
<Group justify="flex-end">
<Button
color="plum"
onClick={submitForm}
disabled={!canSubmit || submit.isPending}
loading={submit.isPending}
>
{m.common__pages__booking_form__submit()}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Stack gap="sm">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
{asI18n(`${title}`)}
</Text>
<Box>
<Stack gap="md">{children}</Stack>
</Box>
</Stack>
)
}
export const Route = createFileRoute('/booking-form/$token')({
component: BookingFormLanding,
})

View File

@@ -0,0 +1,485 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import {
bookingStatus,
dietary as dietaryLabels,
participantKind as kindLabels,
allergen as allergenLabels,
} from '@/i18n/i18n-enum.gen'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Group,
Modal,
Progress,
RingProgress,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
import { RequireAuth } from '../auth'
import { BOOKING_TRANSITIONS, type BookingStatus } from '../lib/status'
import { fmtDate, toDateInputStr } from '../lib/dates'
const STATUS_COLORS: Record<string, string> = {
enquiry: 'pink',
reserved: 'orange',
confirmed: 'green',
ended: 'gray',
cancelled: 'red',
}
const DIETARY_KEYS = ['omnivore', 'vegetarian', 'vegan', 'gluten_free', 'lactose_free', 'pescatarian'] as const
const DIETARY_COLORS: Record<string, string> = {
omnivore: '#94a3b8',
vegetarian: '#84cc16',
vegan: '#22c55e',
gluten_free: '#eab308',
lactose_free: '#06b6d4',
pescatarian: '#0ea5e9',
unknown: '#64748b',
}
function BookingDetailInner() {
useLocale()
const { bookingId } = Route.useParams()
const qc = useQueryClient()
const queryArgs = { bookingId }
const { data, isLoading, error } = usePikkuQuery('getBookingDetail', queryArgs)
const invalidate = () =>
qc.invalidateQueries({ queryKey: ['getBookingDetail', queryArgs] })
const addMut = usePikkuMutation('addParticipant', { onSuccess: invalidate })
const updateMut = usePikkuMutation('updateParticipant', { onSuccess: invalidate })
const removeMut = usePikkuMutation('removeParticipant', { onSuccess: invalidate })
const [adding, setAdding] = useState(false)
const [newName, setNewName] = useState('')
const [newKind, setNewKind] = useState<'overnight' | 'day_guest'>('overnight')
if (isLoading) return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
if (error) return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
if (!data) return null
const { booking, client, participants, invoices, dietaryBreakdown } = data
const totalParticipants = participants.length
const dietaryTotal = dietaryBreakdown.reduce((s, d) => s + d.count, 0) || 1
const dietaryOptions = DIETARY_KEYS.map((k) => ({
value: k,
label: dietaryLabels[k](),
}))
const submitNew = () => {
if (!newName.trim()) return
addMut.mutate(
{ bookingId, name: newName.trim(), kind: newKind, email: null, age: null, dietaryTag: null, notes: null },
{
onSuccess: () => {
setNewName('')
setNewKind('overnight')
setAdding(false)
},
},
)
}
return (
<Container size="lg" py="xl">
<Stack gap="lg">
<Stack gap={4}>
<Group gap="xs">
<Button component={Link} to="/admin/bookings" size="xs" variant="subtle">
{asI18n(`${m.common__pages__admin__bookings__title()}`)}
</Button>
</Group>
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Title order={1}>{asI18n(`${booking.eventName}`)}</Title>
<Text size="sm" c="dimmed">{asI18n(`${client.name}`)}</Text>
<Text size="sm">{asI18n(`${fmtDate(booking.startDate, getLocale())}${fmtDate(booking.endDate, getLocale())}`)}</Text>
</Stack>
<Group gap="xs" align="center">
<Badge color={STATUS_COLORS[booking.status] ?? 'gray'} size="lg">
{bookingStatus[booking.status as BookingStatus]()}
</Badge>
<StatusChanger
bookingId={booking.bookingId}
status={booking.status}
onChanged={invalidate}
/>
</Group>
</Group>
</Stack>
<Tabs defaultValue="participants">
<Tabs.List>
<Tabs.Tab value="overview">{m.common__pages__booking__tabs__overview()}</Tabs.Tab>
<Tabs.Tab value="participants">
{asI18n(`${m.common__pages__booking__tabs__participants()} (${totalParticipants})`)}
</Tabs.Tab>
<Tabs.Tab value="invoices">
{asI18n(`${m.common__pages__booking__tabs__invoices()} (${invoices.length})`)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Stack gap="md">
<Card withBorder radius="md" p="lg">
<Stack gap="sm">
<Title order={4}>{m.common__pages__booking__overview__schedule()}</Title>
<Group gap="lg">
<Text size="sm">
<b>{asI18n(`${m.common__pages__booking__overview__breakfast()}:`)}</b>{asI18n(` ${booking.mealTimeBreakfast ?? '—'}`)}
</Text>
<Text size="sm">
<b>{asI18n(`${m.common__pages__booking__overview__lunch()}:`)}</b>{asI18n(` ${booking.mealTimeLunch ?? '—'}`)}
</Text>
<Text size="sm">
<b>{asI18n(`${m.common__pages__booking__overview__dinner()}:`)}</b>{asI18n(` ${booking.mealTimeDinner ?? '—'}`)}
</Text>
</Group>
{booking.eventOutline && (
<Text size="sm" fw={500}>{asI18n(`${booking.eventOutline}`)}</Text>
)}
{booking.description && (
<Text size="sm" c="dimmed">{asI18n(`${booking.description}`)}</Text>
)}
</Stack>
</Card>
<Card withBorder radius="md" p="lg">
<Stack gap="sm">
<Title order={4}>{m.common__pages__booking__overview__headcount()}</Title>
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm">
{m.common__pages__booking__overview__added({
count: totalParticipants,
target: booking.expectedPersons ?? '?',
})}
</Text>
</Group>
<Progress value={(totalParticipants / Math.max(booking.expectedPersons ?? 1, 1)) * 100} />
</Box>
</Stack>
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="participants" pt="md">
<Stack gap="md">
<Card withBorder radius="md" p="lg">
<Group align="flex-start" justify="space-between">
<Stack gap="xs">
<Title order={4}>{m.common__pages__booking__participants__dietary_breakdown()}</Title>
<Text size="sm" c="dimmed">
{m.common__pages__booking__participants__total_participants({ count: totalParticipants })}
</Text>
</Stack>
<RingProgress
size={120}
thickness={14}
sections={dietaryBreakdown.map((d) => ({
value: (d.count / dietaryTotal) * 100,
color: DIETARY_COLORS[d.tag] ?? DIETARY_COLORS.unknown,
tooltip: `${d.tag in dietaryLabels ? dietaryLabels[d.tag as keyof typeof dietaryLabels]() : asI18n(d.tag)}: ${d.count}`,
}))}
/>
</Group>
<Group gap="xs" mt="md">
{dietaryBreakdown.map((d) => (
<Badge
key={d.tag}
color="gray"
variant="light"
style={{ borderLeft: `3px solid ${DIETARY_COLORS[d.tag] ?? DIETARY_COLORS.unknown}` }}
>
{asI18n(`${d.tag in dietaryLabels ? dietaryLabels[d.tag as keyof typeof dietaryLabels]() : asI18n(d.tag)}: ${d.count}`)}
</Badge>
))}
</Group>
</Card>
<Card withBorder radius="md" p="lg">
<Stack gap="md">
<Group justify="space-between">
<Title order={4}>{m.common__pages__booking__participants__title()}</Title>
<Button size="xs" onClick={() => setAdding(true)}>
{m.common__pages__booking__participants__add_cta()}
</Button>
</Group>
{participants.length === 0 ? (
<Text size="sm" c="dimmed">{m.common__pages__booking__participants__empty()}</Text>
) : (
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common__pages__booking__participants__cols__name()}</Table.Th>
<Table.Th>{m.common__pages__booking__participants__cols__kind()}</Table.Th>
<Table.Th>{m.common__pages__booking__participants__cols__diet()}</Table.Th>
<Table.Th>{m.common__pages__booking__participants__cols__allergies()}</Table.Th>
<Table.Th>{m.common__pages__booking__participants__cols__room()}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{participants.map((p) => (
<Table.Tr key={p.participantId}>
<Table.Td>{p.name}</Table.Td>
<Table.Td>
<Badge variant="light" color={p.kind === 'day_guest' ? 'cyan' : 'blue'}>
{kindLabels[p.kind as keyof typeof kindLabels]()}
</Badge>
</Table.Td>
<Table.Td>
<Select
size="xs"
placeholder={asI18n('—')}
data={dietaryOptions}
value={p.dietaryTag ?? null}
onChange={(value) =>
updateMut.mutate({
participantId: p.participantId,
dietaryTag: value as any,
})
}
clearable
style={{ minWidth: 140 }}
/>
</Table.Td>
<Table.Td>
{p.allergies.length === 0 ? (
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
) : (
<Group gap={4}>
{p.allergies.map((a) => (
<Badge
key={a.allergen}
size="xs"
color={a.severity === 'separate_prep' ? 'red' : 'orange'}
variant="light"
>
{a.allergen in allergenLabels
? allergenLabels[a.allergen as keyof typeof allergenLabels]()
: asI18n(a.allergen)}
</Badge>
))}
</Group>
)}
</Table.Td>
<Table.Td>{p.roomNumber ?? '—'}</Table.Td>
<Table.Td>
<ActionIcon
size="sm"
variant="subtle"
color="red"
onClick={() => {
if (
confirm(
m.common__pages__booking__participants__remove_confirm({ name: p.name }),
)
) {
removeMut.mutate({
participantId: p.participantId,
})
}
}}
>
×
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="invoices" pt="md">
<Card withBorder radius="md" p="lg">
{invoices.length === 0 ? (
<Text size="sm" c="dimmed">{m.common__pages__booking__invoices__empty()}</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common__pages__booking__invoices__cols__number()}</Table.Th>
<Table.Th>{m.common__pages__booking__invoices__cols__kind()}</Table.Th>
<Table.Th>{m.common__pages__booking__invoices__cols__amount()}</Table.Th>
<Table.Th>{m.common__pages__booking__invoices__cols__status()}</Table.Th>
<Table.Th>{m.common__pages__booking__invoices__cols__issued()}</Table.Th>
<Table.Th>{m.common__pages__booking__invoices__cols__due()}</Table.Th>
<Table.Th>{m.common__pages__booking__invoices__cols__paid()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{invoices.map((inv) => (
<Table.Tr key={inv.invoiceId}>
<Table.Td>
{inv.pdfUrl ? (
<Button
component="a"
href={inv.pdfUrl}
target="_blank"
rel="noreferrer"
download={`${inv.invoiceNumber}.pdf`}
variant="subtle"
px={0}
>
{asI18n(`${inv.invoiceNumber}`)}
</Button>
) : (
inv.invoiceNumber
)}
</Table.Td>
<Table.Td>{inv.kind}</Table.Td>
<Table.Td>{(inv.amountCents / 100).toFixed(2)}</Table.Td>
<Table.Td>
<Badge color={inv.status === 'paid' ? 'green' : 'orange'}>{asI18n(`${inv.status}`)}</Badge>
</Table.Td>
<Table.Td>{fmtDate(inv.issuedOn, getLocale())}</Table.Td>
<Table.Td>{inv.dueOn ? fmtDate(inv.dueOn, getLocale()) : '—'}</Table.Td>
<Table.Td>{inv.paidOn ? fmtDate(inv.paidOn, getLocale()) : '—'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
<Modal
opened={adding}
onClose={() => setAdding(false)}
title={m.common__pages__booking__participants__add_modal__title()}
size="md"
>
<Stack gap="md">
<TextInput
label={m.common__pages__booking__participants__add_modal__name()}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
required
/>
<Select
label={m.common__pages__booking__participants__add_modal__kind()}
data={[
{ value: 'overnight', label: kindLabels.overnight() },
{ value: 'day_guest', label: kindLabels.day_guest() },
]}
value={newKind}
onChange={(v) => setNewKind((v as any) ?? 'overnight')}
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setAdding(false)}>
{m.common__actions__cancel()}
</Button>
<Button onClick={submitNew} loading={addMut.isPending}>
{m.common__pages__booking__participants__add_modal__add()}
</Button>
</Group>
</Stack>
</Modal>
</Container>
)
}
function StatusChanger({
bookingId,
status,
onChanged,
}: {
bookingId: string
status: string
onChanged: () => void
}) {
useLocale()
const [pending, setPending] = useState<string | null>(null)
const mutation = usePikkuMutation('setBookingStatus', {
onSuccess: () => {
setPending(null)
onChanged()
},
})
const next = BOOKING_TRANSITIONS[status as BookingStatus] ?? []
if (next.length === 0) return null
return (
<>
<Select
size="xs"
placeholder={m.common__pages__booking_status__advance()}
data={next.map((s) => ({
value: s,
label: bookingStatus[s](),
}))}
value={null}
onChange={(v) => v && setPending(v)}
clearable={false}
/>
<Modal
opened={pending !== null}
onClose={() => setPending(null)}
title={m.common__pages__booking_status__confirm_title()}
>
<Stack gap="md">
{pending && (
<Text size="sm">
{m.common__pages__booking_status__confirm_body({
status: bookingStatus[pending as BookingStatus](),
})}
</Text>
)}
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setPending(null)}>
{m.common__actions__cancel()}
</Button>
<Button
loading={mutation.isPending}
onClick={() =>
pending &&
mutation.mutate({
bookingId,
status: pending as BookingStatus,
})
}
>
{m.common__actions__confirm()}
</Button>
</Group>
</Stack>
</Modal>
</>
)
}
function BookingDetailPage() {
return (
<RequireAuth>
<BookingDetailInner />
</RequireAuth>
)
}
export const Route = createFileRoute('/booking/$bookingId')({
component: BookingDetailPage,
})

View File

@@ -0,0 +1,382 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import { type I18nMessage } from '@/i18n/i18n-enum.gen'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
ActionIcon,
Alert,
Box,
Button,
Checkbox,
Container,
Divider,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Tooltip,
} from '@pikku/mantine/core'
import { Info, Plus, Trash2 } from 'lucide-react'
import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
type DateOption = { startDate: string; endDate: string }
const MAX_DATE_OPTIONS = 3
type State = {
eventName: string
eventOutline: string
description: string
// 03 prioritized date options, in priority order (first = preferred).
dateOptions: DateOption[]
expectedPersons: string
halfHouse: boolean
clientName: string
contactEmail: string
contactPhone: string
website: string
notes: string
agbAccepted: boolean
privacyAccepted: boolean
}
const empty = (search?: { date?: string; from?: string; to?: string }): State => {
const seedStart = search?.from ?? search?.date ?? ''
const seedEnd = search?.to ?? search?.date ?? ''
return {
eventName: '',
eventOutline: '',
description: '',
dateOptions: [{ startDate: seedStart, endDate: seedEnd }],
expectedPersons: '',
halfHouse: false,
clientName: '',
contactEmail: '',
contactPhone: '',
website: '',
notes: '',
agbAccepted: false,
privacyAccepted: false,
}
}
function EnquiryPage() {
useLocale()
const search = Route.useSearch()
const [form, setForm] = useState<State>(() => empty(search))
const [success, setSuccess] = useState<string | null>(null)
const mutation = usePikkuMutation('submitEnquiry', {
onSuccess: (data) => setSuccess(data.enquiryId),
})
const set = <K extends keyof State>(key: K, value: State[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const setOption = (i: number, patch: Partial<DateOption>) =>
setForm((f) => ({
...f,
dateOptions: f.dateOptions.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
}))
const addOption = () =>
setForm((f) =>
f.dateOptions.length >= MAX_DATE_OPTIONS
? f
: { ...f, dateOptions: [...f.dateOptions, { startDate: '', endDate: '' }] },
)
const removeOption = (i: number) =>
setForm((f) => ({ ...f, dateOptions: f.dateOptions.filter((_, idx) => idx !== i) }))
// A row is "complete" only when both dates are set and ordered. Partially
// filled or reversed rows block submission; fully empty form = no dates (ok).
const rowState = (o: DateOption): 'empty' | 'complete' | 'invalid' => {
if (!o.startDate && !o.endDate) return 'empty'
if (o.startDate && o.endDate && o.startDate <= o.endDate) return 'complete'
return 'invalid'
}
const optionsValid = form.dateOptions.every((o) => rowState(o) !== 'invalid')
const submit = () => {
const dateOptions = form.dateOptions
.filter((o) => rowState(o) === 'complete')
.map((o) => ({ startDate: o.startDate, endDate: o.endDate }))
mutation.mutate({
venueSlug: 'drawehn',
contact: {
name: form.clientName.trim() || undefined,
contactEmail: form.contactEmail,
contactPhone: form.contactPhone || undefined,
website: form.website || undefined,
},
event: {
eventName: form.eventName,
eventOutline: form.eventOutline || undefined,
description: form.description || undefined,
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
halfHouse: form.halfHouse,
},
dateOptions: dateOptions.length ? dateOptions : undefined,
notes: form.notes || undefined,
privacyAccepted: form.privacyAccepted as true,
})
}
if (success) {
return (
<MarketingShell>
<Container size="sm" py="xl">
<Alert color="green" title={m.common__pages__enquiry__success__title()}>
{m.common__pages__enquiry__success__body({ enquiryId: success })}
</Alert>
</Container>
</MarketingShell>
)
}
const canSubmit =
form.eventName.trim() &&
form.contactEmail.trim() &&
form.agbAccepted &&
form.privacyAccepted &&
optionsValid
return (
<MarketingShell>
<Container size="md" py="xl">
<Stack gap="lg">
<PageTitle title={m.common__pages__enquiry__title()} />
<Paper withBorder radius="md" p="lg">
<Stack gap="lg">
<Section title={m.common__pages__enquiry__sections__event()}>
<TextInput
data-testid="courseName"
label={<FieldLabel label={m.common__pages__enquiry__fields__event_name()} hint={m.common__pages__enquiry__hints__event_name} />}
required
value={form.eventName}
onChange={(e) => set('eventName', e.currentTarget.value)}
/>
<TextInput
data-testid="eventOutline"
label={<FieldLabel label={m.common__pages__enquiry__fields__event_outline()} hint={m.common__pages__enquiry__hints__event_outline} />}
value={form.eventOutline}
onChange={(e) => set('eventOutline', e.currentTarget.value)}
/>
<Textarea
data-testid="description"
label={<FieldLabel label={m.common__pages__enquiry__fields__description()} hint={m.common__pages__enquiry__hints__description} />}
value={form.description}
onChange={(e) => set('description', e.currentTarget.value)}
autosize
minRows={4}
/>
<Stack gap="xs">
<FieldLabel label={m.common__pages__enquiry__fields__date_options()} hint={m.common__pages__enquiry__hints__date_options} />
{form.dateOptions.length === 0 && (
<Text size="sm" c="dimmed">{m.common__pages__enquiry__date_options__none()}</Text>
)}
{form.dateOptions.map((opt, i) => {
const invalid = rowState(opt) === 'invalid'
return (
<Group key={i} gap="xs" align="flex-end" wrap="nowrap" data-testid={`date-option-${i}`}>
<TextInput
data-testid={i === 0 ? 'startDate' : `date-option-${i}-start`}
type="date"
flex={1}
label={i === 0 ? m.common__pages__enquiry__fields__start_date() : undefined}
value={opt.startDate}
onChange={(e) => setOption(i, { startDate: e.currentTarget.value })}
error={invalid ? m.common__pages__enquiry__date_options__invalid() : undefined}
/>
<TextInput
data-testid={i === 0 ? 'endDate' : `date-option-${i}-end`}
type="date"
flex={1}
label={i === 0 ? m.common__pages__enquiry__fields__end_date() : undefined}
value={opt.endDate}
onChange={(e) => setOption(i, { endDate: e.currentTarget.value })}
/>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={m.common__pages__enquiry__date_options__remove()}
data-testid={`date-option-${i}-remove`}
onClick={() => removeOption(i)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
)
})}
{form.dateOptions.length < MAX_DATE_OPTIONS && (
<Group>
<Button
variant="light"
color="plum"
size="xs"
leftSection={<Plus size={14} />}
onClick={addOption}
data-testid="add-date-option"
>
{m.common__pages__enquiry__date_options__add()}
</Button>
</Group>
)}
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<NumberInput
label={<FieldLabel label={m.common__pages__enquiry__fields__expected_persons()} hint={m.common__pages__enquiry__hints__expected_persons} />}
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
min={1}
/>
<Select
label={<FieldLabel label={m.common__pages__enquiry__fields__half_house()} hint={m.common__pages__enquiry__hints__half_house} />}
value={form.halfHouse ? 'true' : 'false'}
onChange={(v) => set('halfHouse', v === 'true')}
allowDeselect={false}
data={[
{ value: 'false', label: m.common__pages__enquiry__fields__half_house_no() },
{ value: 'true', label: m.common__pages__enquiry__fields__half_house_yes() },
]}
/>
</SimpleGrid>
</Section>
<Divider />
<Section title={m.common__pages__enquiry__sections__client()}>
<TextInput
data-testid="organisationName"
label={<FieldLabel label={m.common__pages__enquiry__fields__client_name()} hint={m.common__pages__enquiry__hints__client_name} />}
value={form.clientName}
onChange={(e) => set('clientName', e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
data-testid="contactEmail"
type="email"
label={<FieldLabel label={m.common__pages__enquiry__fields__contact_email()} hint={m.common__pages__enquiry__hints__contact_email} />}
required
value={form.contactEmail}
onChange={(e) => set('contactEmail', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common__pages__enquiry__fields__contact_phone()} hint={m.common__pages__enquiry__hints__contact_phone} />}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common__pages__enquiry__fields__website()} hint={m.common__pages__enquiry__hints__website} />}
value={form.website}
onChange={(e) => set('website', e.currentTarget.value)}
/>
</SimpleGrid>
</Section>
<Divider />
<Section title={m.common__pages__enquiry__sections__notes()}>
<Textarea
label={<FieldLabel label={m.common__pages__enquiry__fields__notes()} hint={m.common__pages__enquiry__hints__notes} />}
value={form.notes}
onChange={(e) => set('notes', e.currentTarget.value)}
autosize
minRows={3}
/>
</Section>
<Divider />
<Section title={m.common__pages__enquiry__sections__terms()}>
<Checkbox
data-testid="agbAccepted"
label={m.common__pages__enquiry__agb_accept()}
checked={form.agbAccepted}
onChange={(e) => set('agbAccepted', e.currentTarget.checked)}
/>
<Checkbox
data-testid="privacyAccepted"
label={<FieldLabel label={m.common__pages__enquiry__fields__privacy_accepted()} hint={m.common__pages__enquiry__hints__privacy_accepted} />}
checked={form.privacyAccepted}
onChange={(e) => set('privacyAccepted', e.currentTarget.checked)}
/>
</Section>
{mutation.error && (
<Alert color="red">{m.common__pages__enquiry__error()}</Alert>
)}
<Group justify="flex-end">
<Button
onClick={submit}
disabled={!canSubmit || mutation.isPending}
loading={mutation.isPending}
>
{m.common__pages__enquiry__submit()}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
</MarketingShell>
)
}
/**
* Field label with an optional tooltip. The icon only renders when a hint
* string exists at `pages.enquiry.hints.<hint>` — so adding/removing a hint key
* in the locale files is all it takes to show or hide the tooltip per field.
*/
function FieldLabel({ label, hint }: { label: string; hint?: I18nMessage }) {
useLocale()
if (!hint) return <>{label}</>
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{label}
<Tooltip
label={hint()}
multiline
w={240}
withArrow
events={{ hover: true, focus: true, touch: true }}
>
<Info
size={14}
aria-label={hint()}
tabIndex={0}
style={{ cursor: 'help', opacity: 0.55, flexShrink: 0 }}
/>
</Tooltip>
</span>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Stack gap="sm">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">{asI18n(`${title}`)}</Text>
<Box>
<Stack gap="md">{children}</Stack>
</Box>
</Stack>
)
}
export const Route = createFileRoute('/enquiry')({
component: EnquiryPage,
validateSearch: (s: Record<string, unknown>): { date?: string; from?: string; to?: string } => ({
date: typeof s.date === 'string' ? s.date : undefined,
from: typeof s.from === 'string' ? s.from : undefined,
to: typeof s.to === 'string' ? s.to : undefined,
}),
})

View File

@@ -0,0 +1,113 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Container,
Group,
Paper,
Stack,
Text,
TextInput,
} from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { EventCard } from '../components/EventCard'
import { PageTitle } from '../components/AppLayout'
type Filter = 'this_year' | 'next_year' | 'all'
function FilterChip({
active,
children,
onClick,
}: {
active: boolean
children: React.ReactNode
onClick: () => void
}) {
return (
<Badge
variant={active ? 'filled' : 'light'}
color={active ? 'plum' : 'gray'}
style={{ cursor: 'pointer', textTransform: 'none' }}
onClick={onClick}
>
{asI18n(`${children}`)}
</Badge>
)
}
function EventsPage() {
useLocale()
const [filter, setFilter] = useState<Filter>('all')
const [search, setSearch] = useState('')
const { data, isLoading, error } = usePikkuQuery('getEventsListing', {
venueSlug: 'drawehn',
filter,
search: search.trim() || undefined,
})
if (isLoading) return <MarketingShell><Container py="xl"><Text>{m.common__states__loading()}</Text></Container></MarketingShell>
if (error) return <MarketingShell><Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container></MarketingShell>
if (!data) return null
return (
<MarketingShell>
<Container size="lg" py="xl">
<Stack gap="lg">
<PageTitle title={m.common__pages__events__title()} />
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<TextInput
placeholder={m.common__pages__events__search_placeholder()}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Group gap="xs">
<FilterChip active={filter === 'this_year'} onClick={() => setFilter('this_year')}>
{m.common__pages__events__filters__this_year()}
</FilterChip>
<FilterChip active={filter === 'next_year'} onClick={() => setFilter('next_year')}>
{m.common__pages__events__filters__next_year()}
</FilterChip>
<FilterChip active={filter === 'all'} onClick={() => setFilter('all')}>
{m.common__pages__events__filters__all()}
</FilterChip>
</Group>
</Stack>
</Paper>
{data.events.length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Text size="sm" c="dimmed" ta="center">{m.common__pages__events__empty()}</Text>
</Paper>
) : (
<Stack gap="md">
{data.events.map((e) => (
<EventCard
key={e.bookingId}
eventName={e.eventName}
startDate={e.startDate}
endDate={e.endDate}
clientName={e.clientName}
coverImageUrl={e.coverImageUrl}
eventOutline={e.eventOutline}
description={e.description}
organiserWebsite={e.organiserWebsite}
/>
))}
</Stack>
)}
</Stack>
</Container>
</MarketingShell>
)
}
export const Route = createFileRoute('/events')({
component: EventsPage,
})

View File

@@ -0,0 +1,276 @@
import { Navigate, createFileRoute } from '@tanstack/react-router'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Container,
Paper,
Progress,
Stack,
Table,
Text,
Title,
} from '@pikku/mantine/core'
import { BrandHeader } from '../components/Brand'
import { RequireAuth, useAuth } from '../auth'
import { usePageTitle } from '../components/AppLayout'
import { fmtDate } from '../lib/dates'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
function MarketingPage({ hero, sections }: { hero: React.ReactNode; sections: React.ReactNode[] }) {
return (
<Box>
{hero}
<Container size="md" py="xl">
<Stack gap="lg">{sections}</Stack>
</Container>
</Box>
)
}
function IndexPage() {
useLocale()
return (
<MarketingPage
hero={(
<Box bg="var(--brand-plum)" py="xl" c="white">
<Container size="md">
<BrandHeader variant="white" />
</Container>
</Box>
)}
sections={[
<Paper key="launchpad" withBorder radius="md" p="lg">
<Stack gap="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
{m.common__pages__launchpad__title()}
</Text>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common__pages__launchpad__client__title()}</Title>
<Text size="sm" c="dimmed">
{m.common__pages__launchpad__client__body()}
</Text>
<Box>
<Button component="a" href="/admin/bookings" size="sm" variant="light">
{m.common__pages__launchpad__client__cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common__pages__launchpad__availability__title()}</Title>
<Text size="sm" c="dimmed">
{m.common__pages__launchpad__availability__body()}
</Text>
<Box>
<Button component="a" href="/availability" size="sm" variant="light">
{m.common__pages__launchpad__availability__cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common__pages__launchpad__admin__title()}</Title>
<Text size="sm" c="dimmed">
{m.common__pages__launchpad__admin__body()}
</Text>
<Box>
<Button component="a" href="/admin/bookings" size="sm" variant="light">
{m.common__pages__launchpad__admin__cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common__pages__launchpad__events__title()}</Title>
<Text size="sm" c="dimmed">
{m.common__pages__launchpad__events__body()}
</Text>
<Box>
<Button component="a" href="/events" size="sm" variant="light">
{m.common__pages__launchpad__events__cta()}
</Button>
</Box>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Title order={4}>{m.common__pages__launchpad__enquiry__title()}</Title>
<Text size="sm" c="dimmed">
{m.common__pages__launchpad__enquiry__body()}
</Text>
<Box>
<Button component="a" href="/enquiry" size="sm" variant="light">
{m.common__pages__launchpad__enquiry__cta()}
</Button>
</Box>
</Stack>
</Paper>
</Stack>
</Paper>,
]}
/>
)
}
function ClientOverview() {
useLocale()
usePageTitle(m.common__pages__client__title())
const { data, isLoading, error } = usePikkuQuery('getClientOverview', null)
if (isLoading && !data) {
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
}
if (error && !data) {
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
}
if (!data) return null
return (
<Container size="lg" py="md">
<Stack gap="lg">
<Paper withBorder radius="md" p="lg">
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
{m.common__pages__client__title()}
</Text>
<Title order={2}>{m.common__brand__name()}</Title>
{data.clients.length > 0 && (
<Text c="dimmed">
{asI18n(data.clients.map((client) => client.name).join(', '))}
</Text>
)}
</Stack>
</Paper>
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Title order={3}>{m.common__pages__client__upcoming()}</Title>
{data.upcomingBookings.length === 0 ? (
<Text c="dimmed">{m.common__pages__client__no_upcoming()}</Text>
) : (
data.upcomingBookings.map((booking) => (
<Paper key={booking.bookingId} withBorder radius="md" p="md">
<Stack gap="xs">
<Stack gap={4}>
<Text fw={600}>{asI18n(`${booking.eventName}`)}</Text>
<Text size="sm" c="dimmed">
{asI18n(`${fmtDate(booking.startDate, getLocale())} - ${fmtDate(booking.endDate, getLocale())}`)}
</Text>
</Stack>
{booking.isCurrentEvent && (
<Box>
<Badge color="plum" variant="light">
{m.common__pages__client__current_badge()}
</Badge>
</Box>
)}
<Text size="sm">
{booking.expectedPersons
? m.common__pages__client__participants_count({
count: booking.participantCount,
target: booking.expectedPersons,
})
: m.common__pages__client__participants_count_unknown({
count: booking.participantCount,
})}
</Text>
<Stack gap={4}>
<Text size="sm" c="dimmed">
{m.common__pages__client__completeness()}
</Text>
<Progress
color="plum"
radius="xl"
size="lg"
value={booking.completenessPercent}
/>
</Stack>
</Stack>
</Paper>
))
)}
</Stack>
</Paper>
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Title order={3}>{m.common__pages__client__recent_invoices()}</Title>
{data.recentInvoices.length === 0 ? (
<Text c="dimmed">{m.common__pages__client__no_invoices()}</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.common__pages__client__invoice_cols__booking()}</Table.Th>
<Table.Th>{m.common__pages__client__invoice_cols__number()}</Table.Th>
<Table.Th>{m.common__pages__client__invoice_cols__kind()}</Table.Th>
<Table.Th>{m.common__pages__client__invoice_cols__amount()}</Table.Th>
<Table.Th>{m.common__pages__client__invoice_cols__status()}</Table.Th>
<Table.Th>{m.common__pages__client__invoice_cols__issued()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.recentInvoices.map((invoice) => (
<Table.Tr key={invoice.invoiceId}>
<Table.Td>{invoice.bookingName}</Table.Td>
<Table.Td>
{invoice.pdfUrl ? (
<Button
component="a"
href={invoice.pdfUrl}
target="_blank"
rel="noreferrer"
download={`${invoice.invoiceNumber}.pdf`}
variant="subtle"
px={0}
>
{asI18n(`${invoice.invoiceNumber}`)}
</Button>
) : (
invoice.invoiceNumber
)}
</Table.Td>
<Table.Td>{invoice.kind}</Table.Td>
<Table.Td>{(invoice.amountCents / 100).toFixed(2)} </Table.Td>
<Table.Td>{invoice.status}</Table.Td>
<Table.Td>{fmtDate(invoice.issuedOn, getLocale())}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
</Stack>
</Container>
)
}
function Dashboard() {
const { role } = useAuth()
if (role === 'admin' || role === 'owner') return <Navigate to="/admin/bookings" />
if (role === 'client') return <ClientOverview />
return <IndexPage />
}
function IndexRoute() {
return (
<RequireAuth>
<Dashboard />
</RequireAuth>
)
}
export const Route = createFileRoute('/')({
component: IndexRoute,
})

View File

@@ -0,0 +1,157 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { m } from '@/i18n/messages'
import { bookingStatus } from '@/i18n/i18n-enum.gen'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { Box, Container, Group, Paper, Stack, Text } from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { usePageTitle } from '../components/AppLayout'
import { PublicShell } from '../components/PublicShell'
import { BOOKING_STATUS, type BookingStatus } from '../lib/status'
import { fmtDateShort } from '../lib/dates'
const COLUMNS: BookingStatus[] = ['enquiry', 'reserved', 'confirmed', 'ended', 'cancelled']
type Booking = {
bookingId: string
eventName: string
startDate: Date
endDate: Date
status: string
clientName: string
isStammgruppe: boolean
depositPaid: boolean
hasDeposit: boolean
halfHouse: boolean
}
function BookingCard({ booking, locale }: { booking: Booking; locale: string }) {
useLocale()
const palette = BOOKING_STATUS[booking.status as BookingStatus]
return (
<Link
to="/admin/bookings/$bookingId"
params={{ bookingId: booking.bookingId }}
style={{ textDecoration: 'none', display: 'block' }}
>
<Paper
withBorder
radius="md"
p="sm"
style={{
borderLeft: `3px solid ${palette.dot}`,
cursor: 'pointer',
}}
>
<Stack gap={4}>
<Text size="sm" fw={600} lineClamp={2} c="var(--mantine-color-text)">
{asI18n(`${booking.eventName}`)}
</Text>
<Group gap={4}>
{booking.isStammgruppe && (
<Text size="xs" c="yellow.6" title={m.common__pages__admin_booking__client__regular_group()} style={{ cursor: 'default' }}>{asI18n('★')}</Text>
)}
<Text size="xs" c="dimmed" lineClamp={1}>{asI18n(`${booking.clientName}`)}</Text>
</Group>
<Text size="xs" c="dimmed">
{asI18n(`${fmtDateShort(booking.startDate, locale)} ${fmtDateShort(booking.endDate, locale)}`)}
</Text>
{booking.halfHouse && (
<Text size="xs" c="dimmed">{asI18n('½')}</Text>
)}
{booking.hasDeposit && (
<Text size="xs" c={booking.depositPaid ? 'green' : 'orange'} fw={500}>
{(booking.depositPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
</Text>
)}
</Stack>
</Paper>
</Link>
)
}
function KanbanInner() {
useLocale()
usePageTitle(m.common__pages__kanban__title())
const { data, isLoading, error } = usePikkuQuery('getAdminBookings', { venueSlug: 'drawehn' })
if (isLoading) return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
if (error) return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
if (!data) return null
const byStatus = new Map<BookingStatus, Booking[]>()
for (const col of COLUMNS) byStatus.set(col, [])
for (const b of data.bookings) {
const col = b.status as BookingStatus
if (byStatus.has(col)) byStatus.get(col)!.push({ ...b, halfHouse: !!b.halfHouse } as unknown as Booking)
}
return (
<Box
style={{
overflowX: 'auto',
minHeight: 0,
paddingBottom: 24,
}}
px="xl"
pt="md"
>
<Group align="flex-start" wrap="nowrap" gap="md" style={{ minWidth: 'max-content' }}>
{COLUMNS.map((col) => {
const palette = BOOKING_STATUS[col]
const cards = byStatus.get(col) ?? []
return (
<Stack
key={col}
gap="sm"
style={{ width: 260, flexShrink: 0 }}
>
<Group gap="xs" align="center">
<Box
style={{
width: 8,
height: 8,
borderRadius: 4,
background: palette.dot,
flexShrink: 0,
}}
/>
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: '0.06em', color: palette.fg }}>
{bookingStatus[col]()}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{cards.length}
</Text>
</Group>
<Stack gap="xs">
{cards.length === 0 ? (
<Paper withBorder radius="md" p="sm" style={{ borderStyle: 'dashed' }}>
<Text size="xs" c="dimmed" ta="center">{asI18n('—')}</Text>
</Paper>
) : (
cards.map((b) => (
<BookingCard key={b.bookingId} booking={b} locale={getLocale()} />
))
)}
</Stack>
</Stack>
)
})}
</Group>
</Box>
)
}
function KanbanPage() {
return (
<PublicShell>
<KanbanInner />
</PublicShell>
)
}
export const Route = createFileRoute('/kanban')({
component: KanbanPage,
})

View File

@@ -0,0 +1,92 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { startTransition, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { useQueryClient } from '@tanstack/react-query'
import {
Alert,
Button,
Container,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@pikku/mantine/core'
import { BrandMark } from '../components/Brand'
import { signIn } from '../lib/auth'
function LoginPage() {
useLocale()
const navigate = useNavigate()
const qc = useQueryClient()
const [email, setEmail] = useState(import.meta.env.DEV ? 'sarah@seminarhof.example' : '')
const [password, setPassword] = useState(import.meta.env.DEV ? 'owner1234' : '')
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState(false)
const handleSubmit = async () => {
setIsPending(true)
setError(false)
try {
await signIn(email, password)
await qc.invalidateQueries({ queryKey: ['me'] })
startTransition(() => {
navigate({ to: '/' })
})
} catch {
setError(true)
} finally {
setIsPending(false)
}
}
return (
<Container
size="xs"
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Paper withBorder radius="md" p="lg" w="100%">
<Stack gap="md" align="stretch">
<Stack gap={4} align="center">
<BrandMark size={64} />
<Title order={2} ta="center" c="var(--brand-plum)">{m.auth__login__title()}</Title>
<Text size="sm" c="dimmed" ta="center">{m.auth__login__subtitle()}</Text>
</Stack>
<TextInput
type="email"
label={m.auth__login__email()}
placeholder={m.auth__login__email_placeholder()}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<PasswordInput
label={m.auth__login__password()}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
{error && (
<Alert color="red">{m.auth__login__errors__invalid_credentials()}</Alert>
)}
<Button
onClick={handleSubmit}
disabled={!email || !password || isPending}
loading={isPending}
>
{m.auth__login__submit()}
</Button>
</Stack>
</Paper>
</Container>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})

View File

@@ -0,0 +1,36 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { useQueryClient } from '@tanstack/react-query'
import { Container, Paper, Stack, Text, Title, Button } from '@pikku/mantine/core'
import { signOut } from '../lib/auth'
function LogoutPage() {
useLocale()
const navigate = useNavigate()
const qc = useQueryClient()
useEffect(() => {
signOut().then(() => qc.clear()).catch(() => qc.clear())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Container size="xs" py="xl">
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Title order={2}>{m.auth__logout__title()}</Title>
<Text size="sm" c="dimmed">{m.auth__logout__subtitle()}</Text>
<Button onClick={() => navigate({ to: '/login' })}>
{m.auth__logout__back_to_login()}
</Button>
</Stack>
</Paper>
</Container>
)
}
export const Route = createFileRoute('/logout')({
component: LogoutPage,
})

View File

@@ -0,0 +1,353 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './pages/__root'
import { Route as LogoutRouteImport } from './pages/logout'
import { Route as LoginRouteImport } from './pages/login'
import { Route as KanbanRouteImport } from './pages/kanban'
import { Route as EventsRouteImport } from './pages/events'
import { Route as EnquiryRouteImport } from './pages/enquiry'
import { Route as AvailabilityRouteImport } from './pages/availability'
import { Route as IndexRouteImport } from './pages/index'
import { Route as AdminIndexRouteImport } from './pages/admin.index'
import { Route as BookingBookingIdRouteImport } from './pages/booking.$bookingId'
import { Route as BookingFormTokenRouteImport } from './pages/booking-form.$token'
import { Route as AdminEnquiriesRouteImport } from './pages/admin.enquiries'
import { Route as AdminBookingsRouteImport } from './pages/admin.bookings'
import { Route as AdminBookingsIndexRouteImport } from './pages/admin.bookings.index'
import { Route as AdminBookingsBookingIdRouteImport } from './pages/admin.bookings.$bookingId'
const LogoutRoute = LogoutRouteImport.update({
id: '/logout',
path: '/logout',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const KanbanRoute = KanbanRouteImport.update({
id: '/kanban',
path: '/kanban',
getParentRoute: () => rootRouteImport,
} as any)
const EventsRoute = EventsRouteImport.update({
id: '/events',
path: '/events',
getParentRoute: () => rootRouteImport,
} as any)
const EnquiryRoute = EnquiryRouteImport.update({
id: '/enquiry',
path: '/enquiry',
getParentRoute: () => rootRouteImport,
} as any)
const AvailabilityRoute = AvailabilityRouteImport.update({
id: '/availability',
path: '/availability',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/admin/',
path: '/admin/',
getParentRoute: () => rootRouteImport,
} as any)
const BookingBookingIdRoute = BookingBookingIdRouteImport.update({
id: '/booking/$bookingId',
path: '/booking/$bookingId',
getParentRoute: () => rootRouteImport,
} as any)
const BookingFormTokenRoute = BookingFormTokenRouteImport.update({
id: '/booking-form/$token',
path: '/booking-form/$token',
getParentRoute: () => rootRouteImport,
} as any)
const AdminEnquiriesRoute = AdminEnquiriesRouteImport.update({
id: '/admin/enquiries',
path: '/admin/enquiries',
getParentRoute: () => rootRouteImport,
} as any)
const AdminBookingsRoute = AdminBookingsRouteImport.update({
id: '/admin/bookings',
path: '/admin/bookings',
getParentRoute: () => rootRouteImport,
} as any)
const AdminBookingsIndexRoute = AdminBookingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AdminBookingsRoute,
} as any)
const AdminBookingsBookingIdRoute = AdminBookingsBookingIdRouteImport.update({
id: '/$bookingId',
path: '/$bookingId',
getParentRoute: () => AdminBookingsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/availability': typeof AvailabilityRoute
'/enquiry': typeof EnquiryRoute
'/events': typeof EventsRoute
'/kanban': typeof KanbanRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/admin/bookings': typeof AdminBookingsRouteWithChildren
'/admin/enquiries': typeof AdminEnquiriesRoute
'/booking-form/$token': typeof BookingFormTokenRoute
'/booking/$bookingId': typeof BookingBookingIdRoute
'/admin/': typeof AdminIndexRoute
'/admin/bookings/$bookingId': typeof AdminBookingsBookingIdRoute
'/admin/bookings/': typeof AdminBookingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/availability': typeof AvailabilityRoute
'/enquiry': typeof EnquiryRoute
'/events': typeof EventsRoute
'/kanban': typeof KanbanRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/admin/enquiries': typeof AdminEnquiriesRoute
'/booking-form/$token': typeof BookingFormTokenRoute
'/booking/$bookingId': typeof BookingBookingIdRoute
'/admin': typeof AdminIndexRoute
'/admin/bookings/$bookingId': typeof AdminBookingsBookingIdRoute
'/admin/bookings': typeof AdminBookingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/availability': typeof AvailabilityRoute
'/enquiry': typeof EnquiryRoute
'/events': typeof EventsRoute
'/kanban': typeof KanbanRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/admin/bookings': typeof AdminBookingsRouteWithChildren
'/admin/enquiries': typeof AdminEnquiriesRoute
'/booking-form/$token': typeof BookingFormTokenRoute
'/booking/$bookingId': typeof BookingBookingIdRoute
'/admin/': typeof AdminIndexRoute
'/admin/bookings/$bookingId': typeof AdminBookingsBookingIdRoute
'/admin/bookings/': typeof AdminBookingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/availability'
| '/enquiry'
| '/events'
| '/kanban'
| '/login'
| '/logout'
| '/admin/bookings'
| '/admin/enquiries'
| '/booking-form/$token'
| '/booking/$bookingId'
| '/admin/'
| '/admin/bookings/$bookingId'
| '/admin/bookings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/availability'
| '/enquiry'
| '/events'
| '/kanban'
| '/login'
| '/logout'
| '/admin/enquiries'
| '/booking-form/$token'
| '/booking/$bookingId'
| '/admin'
| '/admin/bookings/$bookingId'
| '/admin/bookings'
id:
| '__root__'
| '/'
| '/availability'
| '/enquiry'
| '/events'
| '/kanban'
| '/login'
| '/logout'
| '/admin/bookings'
| '/admin/enquiries'
| '/booking-form/$token'
| '/booking/$bookingId'
| '/admin/'
| '/admin/bookings/$bookingId'
| '/admin/bookings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AvailabilityRoute: typeof AvailabilityRoute
EnquiryRoute: typeof EnquiryRoute
EventsRoute: typeof EventsRoute
KanbanRoute: typeof KanbanRoute
LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute
AdminBookingsRoute: typeof AdminBookingsRouteWithChildren
AdminEnquiriesRoute: typeof AdminEnquiriesRoute
BookingFormTokenRoute: typeof BookingFormTokenRoute
BookingBookingIdRoute: typeof BookingBookingIdRoute
AdminIndexRoute: typeof AdminIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/logout': {
id: '/logout'
path: '/logout'
fullPath: '/logout'
preLoaderRoute: typeof LogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/kanban': {
id: '/kanban'
path: '/kanban'
fullPath: '/kanban'
preLoaderRoute: typeof KanbanRouteImport
parentRoute: typeof rootRouteImport
}
'/events': {
id: '/events'
path: '/events'
fullPath: '/events'
preLoaderRoute: typeof EventsRouteImport
parentRoute: typeof rootRouteImport
}
'/enquiry': {
id: '/enquiry'
path: '/enquiry'
fullPath: '/enquiry'
preLoaderRoute: typeof EnquiryRouteImport
parentRoute: typeof rootRouteImport
}
'/availability': {
id: '/availability'
path: '/availability'
fullPath: '/availability'
preLoaderRoute: typeof AvailabilityRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/': {
id: '/admin/'
path: '/admin'
fullPath: '/admin/'
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/booking/$bookingId': {
id: '/booking/$bookingId'
path: '/booking/$bookingId'
fullPath: '/booking/$bookingId'
preLoaderRoute: typeof BookingBookingIdRouteImport
parentRoute: typeof rootRouteImport
}
'/booking-form/$token': {
id: '/booking-form/$token'
path: '/booking-form/$token'
fullPath: '/booking-form/$token'
preLoaderRoute: typeof BookingFormTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/enquiries': {
id: '/admin/enquiries'
path: '/admin/enquiries'
fullPath: '/admin/enquiries'
preLoaderRoute: typeof AdminEnquiriesRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/bookings': {
id: '/admin/bookings'
path: '/admin/bookings'
fullPath: '/admin/bookings'
preLoaderRoute: typeof AdminBookingsRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/bookings/': {
id: '/admin/bookings/'
path: '/'
fullPath: '/admin/bookings/'
preLoaderRoute: typeof AdminBookingsIndexRouteImport
parentRoute: typeof AdminBookingsRoute
}
'/admin/bookings/$bookingId': {
id: '/admin/bookings/$bookingId'
path: '/$bookingId'
fullPath: '/admin/bookings/$bookingId'
preLoaderRoute: typeof AdminBookingsBookingIdRouteImport
parentRoute: typeof AdminBookingsRoute
}
}
}
interface AdminBookingsRouteChildren {
AdminBookingsBookingIdRoute: typeof AdminBookingsBookingIdRoute
AdminBookingsIndexRoute: typeof AdminBookingsIndexRoute
}
const AdminBookingsRouteChildren: AdminBookingsRouteChildren = {
AdminBookingsBookingIdRoute: AdminBookingsBookingIdRoute,
AdminBookingsIndexRoute: AdminBookingsIndexRoute,
}
const AdminBookingsRouteWithChildren = AdminBookingsRoute._addFileChildren(
AdminBookingsRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AvailabilityRoute: AvailabilityRoute,
EnquiryRoute: EnquiryRoute,
EventsRoute: EventsRoute,
KanbanRoute: KanbanRoute,
LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute,
AdminBookingsRoute: AdminBookingsRouteWithChildren,
AdminEnquiriesRoute: AdminEnquiriesRoute,
BookingFormTokenRoute: BookingFormTokenRoute,
BookingBookingIdRoute: BookingBookingIdRoute,
AdminIndexRoute: AdminIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

16
apps/app/src/router.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

129
apps/app/src/specs.md Normal file
View File

@@ -0,0 +1,129 @@
# Seminarhof — product backlog
Status legend: ✅ done · 🟡 partial · ⬜ todo · 🔮 phase 2
---
## ✅ Shipped
- **Enquiry table.** Public enquiries land in `enquiry` (not `booking`). Admin
view at `/admin/enquiries` with approve / waitlist / decline.
- **Approve flow.** Requires start + end date and a client — either pick an
existing one (searchable autosuggest) or create a new one (email required,
defaults to the enquiry contact email; rejects duplicate emails). Creates a
`reserved` booking linked via `booking.enquiry_id`.
- **`organization``client` rename** (+ `client_member` already models
multiple login users per client; one is seeded from the enquiry email).
- **`course``event` rename** across the codebase.
- **Enquiry required fields trimmed:** event name + contact email only;
privacy-policy consent only (no T&C checkbox).
- **Booking-detail "Enquiry" milestone** sources its date from the enquiry's
real submission time, not the approval timestamp.
---
## Backlog (epics)
### E1 · Calendar / availability
-**1.1** Hide `enquiry` status on public availability — show it to
admins/owners only. `getAvailability` reads the optional session and filters
out enquiry-state bookings for non-staff; the legend drops the enquiry swatch
when no such day is present.
-**1.2** Half-house: render the day cell **split by a diagonal line** (two
triangles, one per half-house booking) instead of the current dot. On hover,
show **both** events being held that day. `getAvailability` returns a
per-day `bookings[]` (deterministic order); `availability.tsx` renders the
diagonal `linear-gradient` split when two half-house bookings share a day and
lists both in the tooltip. Single half-house days keep the corner dot.
-**1.3** Hover → rich detail card for bookings that are **reserved AND
public**.
-**1.4** Waitlist dot in the top-right corner of the day cell. *(Pairs with
E6 — the wishlist/waitlist data.)*
- 🔮 **1.5** Google Calendar auto-sync. **Phase 2.**
### E2 · Actions hub + transition safety ✅
-**2.1** All booking actions consolidated into one header hub
(`BookingActions.tsx`), beside Cancel booking. `BookingStatusPanel` is now
display-only (timeline + next-action alert).
-**2.2** Every transition goes through a two-click `ConfirmButton`
(`ConfirmButton.tsx`): first click arms ("Confirm?", red); second commits;
auto-reverts after 3s. Replaced the raw `window.confirm` for cancel too.
### E3 · Contract form + invoicing *(big rock; status-model redesign approved)*
- 🟡 **3.1** Binding booking form scoped to a booking — **core fields shipped**.
Client opens a **passwordless magic link** (`/booking-form/$token`), lands
logged-in as a provisioned `client` user, and fills name/company, billing
address, phone, website, payment method (**Cash / bank transfer**), event
basics + AGB/privacy consents. Submit is legally binding (stamps
`contractResponse='approved'` + `agbAcceptedAt`/`privacyAcceptedAt`). Token =
a short-lived signed JWT via **`@pikku/jose`** (no token table). Admin mints
the link from the booking actions hub. *Deferred to E7/E5: the priced add-on
groups (extras / room equipment / materials) that feed the final invoice.*
-**3.2** Sending the contract → recorded response / binding. *(Lifecycle
work — `recordContractResponse` + the form submit both mark it binding.)*
-**3.3** Generate a **preview invoice PDF** from the form → email to client
+ admin → upload into files. *(Depends on E5 — blocked.)*
-**3.4** 14-day deadline for signing + sending the deposit. *(Lifecycle
cron: deposit `dueOn=+14d`, day-7 reminder, day-14 overdue flag.)*
-**3.5** On bank receipt, admin uploads the **official invoice** and marks
it paid → *deposit paid*. *(Mark-paid done via `recordDepositReceived`; the
upload depends on E5 — blocked.)*
-**3.6** **Status-model redesign** — done by the lifecycle work: enum is
`enquiry → reserved → confirmed → ended` (+ `cancelled`); contract-signed +
deposit-paid are sub-milestones inside `reserved`, shown as a timeline in
`BookingStatusPanel`.
*Auth note:* the magic link issues the same DB-session as password login
(reuses `lib/session.ts`); `app_user` is reused by email, `client_member`
links the user to the client org. `consumeBookingFormLink` rejects
cancelled/ended bookings and re-checks org membership.
### E4 · Admin dashboard ✅
- ✅ Required-actions hub at `/admin` (the admin landing): four "needs action"
cards — pending enquiries (with a waitlist badge), contracts to send,
contracts awaiting response, deposits outstanding — plus an "upcoming events"
column (confirmed, next 30 days). One read-only `getAdminDashboard` function
(`pikkuFunc`, `isAdminOrOwner`) aggregates the buckets from existing booking /
enquiry / invoice / contract data; each card row links to its workspace
(enquiries list or booking detail). "Deposits outstanding" ≠ overdue — the
14-day deadline lands with E7.
### E5 · File uploads *(infra prerequisite for E3.3 + event images)*
- ⬜ Upload invoices.
- ⬜ Upload public event images.
### E6 · Multi-date / wishlist enquiries
-**6.1** Enquiry form allows multiple date options, sorted by priority.
-**6.2** "Wishlist" path when dates conflict: instead of approve→booking,
keep it as a wishlist with its own workspace table. *(Builds on the existing
`waitlisted_at`.)*
### E7 · Post-confirmation editing rules + `finalizing` state
-**New lifecycle state `finalizing`** (badge "Finalizing"), entered
automatically **14 days before** start: `confirmed → finalizing → ended`.
Add the enum value, a `finalizing_at` column, and the auto-transition (cron).
- ⬜ After *confirmed*, participants (count) / rooms (allocation) / diet are
editable up to **14 days** before the event starts.
- ⬜ Entering `finalizing` (the 14-day mark) → auto-create a project/base
invoice.
- ⬜ Edits after the 14-day mark are still allowed but produce a **differing**
(delta) invoice.
- ⬜ Allergies remain editable until the event ends, with no invoice change.
### E8 · Rename `completed` → `ended` ✅
- ✅ Status value `completed``ended` and column `completed_at``ended_at`,
end-to-end (DB-first). Enum now:
`enquiry → reserved → confirmed → ended` (+ `cancelled`); `finalizing`
slots in before `ended` later via E7.
### E9 · Unified public site chrome
- ⬜ Make the public enquiry / events / availability pages match the
seminarhof-drawehn.de header + footer for one cohesive website/app feel.
---
## Next up
Quick wins **E8** + **E2** are shipped. Suggested next sequence (per epic
notes above): **E5** (file uploads — unblocks E3.3) → **E3** (contract +
invoicing, incl. status-model redesign) → **E4** (dashboard) → **E6 / E7 / E9**.

71
apps/app/src/theme.css Normal file
View File

@@ -0,0 +1,71 @@
/**
* Seminarhof Drawehn brand theme — global CSS variables + font import.
*
* Source palette extracted from seminarhof-drawehn.de Elementor kit:
* --brand-plum (secondary), --brand-gold (accent), --brand-text, --brand-bg.
* Mantine theme (theme.ts) consumes the same hex codes via createTheme.
*/
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap');
:root {
--brand-plum: #88557f;
--brand-plum-dark: #4e3149;
--brand-pink: #e9c8e3;
--brand-gold: #ffce00;
--brand-gold-soft: #fee162;
--brand-peach: #ffbc7d;
--brand-text: #181818;
--brand-muted: #989499;
--brand-bg: #f5f5f5;
}
html,
body {
background: var(--brand-bg);
color: var(--brand-text);
font-family: 'Open Sans', system-ui, sans-serif;
}
/* AppShell sidebar NavLink — brand-aligned resting / hover / active states.
Active rows get a 3 px inset plum bar plus a soft plum 14% wash. */
.mantine-NavLink-root {
border-radius: 8px;
padding: 8px 12px;
color: var(--brand-text);
position: relative;
transition: background-color 120ms ease, color 120ms ease;
}
.mantine-NavLink-root:hover {
background-color: rgba(136, 85, 127, 0.08);
}
.mantine-NavLink-root[data-active] {
background-color: rgba(136, 85, 127, 0.14);
color: var(--brand-plum-dark);
font-weight: 600;
}
.mantine-NavLink-root[data-active]::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 2px;
background: var(--brand-plum);
}
.mantine-NavLink-label {
font-size: 14px;
letter-spacing: 0.005em;
}
/* Dashboard action/upcoming rows — subtle hover affordance. */
.dash-row {
transition: background-color 120ms ease;
}
.dash-row:hover {
background-color: rgba(136, 85, 127, 0.08);
}

1
apps/app/src/theme.ts Normal file
View File

@@ -0,0 +1 @@
export { theme, brand } from '@project/mantine-theme'

24
apps/app/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

41
apps/app/vite.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import path from 'node:path'
export default defineConfig({
plugins: [
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR on
// message edits. Must run first.
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
tanstackStart({
router: {
routesDirectory: path.resolve(import.meta.dirname, 'src/pages'),
generatedRouteTree: path.resolve(import.meta.dirname, 'src/routeTree.gen.ts'),
routeFileIgnorePrefix: '-',
quoteStyle: 'single',
},
}),
react(),
],
server: {
port: 5001,
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:5003',
'/upload': 'http://localhost:5003',
'/assets': 'http://localhost:5003',
},
},
resolve: {
dedupe: ['react', 'react-dom'],
alias: {
'@': path.resolve(import.meta.dirname, 'src'),
'@project/functions-sdk': path.resolve(
import.meta.dirname,
'../../packages/functions-sdk/src',
),
},
},
})