chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-22 09:44:35 +02:00
commit c3035e16ec
161 changed files with 18517 additions and 0 deletions

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
vendor/
node_modules
dist
.vite
.tanstack
*.tsbuildinfo
# Coverage artifacts — c8's raw output and the function-coverage report
coverage/
.nyc_output/
# Runtime artifacts — dev DB, uploaded content, scratch files
/.pikku-runtime/
/.e2e-runtime/
*.db
*.db-shm
*.db-wal
# Production data — never commit
data/seed/
data/seed-scrubbed/
docs/handover/
# Generated by Cloudflare deploy bundling
packages/functions/.deploy/
.deploy/
# Generated by @pikku/cli — regenerated on `pikku all`.
# Ignore the entire .pikku/ tree (it includes meta JSON, schemas, scaffolds,
# pikku-bootstrap, the dev SQLite, etc.) and whitelist only the api.gen.ts
# stub that keeps the skeleton compilable pre-`pikku all`.
packages/functions/.pikku/
!packages/functions/.pikku/client/
packages/functions/.pikku/client/*
!packages/functions/.pikku/client/api.gen.ts
src/scaffold/*.gen.ts
packages/functions/src/pikku/*
packages/functions/src/scaffold/*.gen.ts
packages/functions/src/types/*.gen.ts
packages/functions-sdk/src/pikku/*.gen.ts
packages/functions-sdk/src/pikku/*.gen.d.ts
versions.pikku.json
# Yarn — commit the root lockfile; ignore PnP/cache and generated per-unit lockfiles
.yarn/
.pnp.*
yarn.lock
!/yarn.lock
# Playwright MCP debug artifacts
.playwright-mcp/
# Compiled classification sidecar — regenerated by `pikku db migrate` from db/annotations.ts
db/annotations.gen.json

4
.yarnrc.yml Normal file
View File

@@ -0,0 +1,4 @@
# Force node_modules linker — some toolchain deps (uWebSockets.js native
# addon, pikku CLI bin, esbuild native) resolve cleanly only with a
# traditional tree.
nodeLinker: node-modules

47
CLAUDE.md Normal file
View File

@@ -0,0 +1,47 @@
# Project conventions
## Error handling
Always use **defined error classes** for caught / expected exceptions — never
`throw new Error('some_string')`.
- Domain errors live in `packages/functions/src/errors.ts`. Each extends a
Pikku core error (`BadRequestError`, `ConflictError`, `ForbiddenError`,
`NotFoundError`, …) and is registered with `addError(...)` so it maps to the
correct HTTP status and a stable client-facing code.
- Raw `throw new Error(...)` is only acceptable for truly unexpected,
programmer-error conditions that should surface as a 500.
- Validation/contract failures (bad params, illegal state transitions, etc.)
must be a defined error so callers can branch on the type and the client
receives a meaningful status + message.
## Sessions (optional auth everywhere)
`sessionCookieMiddleware` is registered globally (`addHTTPMiddleware('*', …)`
in `wirings/middleware.ts`), so the session cookie is resolved on **every**
route — including `auth: false` / `pikkuSessionlessFunc` endpoints.
- In a sessionless function, read the optional session from the third arg:
`func: async (services, input, { session }) => { const s = await session?.get(); … }`.
`s` is `undefined` for anonymous callers and the `UserSession`
(`{ userId, role }`) for logged-in ones.
- This lets a public endpoint vary its response by viewer role (e.g. show
admins more than anonymous visitors) without a separate authenticated route.
- Use `pikkuFunc` (required session, third arg `{ session }` non-optional) only
when the route must reject anonymous callers outright.
## Database changes (Pikku DB-first pipeline)
Schema changes go **DB-first**, in this order — doing it code-first breaks the
Pikku inspector / generated types:
1. Edit the SQL (`db/sqlite/*.sql`, `db/sqlite-seed.sql`).
2. Regenerate: `yarn db:reset && yarn db:migrate`
(`pikku db migrate` refreshes `.pikku/db/schema.d.ts` and the generated
zod at `.pikku/db/zod.gen.ts`, imported as `#pikku/db/zod.gen.js`).
3. Update backend function logic against the new types.
4. Regenerate the client SDK: `yarn workspace @project/functions pikku all`.
5. Update the frontend / consumers.
This is a pre-production project — edit migrations in place rather than
appending compatibility migrations.

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",
"common_nav_role_client": "Kunde",
"common_nav_role_admin": "Admin",
"common_nav_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",
"common_pages_booking_participants_kinds_overnight": "Übernachtung",
"common_pages_booking_participants_kinds_day_guest": "Tagesgast",
"common_pages_booking_participants_dietary_omnivore": "Allesesser:in",
"common_pages_booking_participants_dietary_vegetarian": "Vegetarisch",
"common_pages_booking_participants_dietary_vegan": "Vegan",
"common_pages_booking_participants_dietary_gluten_free": "Glutenfrei",
"common_pages_booking_participants_dietary_lactose_free": "Laktosefrei",
"common_pages_booking_participants_dietary_pescatarian": "Pescetarisch",
"common_pages_booking_participants_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",
"common_pages_booking_participants_severity_mild": "Leicht",
"common_pages_booking_participants_severity_moderate": "Mittel",
"common_pages_booking_participants_severity_severe": "Schwer",
"common_pages_booking_participants_severity_standard": "Standard",
"common_pages_booking_participants_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",
"common_pages_admin_enquiries_status_pending": "Offen",
"common_pages_admin_enquiries_status_approved": "Bestätigt",
"common_pages_admin_enquiries_status_declined": "Abgelehnt",
"common_pages_admin_enquiries_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",
"common_pages_admin_booking_invoices_kind_deposit": "Anzahlung",
"common_pages_admin_booking_invoices_kind_final": "Schlussrechnung",
"common_pages_admin_booking_invoices_status_outstanding": "Offen",
"common_pages_admin_booking_invoices_status_paid": "Bezahlt",
"common_pages_admin_booking_invoices_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",
"common_pages_admin_booking_audit_emails_send_contract_and_deposit_email": "Vertrag & Anzahlungsrechnung",
"common_pages_admin_booking_audit_emails_send_deposit_reminder_email": "Anzahlungserinnerung",
"common_pages_admin_booking_audit_emails_send_reservation_confirmed_email": "Reservierung bestätigt",
"common_pages_admin_booking_audit_emails_send_booking_confirmed_email": "Buchung bestätigt",
"common_pages_admin_booking_audit_emails_send_cancellation_email": "Stornierung",
"common_pages_admin_booking_audit_emails_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",
"common_pages_admin_booking_status_flag_reason_deposit_overdue": "Anzahlung überfällig Kunde hat nicht innerhalb von 14 Tagen gezahlt.",
"common_pages_admin_booking_status_flag_reason_short_window": "Startdatum weniger als 14 Tage entfernt Zahlungsfrist sehr knapp.",
"common_pages_admin_booking_status_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",
"common_pages_admin_booking_rooms_buildings_gaestehaus": "Gästehaus",
"common_pages_admin_booking_rooms_buildings_haupthaus": "Haupthaus",
"common_pages_admin_booking_rooms_floors_eg": "Erdgeschoss",
"common_pages_admin_booking_rooms_floors_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.",
"common_pages_booking_status_label_enquiry": "Anfrage",
"common_pages_booking_status_label_reserved": "Reserviert",
"common_pages_booking_status_label_confirmed": "Bestätigt",
"common_pages_booking_status_label_ended": "Beendet",
"common_pages_booking_status_label_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.",
"common_pages_allergens_gluten_strict": "Gluten (streng)",
"common_pages_allergens_nuts": "Nüsse",
"common_pages_allergens_soy": "Soja",
"common_pages_allergens_dairy": "Milchprodukte",
"common_pages_allergens_egg": "Ei",
"common_pages_allergens_fish": "Fisch",
"common_pages_allergens_shellfish": "Meeresfrüchte",
"common_pages_allergens_sesame": "Sesam",
"common_pages_allergens_celery": "Sellerie",
"common_pages_allergens_mustard": "Senf",
"common_pages_allergens_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",
"common_nav_role_client": "Client",
"common_nav_role_admin": "Admin",
"common_nav_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",
"common_pages_booking_participants_kinds_overnight": "Overnight",
"common_pages_booking_participants_kinds_day_guest": "Day guest",
"common_pages_booking_participants_dietary_omnivore": "Omnivore",
"common_pages_booking_participants_dietary_vegetarian": "Vegetarian",
"common_pages_booking_participants_dietary_vegan": "Vegan",
"common_pages_booking_participants_dietary_gluten_free": "Gluten-free",
"common_pages_booking_participants_dietary_lactose_free": "Lactose-free",
"common_pages_booking_participants_dietary_pescatarian": "Pescatarian",
"common_pages_booking_participants_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}",
"common_pages_booking_participants_severity_mild": "Mild",
"common_pages_booking_participants_severity_moderate": "Moderate",
"common_pages_booking_participants_severity_severe": "Severe",
"common_pages_booking_participants_severity_standard": "Standard",
"common_pages_booking_participants_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",
"common_pages_admin_enquiries_status_pending": "Pending",
"common_pages_admin_enquiries_status_approved": "Approved",
"common_pages_admin_enquiries_status_declined": "Declined",
"common_pages_admin_enquiries_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",
"common_pages_admin_booking_invoices_kind_deposit": "Deposit",
"common_pages_admin_booking_invoices_kind_final": "Final",
"common_pages_admin_booking_invoices_status_outstanding": "Outstanding",
"common_pages_admin_booking_invoices_status_paid": "Paid",
"common_pages_admin_booking_invoices_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",
"common_pages_admin_booking_audit_emails_send_contract_and_deposit_email": "Contract & deposit invoice",
"common_pages_admin_booking_audit_emails_send_deposit_reminder_email": "Deposit reminder",
"common_pages_admin_booking_audit_emails_send_reservation_confirmed_email": "Reservation confirmed",
"common_pages_admin_booking_audit_emails_send_booking_confirmed_email": "Booking confirmed",
"common_pages_admin_booking_audit_emails_send_cancellation_email": "Cancellation",
"common_pages_admin_booking_audit_emails_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",
"common_pages_admin_booking_status_flag_reason_deposit_overdue": "Deposit overdue — client has not paid within 14 days.",
"common_pages_admin_booking_status_flag_reason_short_window": "Start date is less than 14 days away — deposit window is very tight.",
"common_pages_admin_booking_status_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",
"common_pages_admin_booking_rooms_buildings_gaestehaus": "Gästehaus",
"common_pages_admin_booking_rooms_buildings_haupthaus": "Haupthaus",
"common_pages_admin_booking_rooms_floors_eg": "Ground floor",
"common_pages_admin_booking_rooms_floors_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",
"common_pages_allergens_gluten_strict": "Gluten (strict)",
"common_pages_allergens_nuts": "Nuts",
"common_pages_allergens_soy": "Soy",
"common_pages_allergens_dairy": "Dairy",
"common_pages_allergens_egg": "Egg",
"common_pages_allergens_fish": "Fish",
"common_pages_allergens_shellfish": "Shellfish",
"common_pages_allergens_sesame": "Sesame",
"common_pages_allergens_celery": "Celery",
"common_pages_allergens_mustard": "Mustard",
"common_pages_allergens_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.",
"common_pages_booking_status_label_enquiry": "Enquiry",
"common_pages_booking_status_label_reserved": "Reserved",
"common_pages_booking_status_label_confirmed": "Confirmed",
"common_pages_booking_status_label_ended": "Ended",
"common_pages_booking_status_label_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"
}

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

@@ -0,0 +1,53 @@
{
"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.35",
"@pikku/fetch": "^0.12.3",
"@pikku/mantine": "^0.12.5",
"@pikku/react": "^0.12.3",
"@project/functions": "workspace:*",
"@project/functions-sdk": "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",
"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": {
"@inlang/paraglide-js": "^2.20.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,270 @@
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, mKey } from '@/i18n/messages'
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
labelKey: string
roles: Role[]
}
type Section = {
titleKey: string
items: NavItem[]
}
const SECTIONS: Section[] = [
{
titleKey: 'common.nav.sections.workspace',
items: [
{ to: '/admin', labelKey: 'common.nav.items.dashboard', roles: ['admin', 'owner'] },
{ to: '/admin/bookings', labelKey: 'common.nav.items.bookings', roles: ['admin', 'owner'] },
{ to: '/admin/enquiries', labelKey: 'common.nav.items.enquiries', roles: ['admin', 'owner'] },
{ to: '/kanban', labelKey: 'common.nav.items.kanban', roles: ['admin', 'owner'] },
],
},
{
titleKey: 'common.nav.sections.public',
items: [
{ to: '/availability', labelKey: 'common.nav.items.availability', roles: ['client', 'admin', 'owner'] },
{ to: '/enquiry', labelKey: 'common.nav.items.enquiry', roles: ['admin', 'owner'] },
{ to: '/events', labelKey: '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 }}>
{mKey(`common.nav.role.${user.role}` as any)}
</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) => {
const visible = section.items.filter((n) => n.roles.includes(role))
if (visible.length === 0) return null
return (
<Stack key={section.titleKey} gap={4}>
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={600}
px="sm"
mb={4}
style={{ letterSpacing: '0.08em' }}
>
{asI18n(`${mKey(section.titleKey as any)}`)}
</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={mKey(item.labelKey as any)}
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,176 @@
import { Alert, Anchor, Group, Stack } from '@pikku/mantine/core'
import { useState } from 'react'
import { m, mKey } 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 k = (key: string) => mKey(`common.pages.adminBooking.status.actions.${key}` as any)
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' }))}
>
{k('approveEnquiry')}
</ConfirmButton>
)}
{status === 'reserved' && !contractSentAt && (
<ConfirmButton
size="xs"
variant="light"
data-testid="action-send-contract"
loading={triggerContract.isPending || isRefreshing}
onConfirm={() => void runAndRefresh(() => triggerContract.mutate({ bookingId }))}
>
{k('sendContractNow')}
</ConfirmButton>
)}
{status === 'reserved' && (
<ConfirmButton
size="xs"
variant="light"
color="plum"
data-testid="action-create-form-link"
loading={formLink.isPending}
onConfirm={() => formLink.mutate({ bookingId })}
>
{k('createFormLink')}
</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' }))}
>
{k('recordContractApproved')}
</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' }))}
>
{k('recordContractDeclined')}
</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 }))}
>
{k('recordDepositReceived')}
</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' }))}
>
{k('markEnded')}
</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' }))}
>
{k('cancelBooking')}
</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,263 @@
import { m, mKey } from '@/i18n/messages'
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()}>
{mKey(`common.pages.adminBooking.status.flagReason.${booking.flagReason}` as any)}
</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' && mKey(`common.pages.adminBooking.status.flagReason.${action.reason}` as any)}
{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' && m.common_pages_admin_booking_status_flag_reason_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,181 @@
import { asI18n } from '@pikku/react'
import { useRef, useState } from 'react'
import { Button, Group, Paper, Stack, Text } from '@pikku/mantine/core'
export type UploadRequest = {
uploadUrl: string
contentKey: string
uploadMethod?: 'PUT' | 'POST'
uploadHeaders?: Record<string, string>
}
export type UploadedAsset = {
contentKey: string
fileName: string
contentType: string
size: number
}
type FileUploaderProps = {
accept?: string[]
compact?: boolean
disabled?: boolean
inputTestId?: string
idleLabel: string
activeLabel?: string
uploadingLabel?: string
uploadedLabel?: string
browseLabel?: string
hint?: string
requestUpload: (file: File) => Promise<UploadRequest>
onUploaded: (asset: UploadedAsset) => void
onUploadStateChange?: (uploading: boolean) => void
onError?: (error: Error) => void
}
const matchesAccept = (file: File, accept: string[]) =>
accept.length === 0 ||
accept.some((rule) => {
if (rule.endsWith('/*')) {
return file.type.startsWith(rule.slice(0, -1))
}
return file.type === rule
})
export function FileUploader({
accept = [],
compact = false,
disabled = false,
inputTestId,
idleLabel,
activeLabel,
uploadingLabel = 'Uploading...',
uploadedLabel = 'Uploaded',
browseLabel = 'Browse',
hint,
requestUpload,
onUploaded,
onUploadStateChange,
onError,
}: FileUploaderProps) {
const inputRef = useRef<HTMLInputElement | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [fileName, setFileName] = useState('')
const setUploading = (value: boolean) => {
setIsUploading(value)
onUploadStateChange?.(value)
}
const uploadFile = async (file: File) => {
if (!matchesAccept(file, accept)) {
onError?.(new Error('Unsupported file type'))
return
}
setUploading(true)
try {
const upload = await requestUpload(file)
const response = await fetch(upload.uploadUrl, {
method: upload.uploadMethod ?? 'PUT',
headers: {
...(upload.uploadHeaders ?? {}),
...(!upload.uploadHeaders?.['Content-Type'] && file.type
? { 'Content-Type': file.type }
: {}),
},
body: file,
})
if (response.status >= 400) {
throw new Error('File upload failed')
}
setFileName(file.name)
onUploaded({
contentKey: upload.contentKey,
fileName: file.name,
contentType: file.type,
size: file.size,
})
} catch (error) {
onError?.(error instanceof Error ? error : new Error('File upload failed'))
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
const label = isUploading
? uploadingLabel
: fileName
? `${uploadedLabel}: ${fileName}`
: isDragging
? activeLabel ?? idleLabel
: idleLabel
return (
<Paper
withBorder
radius="md"
p={compact ? 'xs' : 'md'}
style={{
borderStyle: 'dashed',
cursor: disabled || isUploading ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
onClick={() => {
if (!disabled && !isUploading) inputRef.current?.click()
}}
onDragOver={(e) => {
e.preventDefault()
if (!disabled && !isUploading) setIsDragging(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragging(false)
}}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
if (disabled || isUploading) return
const file = e.dataTransfer.files?.[0]
if (file) void uploadFile(file)
}}
>
<Stack gap={compact ? 4 : 'xs'}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fw={500} size={compact ? 'xs' : 'sm'}>{asI18n(`${label}`)}</Text>
<Button
size="xs"
variant="light"
loading={isUploading}
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
inputRef.current?.click()
}}
>
{asI18n(`${browseLabel}`)}
</Button>
</Group>
{hint && (
<Text c="dimmed" size={compact ? 'xs' : 'sm'}>
{asI18n(`${hint}`)}
</Text>
)}
</Stack>
<input
ref={inputRef}
hidden
type="file"
accept={accept.join(',')}
data-testid={inputTestId}
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) void uploadFile(file)
}}
/>
</Paper>
)
}

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,601 @@
import { useMemo, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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: mKey(`common.pages.booking.participants.dietary.${v}` as any),
}))
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: m.common_pages_booking_participants_kinds_overnight(),
},
{
value: 'day_guest',
label: m.common_pages_booking_participants_kinds_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' }}
>
{mKey(`common.pages.booking.participants.kinds.${g.kind}` as any)}
</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={m.common_pages_booking_participants_dietary_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
: mKey(`common.pages.booking.participants.severity.${sev}` as const)
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>{mKey(`common.pages.allergens.${allergy.allergen}` as any)}</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,492 @@
import { useMemo, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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 = mKey(`common.pages.adminBooking.rooms.buildings.${building}` as any)
const f = mKey(`common.pages.adminBooking.rooms.floors.${floor}` as any)
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,58 @@
import { Box, Group, Text } from '@pikku/mantine/core'
import { mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
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 labelKey =
props.kind === 'booking'
? `common.pages.bookingStatus.label.${props.status}`
: `common.pages.adminBooking.invoices.status.${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>{asI18n(`${mKey(labelKey as any)}`)}</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,12 @@
// Single source of truth for turning an i18next dotted key (`landing.cards.x`)
// into a Paraglide snake_case message identifier (`landing_cards_x`). Used by the
// runtime resolver `mKey` for the unavoidable dynamic cases.
export const identOf = (dotPath: string): string =>
dotPath
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.replace(/[.\-\s]+/g, '_')
.replace(/[^A-Za-z0-9_]/g, '')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase()

View File

@@ -0,0 +1,57 @@
// 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 { identOf } from './ident.js'
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>
/**
* Runtime key resolver for computed keys — `mKey(`landing.cards.${k}.title`)` or
* `mKey(MAP[k])`. Flattens the dotted key to its snake_case token and calls the
* message. Returns the raw key (and warns) on a miss, mirroring i18next's
* graceful degradation. Prefer static `m.token(...)` wherever the key is known.
*/
export const mKey = (key: string, args?: Record<string, unknown>): I18nString => {
const fn = _raw[identOf(key)]
if (!fn) {
if (typeof console !== 'undefined') console.warn(`[i18n] missing message for key "${key}"`)
return maskI18n(key) as unknown as I18nString
}
return maskI18n(fn(args)) as unknown as I18nString
}
/** Whether a message exists for a dotted key (replaces i18next `i18n.exists`). */
export const mExists = (key: string): boolean => !!_raw[identOf(key)]
/**
* Resolves an i18next `returnObjects` array (stored as indexed keys `prefix.0`,
* `prefix.1`, …) back into a list. Returns messages until the first gap.
*/
export const mList = (keyPrefix: string, args?: Record<string, unknown>): I18nString[] => {
const out: I18nString[] = []
for (let i = 0; ; i++) {
const fn = _raw[identOf(`${keyPrefix}.${i}`)]
if (!fn) break
out.push(maskI18n(fn(args)) as unknown as I18nString)
}
return out
}

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

@@ -0,0 +1,24 @@
import { createAuthClient } from 'better-auth/client'
function apiUrl(): string {
if (typeof window !== 'undefined' && (window as any).__E2E_API_URL) {
return (window as any).__E2E_API_URL as string
}
return import.meta.env.VITE_API_URL ?? '/api'
}
// 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)
}

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,259 @@
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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: mKey(`common.pages.bookingStatus.label.${s}` as any),
})),
[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,563 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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">
{mKey(`common.pages.admin.enquiries.status.${enquiry.status}` as any)}
</Badge>
{enquiry.status === 'pending' && enquiry.waitlistedAt && (
<Badge color="orange" variant="light" size="sm">
{m.common_pages_admin_enquiries_status_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: mKey(`common.pages.admin.enquiries.status.${s}` as any),
})),
[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,311 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { m, mKey } 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 tk = (k: string, opts?: Record<string, unknown>) =>
mKey(`common.pages.admin.dashboard.${k}` as any, opts ?? {})
const more = (bucket: { count: number; items: unknown[] }) =>
bucket.count > bucket.items.length ? (
<Text size="xs" c="dimmed" px="xs" pt={4}>
{tk('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={tk('cards.pendingEnquiries')}
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">
{tk('waitlisted')}
</Badge>
) : null
}
onClick={goEnquiries}
/>
))}
{more(data.pendingEnquiries)}
</ActionCard>
),
bookingCard('send', FileText, tk('cards.contractsToSend'), data.contractsToSend),
bookingCard(
'await',
FileClock,
tk('cards.contractsAwaiting'),
data.contractsAwaitingResponse,
),
bookingCard('dep', Wallet, tk('cards.depositsOutstanding'), 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}>{tk('needsAction')}</Title>
{noAction ? (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
{tk('allClear')}
</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}>{tk('upcoming')}</Title>
<Text size="xs" c="dimmed">
{tk('upcomingWindow')}
</Text>
</Group>
<Paper withBorder radius="md" p={data.upcomingEvents.count ? 'xs' : 'lg'}>
{data.upcomingEvents.count === 0 ? (
<Text size="sm" c="dimmed" ta="center">
{tk('noUpcoming')}
</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 ? ` · ${tk('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,337 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { m, mKey } 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])
const tk = (k: string, opts?: Record<string, unknown>) =>
mKey(`common.pages.bookingForm.${k}` as any, opts ?? {})
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={tk('title')} />
<Alert color="green" title={tk('success.title')}>
{tk('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={tk('title')} />
<Stack gap="lg">
<Box>
<Title order={2}>{tk('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={tk('sections.billing')}>
<TextInput
label={tk('fields.name')}
required
value={form.name}
onChange={(e) => set('name', e.currentTarget.value)}
/>
<Textarea
label={tk('fields.billingAddress')}
required
autosize
minRows={3}
value={form.billingAddress}
onChange={(e) => set('billingAddress', e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={tk('fields.contactPhone')}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={tk('fields.website')}
value={form.website}
onChange={(e) => set('website', e.currentTarget.value)}
/>
</SimpleGrid>
<Radio.Group
label={tk('fields.paymentMethod')}
value={form.paymentMethod}
onChange={(v) => set('paymentMethod', v as 'cash' | 'transfer')}
required
>
<Group mt="xs" gap="lg">
<Radio value="cash" label={tk('payment.cash')} />
<Radio value="transfer" label={tk('payment.transfer')} />
</Group>
</Radio.Group>
</Section>
<Divider />
<Section title={tk('sections.event')}>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<NumberInput
label={tk('fields.expectedPersons')}
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
min={1}
/>
<TextInput
label={tk('fields.arrivalTime')}
value={form.arrivalTime}
onChange={(e) => set('arrivalTime', e.currentTarget.value)}
/>
<TextInput
label={tk('fields.departureTime')}
value={form.departureTime}
onChange={(e) => set('departureTime', e.currentTarget.value)}
/>
</SimpleGrid>
<Textarea
label={tk('fields.dailyPlan')}
autosize
minRows={2}
value={form.dailyPlan}
onChange={(e) => set('dailyPlan', e.currentTarget.value)}
/>
<Checkbox
label={tk('fields.onlineAd')}
checked={form.onlineAd}
onChange={(e) => set('onlineAd', e.currentTarget.checked)}
/>
<Textarea
label={tk('fields.notes')}
autosize
minRows={2}
value={form.notes}
onChange={(e) => set('notes', e.currentTarget.value)}
/>
</Section>
<Divider />
<Section title={tk('sections.terms')}>
<Checkbox
label={tk('fields.acceptTerms')}
checked={form.acceptTerms}
onChange={(e) => set('acceptTerms', e.currentTarget.checked)}
/>
<Checkbox
label={tk('fields.acceptPrivacy')}
checked={form.acceptPrivacy}
onChange={(e) => set('acceptPrivacy', e.currentTarget.checked)}
/>
</Section>
{submit.error && <Alert color="red">{tk('error')}</Alert>}
<Group justify="flex-end">
<Button
color="plum"
onClick={submitForm}
disabled={!canSubmit || submit.isPending}
loading={submit.isPending}
>
{tk('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,477 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { m, mKey } from '@/i18n/messages'
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: mKey(`common.pages.booking.participants.dietary.${k}` as any),
}))
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">
{mKey(`common.pages.bookingStatus.label.${booking.status}` as any)}
</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: `${mKey(`common.pages.booking.participants.dietary.${d.tag}` as any, { defaultValue: 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(`${mKey(`common.pages.booking.participants.dietary.${d.tag}` as any, { defaultValue: 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'}>
{mKey(`common.pages.booking.participants.kinds.${p.kind}` as any)}
</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"
>
{mKey(`common.pages.allergens.${a.allergen}` as any)}
</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: m.common_pages_booking_participants_kinds_overnight() },
{ value: 'day_guest', label: m.common_pages_booking_participants_kinds_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: mKey(`common.pages.bookingStatus.label.${s}` as any),
}))}
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: mKey(`common.pages.bookingStatus.label.${pending}` as any),
})}
</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, mKey, mExists } from '@/i18n/messages'
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="eventName" />}
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="eventOutline" />}
value={form.eventOutline}
onChange={(e) => set('eventOutline', e.currentTarget.value)}
/>
<Textarea
data-testid="description"
label={<FieldLabel label={m.common_pages_enquiry_fields_description()} hint="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="dateOptions" />
{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="expectedPersons" />}
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="halfHouse" />}
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="clientName" />}
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="contactEmail" />}
required
value={form.contactEmail}
onChange={(e) => set('contactEmail', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common_pages_enquiry_fields_contact_phone()} hint="contactPhone" />}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common_pages_enquiry_fields_website()} hint="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="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="privacyAccepted" />}
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: string }) {
useLocale()
const key = `common.pages.enquiry.hints.${hint}`
if (!mExists(key)) return <>{label}</>
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{label}
<Tooltip
label={mKey(key as any)}
multiline
w={240}
withArrow
events={{ hover: true, focus: true, touch: true }}
>
<Info
size={14}
aria-label={mKey(key as any)}
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,164 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { m, mKey } from '@/i18n/messages'
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']
const COLUMN_LABELS: Record<BookingStatus, string> = {
enquiry: 'pages.bookingStatus.label.enquiry',
reserved: 'pages.bookingStatus.label.reserved',
confirmed: 'pages.bookingStatus.label.confirmed',
ended: 'pages.bookingStatus.label.ended',
cancelled: 'pages.bookingStatus.label.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 }}>
{mKey(`common.${COLUMN_LABELS[col]}` as any)}
</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);
}

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

@@ -0,0 +1,75 @@
import { createTheme, type MantineColorsTuple } from '@pikku/mantine/core'
// Seminarhof Drawehn brand palette — extracted from the public marketing site
// (seminarhof-drawehn.de Elementor "kit"): primary plum #88557F, accent gold
// #FFCE00, dark text #181818, page bg #F5F5F5. Fonts: Open Sans throughout.
//
// Mantine wants a 10-shade tuple per colour. We seed each tuple from the
// brand hex and extend lighter/darker around it; the brand value sits at
// index 6 (the default `filled` shade).
const plum: MantineColorsTuple = [
'#f7eef5',
'#e9d4e2',
'#d3a7c4',
'#bc7aa6',
'#a05c8e',
'#94487f',
'#88557F', // brand secondary
'#6f4467',
'#583651',
'#4E3149', // brand dark plum
]
const gold: MantineColorsTuple = [
'#fff8e0',
'#ffeeba',
'#ffe48f',
'#ffd95f',
'#ffd13b',
'#FFCE00', // brand accent
'#e6b900',
'#b38f00',
'#806600',
'#4d3d00',
]
const peach: MantineColorsTuple = [
'#fff4ea',
'#ffe4cc',
'#ffd2ad',
'#ffbc7d', // brand peach
'#ffa856',
'#ff9433',
'#f37e15',
'#cc6810',
'#a3520a',
'#7a3d05',
]
export const theme = createTheme({
primaryColor: 'plum',
primaryShade: { light: 6, dark: 5 },
colors: { plum, gold, peach },
fontFamily: '"Open Sans", system-ui, sans-serif',
fontFamilyMonospace: 'ui-monospace, Menlo, monospace',
headings: {
fontFamily: '"Open Sans", system-ui, sans-serif',
fontWeight: '700',
},
defaultRadius: 'md',
black: '#181818',
white: '#ffffff',
})
export const brand = {
primary: '#88557F',
primaryDark: '#4E3149',
accent: '#FFCE00',
accentSoft: '#FEE162',
pink: '#E9C8E3',
peach: '#FFBC7D',
text: '#181818',
pageBg: '#F5F5F5',
tagline: 'Arrive, Unwind, Recharge.',
} as const

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

50
db/annotations.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { DbClassificationMap } from '../packages/functions/.pikku/db/classification-map.gen.d.ts'
export const classifications = {
"account": {},
"audit_log": {},
"bathroom": {},
"booking": {
"booking_id": { security: 'public' },
"event_name": { security: 'public' },
"start_date": { security: 'public' },
"end_date": { security: 'public' },
"status": { security: 'public' },
"half_house": { security: 'public' },
"online_ad": { security: 'public' },
"cover_image_url": { security: 'public' },
"description": { security: 'public' },
"organiser_website": { security: 'public' },
"expected_persons": { security: 'public' },
"event_outline": { security: 'public' },
},
"booking_extra": {
"booking_id": { security: 'public' },
},
"booking_session": {},
"client": {
"name": { security: 'public' },
"website": { security: 'public' },
},
"client_member": {},
"enquiry": {
"half_house": { security: 'public' },
},
"enquiry_date_option": {},
"invoice": {
"booking_id": { security: 'public' },
},
"participant": {
"booking_id": { security: 'public' },
},
"participant_allergy": {},
"room": {},
"room_assignment": {
"booking_id": { security: 'public' },
},
"seminar_room": {},
"session": {},
"user": {},
"venue": {},
"verification": {},
} satisfies DbClassificationMap

632
db/sqlite-seed.sql Normal file
View File

@@ -0,0 +1,632 @@
-- Seed data for Seminarhof Drawehn dev environment.
-- Idempotent: existing rows with matching primary keys are left untouched.
-- ─── Venue ───────────────────────────────────────────────────────────
INSERT OR IGNORE INTO venue (venue_id, name, slug) VALUES
('v_drawehn', 'Seminarhof Drawehn', 'drawehn');
-- ─── Bathrooms ───────────────────────────────────────────────────────
INSERT OR IGNORE INTO bathroom (bathroom_id, venue_id, label) VALUES
('bath_1', 'v_drawehn', 'Bad Zi 1'),
('bath_2', 'v_drawehn', 'Bad Zi 2'),
('bath_3', 'v_drawehn', 'Bad Zi 3'),
('bath_4', 'v_drawehn', 'Bad Zi 4'),
('bath_5', 'v_drawehn', 'Bad Zi 5'),
('bath_6', 'v_drawehn', 'Bad Zi 6'),
('bath_7', 'v_drawehn', 'Bad Zi 7'),
('bath_8_9','v_drawehn', 'Bad Zi 8+9 (shared)'),
('bath_10', 'v_drawehn', 'Bad Zi 10'),
('bath_11', 'v_drawehn', 'Bad Zi 11'),
('bath_12', 'v_drawehn', 'Bad Zi 12'),
('bath_13', 'v_drawehn', 'Bad Zi 13'),
('bath_14', 'v_drawehn', 'Bad Zi 14'),
('bath_15', 'v_drawehn', 'Bad Zi 15'),
('bath_16', 'v_drawehn', 'Bad Zi 16'),
('bath_17', 'v_drawehn', 'Bad Zi 17'),
('bath_18', 'v_drawehn', 'Bad Zi 18'),
('bath_19_20','v_drawehn','Bad Zi 19+20 (shared)'),
('bath_21', 'v_drawehn', 'Bad Zi 21'),
('bath_22', 'v_drawehn', 'Bad Zi 22'),
('bath_23', 'v_drawehn', 'Bad Zi 23');
-- ─── Rooms ───────────────────────────────────────────────────────────
INSERT OR IGNORE INTO room
(room_id, venue_id, number, beds, room_type, building, floor, bathroom_id,
wheelchair, ground_floor, quiet, shared_bath, dogs_allowed, double_bed_140,
surcharge_eur_cents)
VALUES
('r_1', 'v_drawehn', 1, 1, 'EZ_only', 'gaestehaus', 'eg', 'bath_1', 1, 1, 0, 0, 0, 0, 0),
('r_2', 'v_drawehn', 2, 2, 'DZ', 'gaestehaus', 'eg', 'bath_2', 1, 1, 0, 0, 0, 0, 0),
('r_3', 'v_drawehn', 3, 2, 'DZ', 'gaestehaus', 'eg', 'bath_3', 0, 1, 0, 0, 0, 0, 0),
('r_4', 'v_drawehn', 4, 2, 'DZ', 'gaestehaus', 'eg', 'bath_4', 0, 1, 0, 0, 0, 0, 0),
('r_5', 'v_drawehn', 5, 3, 'MBZ', 'gaestehaus', 'eg', 'bath_5', 0, 1, 0, 0, 0, 0, 0),
('r_6', 'v_drawehn', 6, 1, 'EZ_only', 'gaestehaus', 'eg', 'bath_6', 0, 1, 0, 0, 0, 0, 0),
('r_7', 'v_drawehn', 7, 3, 'MBZ', 'gaestehaus', 'eg', 'bath_7', 0, 1, 1, 0, 0, 0, 0),
('r_8', 'v_drawehn', 8, 2, 'DZ', 'gaestehaus', 'eg', 'bath_8_9',0, 1, 1, 1, 0, 0, 0),
('r_9', 'v_drawehn', 9, 2, 'DZ', 'gaestehaus', 'eg', 'bath_8_9',0, 1, 1, 1, 0, 0, 0),
('r_10', 'v_drawehn', 10, 2, 'DZ', 'gaestehaus', 'eg', 'bath_10', 0, 1, 1, 0, 1, 0, 2000),
('r_11', 'v_drawehn', 11, 2, 'DZ', 'gaestehaus', 'eg', 'bath_11', 0, 1, 1, 0, 0, 0, 0),
('r_12', 'v_drawehn', 12, 2, 'DZ', 'gaestehaus', 'eg', 'bath_12', 0, 1, 1, 0, 0, 0, 0),
('r_13', 'v_drawehn', 13, 1, 'EZ_only', 'gaestehaus', 'og', 'bath_13', 0, 0, 0, 0, 0, 0, 0),
('r_14', 'v_drawehn', 14, 1, 'EZ_only', 'gaestehaus', 'og', 'bath_14', 0, 0, 0, 0, 0, 1, 0),
('r_15', 'v_drawehn', 15, 2, 'DZ', 'gaestehaus', 'og', 'bath_15', 0, 0, 0, 0, 0, 0, 0),
('r_16', 'v_drawehn', 16, 2, 'DZ', 'gaestehaus', 'og', 'bath_16', 0, 0, 0, 0, 0, 0, 0),
('r_17', 'v_drawehn', 17, 2, 'DZ', 'gaestehaus', 'og', 'bath_17', 0, 0, 0, 0, 0, 0, 0),
('r_18', 'v_drawehn', 18, 2, 'DZ', 'gaestehaus', 'og', 'bath_18', 0, 0, 0, 0, 0, 0, 0),
('r_19', 'v_drawehn', 19, 2, 'DZ', 'haupthaus', 'eg', 'bath_19_20',0, 1, 0, 1, 0, 0, 0),
('r_20', 'v_drawehn', 20, 3, 'DZ', 'haupthaus', 'eg', 'bath_19_20',0, 1, 0, 1, 0, 1, 0),
('r_21', 'v_drawehn', 21, 2, 'DZ', 'haupthaus', 'og', 'bath_21', 0, 0, 0, 0, 0, 0, 0),
('r_22', 'v_drawehn', 22, 1, 'EZ_only', 'haupthaus', 'og', 'bath_22', 0, 0, 0, 0, 0, 1, 0),
('r_23', 'v_drawehn', 23, 2, 'DZ', 'haupthaus', 'og', 'bath_23', 0, 0, 0, 0, 0, 0, 0);
-- ─── Seminar rooms ───────────────────────────────────────────────────
INSERT OR IGNORE INTO seminar_room (seminar_room_id, venue_id, name) VALUES
('sr_mandala', 'v_drawehn', 'Mandala'),
('sr_yogaraum', 'v_drawehn', 'Yogaraum');
-- ─── Demo client ─────────────────────────────────────────────────────
-- Users are created via better-auth sign-up (not seeded here).
-- Demo credentials (sign up via /api/auth/sign-up/email):
-- sarah@seminarhof.example → owner1234
-- christina@seminarhof.example → admin1234
-- demo@yoga-retreat.example → demo1234
INSERT OR IGNORE INTO client
(client_id, venue_id, name, is_stammgruppe, contact_email, website)
VALUES
('client_yoga', 'v_drawehn', 'Yoga Retreat e.V.', 1, 'demo@yoga-retreat.example', 'https://yoga-retreat.example');
-- ─── Demo bookings ──────────────────────────────────────────────────
INSERT OR IGNORE INTO booking
(booking_id, venue_id, client_id, event_name, start_date, end_date,
status, half_house, online_ad, expected_persons, description,
meal_time_breakfast, meal_time_lunch, meal_time_dinner, payment_method,
agb_year, agb_accepted_at, privacy_accepted_at)
VALUES
('b_yoga_2026_summer',
'v_drawehn', 'client_yoga',
'Summer Yoga Retreat 2026',
'2026-07-12', '2026-07-19',
'confirmed', 0, 1, 28,
'A week of yoga, breath work, and quiet meals in Lower Saxony.',
'08:00', '13:00', '19:00', 'transfer',
2026, '2026-02-14T10:30:00Z', '2026-02-14T10:30:00Z'),
('b_yoga_2026_winter',
'v_drawehn', 'client_yoga',
'Winter Stille 2026',
'2026-12-27', '2027-01-02',
'confirmed', 0, 0, 18,
'Year-end silent retreat.',
'08:30', '13:00', '18:30', 'transfer',
2026, '2026-04-01T09:00:00Z', '2026-04-01T09:00:00Z'),
('b_lc_enquiry',
'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat',
'2026-12-20', '2026-12-27',
'enquiry', 0, 0, 10,
'Lifecycle e2e test booking.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL),
('b_lc_reserved',
'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat (Reserved)',
'2026-12-20', '2026-12-27',
'reserved', 0, 0, 10,
'Reserved - contract not yet sent.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL),
('b_lc_contract_sent',
'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat (Contract Sent)',
'2026-12-20', '2026-12-27',
'reserved', 0, 0, 10,
'Reserved - contract sent 8 days ago.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL),
('b_lc_confirmed',
'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat (Confirmed)',
'2026-05-26', '2026-06-02',
'confirmed', 0, 0, 10,
'Confirmed - ended yesterday, ready for auto-complete.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL);
-- ─── Sample participants for the summer retreat ─────────────────────
INSERT OR IGNORE INTO participant (participant_id, booking_id, name, kind, dietary_tag) VALUES
('p_s_01', 'b_yoga_2026_summer', 'Anja Weber', 'overnight', 'vegan'),
('p_s_02', 'b_yoga_2026_summer', 'Bernd Krause', 'overnight', 'vegetarian'),
('p_s_03', 'b_yoga_2026_summer', 'Clara Hoffmann', 'overnight', 'vegan'),
('p_s_04', 'b_yoga_2026_summer', 'Dieter Lange', 'overnight', 'omnivore'),
('p_s_05', 'b_yoga_2026_summer', 'Eva Richter', 'overnight', 'gluten_free'),
('p_s_06', 'b_yoga_2026_summer', 'Frank Müller', 'overnight', NULL),
('p_s_07', 'b_yoga_2026_summer', 'Greta Schulz', 'overnight', 'vegan'),
('p_s_08', 'b_yoga_2026_summer', 'Hans Becker', 'day_guest', 'vegetarian');
INSERT OR IGNORE INTO participant_allergy (participant_id, allergen, severity) VALUES
('p_s_01', 'nuts', 'standard'),
('p_s_05', 'gluten_strict', 'separate_prep');
-- ─── Sample invoices ────────────────────────────────────────────────
INSERT OR IGNORE INTO invoice
(invoice_id, booking_id, kind, invoice_number, issued_on, due_on, paid_on, amount_cents, status)
VALUES
('inv_s_dep', 'b_yoga_2026_summer', 'deposit', '2026-001', '2026-02-15', '2026-03-01', '2026-02-22', 30000, 'paid'),
('inv_w_dep', 'b_yoga_2026_winter', 'deposit', '2026-002', '2026-04-02', '2026-04-16', '2026-04-10', 30000, 'paid'),
('inv_lc_dep', 'b_lc_contract_sent', 'deposit', 'LC-DEP-001', '2026-05-26', '2026-06-09', NULL, 30000, 'outstanding');
-- ─── Additional clients ────────────────────────────────────────────
INSERT OR IGNORE INTO client
(client_id, venue_id, name, is_stammgruppe, contact_email, contact_phone, billing_address, website)
VALUES
('client_breath', 'v_drawehn', 'Breath & Body Collective', 1, 'hi@breath-body.example', '+49 30 22221111', 'Hauptstr. 22, 10115 Berlin', 'https://breath-body.example'),
('client_silent', 'v_drawehn', 'Silent Path Sangha', 0, 'team@silent-path.example','+49 40 33334444', 'Mönckebergstr. 7, 20095 Hamburg', 'https://silent-path.example'),
('client_dance', 'v_drawehn', '5Rhythms Nord', 0, 'kontakt@5r-nord.example', '+49 511 5556677', 'Lister Meile 14, 30161 Hannover','https://5r-nord.example'),
('client_writers', 'v_drawehn', 'Schreibwerkstatt Lüneburger Heide', 0, 'mail@schreiben-heide.example', '+49 5841 991122', 'Lange Str. 4, 29410 Salzwedel', NULL),
('client_qigong', 'v_drawehn', 'Qigong Verein Hamburg e.V.', 1, 'verein@qigong-hh.example','+49 40 88991122', 'Eppendorfer Weg 12, 20259 Hamburg', NULL),
('client_workation','v_drawehn', 'Remote First GmbH', 0, 'ops@remotefirst.example', '+49 30 99887766', 'Linienstr. 3, 10119 Berlin', 'https://remotefirst.example'),
('client_choir', 'v_drawehn', 'Heide Vocal Ensemble', 0, 'info@heide-vocal.example',NULL, NULL, NULL);
-- ─── Additional users (clients linked to orgs) ─────────────────────
-- Inserted into user table (better-auth schema). Passwords must be set via /api/auth/sign-up/email.
INSERT OR IGNORE INTO "user" (id, email, name, locale, role, email_verified, created_at, updated_at) VALUES
('u_breath', 'lea@breath-body.example', 'Lea Breath', 'de', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_silent', 'tom@silent-path.example', 'Tom Silent', 'en', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_dance', 'jana@5r-nord.example', 'Jana Dance', 'de', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_writers', 'paula@schreiben-heide.example', 'Paula Schreiber','de', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_qigong', 'kai@qigong-hh.example', 'Kai Wu', 'de', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_workation','noah@remotefirst.example', 'Noah Remote', 'en', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_choir', 'sofia@heide-vocal.example', 'Sofia Vocal', 'de', 'client', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT OR IGNORE INTO client_member (client_id, user_id, role) VALUES
('client_breath', 'u_breath', 'organiser'),
('client_silent', 'u_silent', 'organiser'),
('client_dance', 'u_dance', 'organiser'),
('client_writers', 'u_writers', 'organiser'),
('client_qigong', 'u_qigong', 'organiser'),
('client_workation','u_workation','organiser'),
('client_choir', 'u_choir', 'organiser');
-- ─── Additional bookings spanning the year ─────────────────────────
INSERT OR IGNORE INTO booking
(booking_id, venue_id, client_id, event_name, start_date, end_date,
status, half_house, online_ad, expected_persons, description,
meal_time_breakfast, meal_time_lunch, meal_time_dinner, payment_method,
agb_year, agb_accepted_at, privacy_accepted_at, organiser_website, cover_image_url, notes)
VALUES
-- past, ended
('b_breath_spring',
'v_drawehn', 'client_breath',
'Spring Breath Intensive',
'2026-03-15', '2026-03-22',
'ended', 0, 1, 24,
'Pranayama and somatic breath work in the early bloom of spring.',
'08:00','13:00','19:00','transfer',
2026, '2025-11-12T09:00:00Z','2025-11-12T09:00:00Z',
'https://breath-body.example', NULL, NULL),
('b_qigong_easter',
'v_drawehn', 'client_qigong',
'Qigong Osterklausur',
'2026-04-03','2026-04-09',
'ended', 1, 0, 14,
'Half-house qigong retreat over the Easter break.',
'07:30','12:30','18:30','transfer',
2026, '2025-12-01T10:00:00Z','2025-12-01T10:00:00Z',
NULL, NULL, 'Stammgruppe — repeat client'),
-- in-flight, current
('b_writers_may',
'v_drawehn', 'client_writers',
'Schreibwoche Mai',
'2026-05-08','2026-05-14',
'confirmed', 0, 1, 16,
'A writing week with morning prompts and afternoon silence.',
'08:30','13:00','19:00','transfer',
2026, '2026-01-20T11:00:00Z','2026-01-20T11:00:00Z',
NULL, NULL, NULL),
-- upcoming, deposit paid
('b_dance_june',
'v_drawehn', 'client_dance',
'5Rhythms Solstice',
'2026-06-19','2026-06-22',
'confirmed', 1, 1, 22,
'Long-weekend movement intensive at the start of summer.',
'08:30','13:30','19:30','transfer',
2026, '2026-02-02T14:00:00Z','2026-02-02T14:00:00Z',
'https://5r-nord.example', NULL, NULL),
-- shares the solstice weekend with 5Rhythms (both half-house) → the
-- availability calendar renders Jun 2022 as a diagonal split.
('b_writers_solstice',
'v_drawehn', 'client_writers',
'Solstice Schreibzirkel',
'2026-06-20','2026-06-22',
'confirmed', 1, 1, 10,
'A small writing circle taking the other half of the house over the solstice.',
'08:30','13:00','19:00','transfer',
2026, '2026-02-10T10:00:00Z','2026-02-10T10:00:00Z',
NULL, NULL, 'Half-house — shares the weekend with 5Rhythms Solstice'),
-- upcoming, reserved
('b_workation_aug',
'v_drawehn', 'client_workation',
'Remote First Workation',
'2026-08-10','2026-08-17',
'reserved', 0, 0, 12,
'Engineering team offsite — co-working with daily morning movement.',
'08:00','13:00','19:00','transfer',
2026, '2026-03-12T16:00:00Z','2026-03-12T16:00:00Z',
'https://remotefirst.example', NULL, 'Needs strong wifi and 2nd seminar room'),
('b_silent_sept',
'v_drawehn', 'client_silent',
'Silent Autumn Sangha',
'2026-09-21','2026-09-28',
'confirmed', 0, 1, 26,
'Seven days of noble silence, sitting and walking meditation.',
'07:30','12:30','18:30','transfer',
2026, '2026-04-04T09:30:00Z','2026-04-04T09:30:00Z',
'https://silent-path.example', NULL, NULL),
('b_choir_october',
'v_drawehn', 'client_choir',
'Vocal Ensemble Probenwoche',
'2026-10-12','2026-10-18',
'confirmed', 0, 1, 20,
'Choir rehearsal week preparing the winter programme.',
'08:30','13:00','19:30','transfer',
2026, '2026-04-15T10:00:00Z','2026-04-15T10:00:00Z',
NULL, NULL, NULL),
-- new enquiry, awaiting admin approve/decline
('b_qigong_nov',
'v_drawehn', 'client_qigong',
'Qigong Spätherbst',
'2026-11-08','2026-11-13',
'enquiry', 1, 0, 12,
NULL,
'08:00','13:00','18:30','transfer',
2026, NULL, NULL,
NULL, NULL, NULL),
-- reserved, contract sent + signed, deposit pending (day 8 of 14)
('b_breath_dec',
'v_drawehn', 'client_breath',
'Atemarbeit zum Jahreswechsel',
'2026-12-12','2026-12-19',
'reserved', 0, 1, 22,
'Year-end breath work for the dark half of the year.',
'08:30','13:00','19:00','transfer',
2026, '2026-05-01T09:00:00Z','2026-05-01T09:00:00Z',
'https://breath-body.example', NULL, NULL),
-- cancelled (was reserved, fell through)
('b_dance_cancelled',
'v_drawehn', 'client_dance',
'5Rhythms Sommerfest (cancelled)',
'2026-07-25','2026-07-28',
'cancelled', 1, 0, 18,
'Cancelled due to overlap with Solstice retreat.',
'08:00','13:00','19:00','transfer',
2026, '2026-02-10T09:00:00Z','2026-02-10T09:00:00Z',
NULL, NULL, NULL);
-- ─── Additional participants for the new bookings ──────────────────
INSERT OR IGNORE INTO participant (participant_id, booking_id, name, kind, dietary_tag) VALUES
-- breath spring (ended) — 12 named
('p_b_01','b_breath_spring','Lea Breath', 'overnight','vegan'),
('p_b_02','b_breath_spring','Mara Lehmann', 'overnight','vegetarian'),
('p_b_03','b_breath_spring','Niko Petrov', 'overnight','omnivore'),
('p_b_04','b_breath_spring','Olga Schmidt', 'overnight','vegan'),
('p_b_05','b_breath_spring','Pia Brandt', 'overnight','gluten_free'),
('p_b_06','b_breath_spring','Quentin Ross', 'overnight','vegetarian'),
('p_b_07','b_breath_spring','Rosa Jung', 'overnight','vegan'),
('p_b_08','b_breath_spring','Stefan Kühn', 'overnight','omnivore'),
('p_b_09','b_breath_spring','Tara Singh', 'overnight','lactose_free'),
('p_b_10','b_breath_spring','Ulla Berger', 'day_guest','vegetarian'),
('p_b_11','b_breath_spring','Vincent Aydin', 'day_guest','vegan'),
('p_b_12','b_breath_spring','Wenke Otto', 'overnight','vegetarian'),
-- writers may (confirmed, in-flight) — 9 named
('p_w_01','b_writers_may','Paula Schreiber','overnight','vegetarian'),
('p_w_02','b_writers_may','Annika Born', 'overnight','vegan'),
('p_w_03','b_writers_may','Bjarne Holm', 'overnight','omnivore'),
('p_w_04','b_writers_may','Conny Dittmer', 'overnight','vegan'),
('p_w_05','b_writers_may','Detlef Vogel', 'overnight','gluten_free'),
('p_w_06','b_writers_may','Erika Pohl', 'overnight','vegetarian'),
('p_w_07','b_writers_may','Falk Reuter', 'overnight','omnivore'),
('p_w_08','b_writers_may','Gesa Mahler', 'day_guest','pescatarian'),
('p_w_09','b_writers_may','Henrik Lass', 'overnight','vegan'),
-- dance june — 8 named
('p_d_01','b_dance_june','Jana Dance', 'overnight','vegan'),
('p_d_02','b_dance_june','Karim Yilmaz', 'overnight','omnivore'),
('p_d_03','b_dance_june','Lena Frey', 'overnight','vegetarian'),
('p_d_04','b_dance_june','Mira Hoch', 'overnight','vegan'),
('p_d_05','b_dance_june','Nils Roth', 'overnight','vegetarian'),
('p_d_06','b_dance_june','Olaf Heim', 'overnight','omnivore'),
('p_d_07','b_dance_june','Petra Wenz', 'overnight','vegan'),
('p_d_08','b_dance_june','Quirin Albers', 'overnight','gluten_free'),
-- silent sept — 10 named
('p_si_01','b_silent_sept','Tom Silent', 'overnight','vegan'),
('p_si_02','b_silent_sept','Ute Mendel', 'overnight','vegetarian'),
('p_si_03','b_silent_sept','Vera Klein', 'overnight','vegan'),
('p_si_04','b_silent_sept','Walter Frey', 'overnight','omnivore'),
('p_si_05','b_silent_sept','Xenia Adler', 'overnight','vegan'),
('p_si_06','b_silent_sept','Yannick Bauer', 'overnight','vegetarian'),
('p_si_07','b_silent_sept','Zoe Maier', 'overnight','gluten_free'),
('p_si_08','b_silent_sept','Ada Friese', 'overnight','vegan'),
('p_si_09','b_silent_sept','Bo Tillmann', 'overnight','vegetarian'),
('p_si_10','b_silent_sept','Carla Hess', 'overnight','vegan'),
-- workation aug — 10 named
('p_wa_01','b_workation_aug','Noah Remote', 'overnight','vegetarian'),
('p_wa_02','b_workation_aug','Aisha Khan', 'overnight','vegan'),
('p_wa_03','b_workation_aug','Bruno Sá', 'overnight','omnivore'),
('p_wa_04','b_workation_aug','Chen Wei', 'overnight','pescatarian'),
('p_wa_05','b_workation_aug','Dani Lopez', 'overnight','vegan'),
('p_wa_06','b_workation_aug','Elif Demir', 'overnight','vegetarian'),
('p_wa_07','b_workation_aug','Felix Andersen','overnight','omnivore'),
('p_wa_08','b_workation_aug','Gita Rao', 'overnight','vegan'),
('p_wa_09','b_workation_aug','Henri Dubois', 'overnight','gluten_free'),
('p_wa_10','b_workation_aug','Ivy Park', 'overnight','vegan'),
-- choir october — 8 named
('p_c_01','b_choir_october','Sofia Vocal', 'overnight','vegetarian'),
('p_c_02','b_choir_october','Toni Krug', 'overnight','vegan'),
('p_c_03','b_choir_october','Una Berg', 'overnight','omnivore'),
('p_c_04','b_choir_october','Vlad Olar', 'overnight','vegan'),
('p_c_05','b_choir_october','Wim de Jong', 'overnight','vegetarian'),
('p_c_06','b_choir_october','Yuna Park', 'overnight','vegan'),
('p_c_07','b_choir_october','Zara Mahmood', 'overnight','gluten_free'),
('p_c_08','b_choir_october','Ali Khalil', 'overnight','omnivore'),
-- qigong easter (ended, half-house) — 6 named
('p_qe_01','b_qigong_easter','Kai Wu', 'overnight','vegan'),
('p_qe_02','b_qigong_easter','Sun Park', 'overnight','vegetarian'),
('p_qe_03','b_qigong_easter','Marek Liu', 'overnight','vegan'),
('p_qe_04','b_qigong_easter','Hannah Lee', 'overnight','pescatarian'),
('p_qe_05','b_qigong_easter','Iris Wang', 'overnight','vegetarian'),
('p_qe_06','b_qigong_easter','Jonas Meier','overnight','omnivore');
-- ─── Allergies sprinkled across new participants ──────────────────
INSERT OR IGNORE INTO participant_allergy (participant_id, allergen, severity) VALUES
('p_b_05','gluten_strict','separate_prep'),
('p_b_09','lactose','standard'),
('p_w_05','gluten_strict','separate_prep'),
('p_d_08','gluten_strict','separate_prep'),
('p_si_07','gluten_strict','separate_prep'),
('p_wa_04','shellfish','standard'),
('p_wa_09','gluten_strict','separate_prep'),
('p_c_07','gluten_strict','separate_prep'),
('p_c_03','peanuts','severe');
-- ─── Invoices for new bookings ─────────────────────────────────────
INSERT OR IGNORE INTO invoice
(invoice_id, booking_id, kind, invoice_number, issued_on, due_on, paid_on, amount_cents, status)
VALUES
('inv_b_dep','b_breath_spring','deposit','2026-010','2025-12-01','2025-12-15','2025-12-08', 36000,'paid'),
('inv_b_fin','b_breath_spring','final', '2026-011','2026-03-23','2026-04-06','2026-04-02',144000,'paid'),
('inv_qe_dep','b_qigong_easter','deposit','2026-012','2025-12-10','2025-12-24','2025-12-20', 21000,'paid'),
('inv_qe_fin','b_qigong_easter','final', '2026-013','2026-04-10','2026-04-24','2026-04-22', 84000,'paid'),
('inv_w_dep_writers','b_writers_may','deposit','2026-014','2026-02-01','2026-02-15','2026-02-09', 24000,'paid'),
('inv_d_dep','b_dance_june','deposit','2026-015','2026-02-15','2026-03-01','2026-02-22', 33000,'paid'),
('inv_si_dep','b_silent_sept','deposit','2026-016','2026-04-15','2026-04-29',NULL, 39000,'outstanding'),
('inv_c_dep','b_choir_october','deposit','2026-017','2026-04-20','2026-05-04','2026-04-30', 30000,'paid'),
('inv_wa_dep','b_workation_aug','deposit','2026-018','2026-03-20','2026-04-03',NULL, 18000,'outstanding'),
('inv_bd_dep','b_breath_dec','deposit','2026-019','2026-05-13','2026-05-27',NULL, 33000,'outstanding');
-- ─── Lifecycle milestone state ────────────────────────────────────
-- Status-transition timestamps (migration 0007) and contract milestones.
-- confirmed bookings: reserved → contract → deposit → confirmed
UPDATE booking SET
reserved_at = '2026-02-15T10:00:00Z',
contract_sent_at = '2026-02-15T10:00:00Z',
contract_response = 'approved', contract_response_at = '2026-02-18T14:00:00Z',
confirmed_at = '2026-02-22T09:00:00Z'
WHERE booking_id = 'b_yoga_2026_summer';
UPDATE booking SET
reserved_at = '2026-04-02T10:00:00Z',
contract_sent_at = '2026-04-02T10:00:00Z',
contract_response = 'approved', contract_response_at = '2026-04-05T14:00:00Z',
confirmed_at = '2026-04-10T09:00:00Z'
WHERE booking_id = 'b_yoga_2026_winter';
UPDATE booking SET
reserved_at = '2025-11-13T10:00:00Z',
contract_sent_at = '2025-11-13T10:00:00Z',
contract_response = 'approved', contract_response_at = '2025-11-16T14:00:00Z',
confirmed_at = '2025-12-08T09:00:00Z',
ended_at = '2026-03-23T12:00:00Z'
WHERE booking_id = 'b_breath_spring';
UPDATE booking SET
reserved_at = '2025-12-02T10:00:00Z',
contract_sent_at = '2025-12-02T10:00:00Z',
contract_response = 'approved', contract_response_at = '2025-12-05T14:00:00Z',
confirmed_at = '2025-12-20T09:00:00Z',
ended_at = '2026-04-10T12:00:00Z'
WHERE booking_id = 'b_qigong_easter';
UPDATE booking SET
reserved_at = '2026-01-21T10:00:00Z',
contract_sent_at = '2026-01-21T10:00:00Z',
contract_response = 'approved', contract_response_at = '2026-01-24T14:00:00Z',
confirmed_at = '2026-02-09T09:00:00Z'
WHERE booking_id = 'b_writers_may';
UPDATE booking SET
reserved_at = '2026-02-03T10:00:00Z',
contract_sent_at = '2026-02-03T10:00:00Z',
contract_response = 'approved', contract_response_at = '2026-02-06T14:00:00Z',
confirmed_at = '2026-02-22T09:00:00Z'
WHERE booking_id = 'b_dance_june';
UPDATE booking SET
reserved_at = '2026-04-05T10:00:00Z',
contract_sent_at = '2026-04-05T10:00:00Z',
contract_response = 'approved', contract_response_at = '2026-04-08T14:00:00Z',
confirmed_at = '2026-04-15T09:00:00Z'
WHERE booking_id = 'b_silent_sept';
UPDATE booking SET
reserved_at = '2026-04-16T10:00:00Z',
contract_sent_at = '2026-04-16T10:00:00Z',
contract_response = 'approved', contract_response_at = '2026-04-19T14:00:00Z',
confirmed_at = '2026-04-30T09:00:00Z'
WHERE booking_id = 'b_choir_october';
UPDATE booking SET
reserved_at = '2026-06-03T10:00:00Z'
WHERE booking_id = 'b_lc_reserved';
UPDATE booking SET
reserved_at = '2026-06-03T10:00:00Z',
contract_sent_at = '2026-05-26'
WHERE booking_id = 'b_lc_contract_sent';
UPDATE booking SET
reserved_at = '2026-05-19T10:00:00Z',
contract_sent_at = '2026-05-19T10:00:00Z',
contract_response = 'approved',
contract_response_at = '2026-05-22T14:00:00Z',
confirmed_at = '2026-05-26T09:00:00Z',
ended_at = '2026-06-02T12:00:00Z'
WHERE booking_id = 'b_lc_confirmed';
-- reserved booking: contract sent + signed, deposit pending, day 8 of 14 (healthy)
UPDATE booking SET
reserved_at = '2026-05-02T10:00:00Z',
contract_sent_at = '2026-05-13T09:00:00Z',
contract_response = 'approved',
contract_response_at = '2026-05-15T14:00:00Z',
deposit_reminder_sent_at = '2026-05-20T06:00:00Z'
WHERE booking_id = 'b_breath_dec';
-- reserved booking: contract sent + signed, deposit overdue → flagged
UPDATE booking SET
reserved_at = '2026-03-13T10:00:00Z',
contract_sent_at = '2026-03-20T09:00:00Z',
contract_response = 'approved',
contract_response_at = '2026-03-24T11:00:00Z',
deposit_reminder_sent_at = '2026-03-27T06:00:00Z',
flagged_at = '2026-04-03T06:00:00Z',
flag_reason = 'deposit_overdue'
WHERE booking_id = 'b_workation_aug';
-- cancelled booking
UPDATE booking SET
reserved_at = '2026-02-11T10:00:00Z',
cancelled_at = '2026-02-25T09:00:00Z'
WHERE booking_id = 'b_dance_cancelled';
-- ─── Sample room assignments (so the room-map view is populated) ────
INSERT OR IGNORE INTO room_assignment (booking_id, participant_id, room_id) VALUES
('b_writers_may','p_w_01','r_1'),
('b_writers_may','p_w_02','r_2'),
('b_writers_may','p_w_03','r_3'),
('b_writers_may','p_w_04','r_4'),
('b_writers_may','p_w_05','r_5'),
('b_writers_may','p_w_06','r_6'),
('b_writers_may','p_w_07','r_7'),
('b_writers_may','p_w_09','r_15');
-- ─── Audit log — status transition history for seeded bookings ───────
INSERT OR IGNORE INTO audit_log (audit_id, venue_id, user_id, entity, entity_id, field, before_value, after_value, action, at) VALUES
-- b_yoga_2026_summer: enquiry → reserved → confirmed
('al_ys_1', 'v_drawehn', NULL, 'booking', 'b_yoga_2026_summer', 'status', 'enquiry', 'reserved', 'update', '2026-02-15T10:00:00Z'),
('al_ys_2', 'v_drawehn', NULL, 'booking', 'b_yoga_2026_summer', 'status', 'reserved', 'confirmed', 'update', '2026-02-22T09:00:00Z'),
-- b_yoga_2026_winter: enquiry → reserved → confirmed
('al_yw_1', 'v_drawehn', NULL, 'booking', 'b_yoga_2026_winter', 'status', 'enquiry', 'reserved', 'update', '2026-04-02T10:00:00Z'),
('al_yw_2', 'v_drawehn', NULL, 'booking', 'b_yoga_2026_winter', 'status', 'reserved', 'confirmed', 'update', '2026-04-10T09:00:00Z'),
-- b_breath_spring: enquiry → reserved → confirmed → ended
('al_bs_1', 'v_drawehn', NULL, 'booking', 'b_breath_spring', 'status', 'enquiry', 'reserved', 'update', '2025-11-13T10:00:00Z'),
('al_bs_2', 'v_drawehn', NULL, 'booking', 'b_breath_spring', 'status', 'reserved', 'confirmed', 'update', '2025-12-08T09:00:00Z'),
('al_bs_3', 'v_drawehn', NULL, 'booking', 'b_breath_spring', 'status', 'confirmed', 'ended', 'update', '2026-03-23T12:00:00Z'),
-- b_qigong_easter: enquiry → reserved → confirmed → ended
('al_qe_1', 'v_drawehn', NULL, 'booking', 'b_qigong_easter', 'status', 'enquiry', 'reserved', 'update', '2025-12-02T10:00:00Z'),
('al_qe_2', 'v_drawehn', NULL, 'booking', 'b_qigong_easter', 'status', 'reserved', 'confirmed', 'update', '2025-12-20T09:00:00Z'),
('al_qe_3', 'v_drawehn', NULL, 'booking', 'b_qigong_easter', 'status', 'confirmed', 'ended', 'update', '2026-04-10T12:00:00Z'),
-- b_writers_may: enquiry → reserved → confirmed
('al_wm_1', 'v_drawehn', NULL, 'booking', 'b_writers_may', 'status', 'enquiry', 'reserved', 'update', '2026-01-21T10:00:00Z'),
('al_wm_2', 'v_drawehn', NULL, 'booking', 'b_writers_may', 'status', 'reserved', 'confirmed', 'update', '2026-02-09T09:00:00Z'),
-- b_dance_june: enquiry → reserved → confirmed
('al_dj_1', 'v_drawehn', NULL, 'booking', 'b_dance_june', 'status', 'enquiry', 'reserved', 'update', '2026-02-03T10:00:00Z'),
('al_dj_2', 'v_drawehn', NULL, 'booking', 'b_dance_june', 'status', 'reserved', 'confirmed', 'update', '2026-02-22T09:00:00Z'),
-- b_workation_aug: enquiry → reserved
('al_wa_1', 'v_drawehn', NULL, 'booking', 'b_workation_aug', 'status', 'enquiry', 'reserved', 'update', '2026-03-13T10:00:00Z'),
-- b_silent_sept: enquiry → reserved → confirmed
('al_ss_1', 'v_drawehn', NULL, 'booking', 'b_silent_sept', 'status', 'enquiry', 'reserved', 'update', '2026-04-05T10:00:00Z'),
('al_ss_2', 'v_drawehn', NULL, 'booking', 'b_silent_sept', 'status', 'reserved', 'confirmed', 'update', '2026-04-15T09:00:00Z'),
-- lifecycle fixtures
('al_lcr_1', 'v_drawehn', NULL, 'booking', 'b_lc_reserved', 'status', 'enquiry', 'reserved', 'update', '2026-06-03T10:00:00Z'),
('al_lcc_1', 'v_drawehn', NULL, 'booking', 'b_lc_contract_sent', 'status', 'enquiry', 'reserved', 'update', '2026-06-03T10:00:00Z'),
('al_lcf_1', 'v_drawehn', NULL, 'booking', 'b_lc_confirmed', 'status', 'enquiry', 'reserved', 'update', '2026-05-19T10:00:00Z'),
('al_lcf_2', 'v_drawehn', NULL, 'booking', 'b_lc_confirmed', 'status', 'reserved', 'confirmed', 'update', '2026-05-26T09:00:00Z'),
('al_lcf_3', 'v_drawehn', NULL, 'booking', 'b_lc_confirmed', 'status', 'confirmed', 'ended', 'update', '2026-06-02T12:00:00Z'),
-- b_choir_october: enquiry → reserved → confirmed
('al_co_1', 'v_drawehn', NULL, 'booking', 'b_choir_october', 'status', 'enquiry', 'reserved', 'update', '2026-04-16T10:00:00Z'),
('al_co_2', 'v_drawehn', NULL, 'booking', 'b_choir_october', 'status', 'reserved', 'confirmed', 'update', '2026-04-30T09:00:00Z'),
-- b_breath_dec: enquiry → reserved
('al_bd_1', 'v_drawehn', NULL, 'booking', 'b_breath_dec', 'status', 'enquiry', 'reserved', 'update', '2026-05-02T10:00:00Z'),
-- b_dance_cancelled: enquiry → reserved → cancelled
('al_dc_1', 'v_drawehn', NULL, 'booking', 'b_dance_cancelled', 'status', 'enquiry', 'reserved', 'update', '2026-02-11T10:00:00Z'),
('al_dc_2', 'v_drawehn', NULL, 'booking', 'b_dance_cancelled', 'status', 'reserved', 'cancelled', 'update', '2026-02-25T09:00:00Z');
-- ─── Public enquiries — awaiting admin triage ──────────────────────
-- Dates live in enquiry_date_option (priority-ordered), not on the enquiry.
INSERT OR IGNORE INTO enquiry
(enquiry_id, venue_id, contact_name, contact_email, contact_phone, website,
event_name, event_outline, description,
expected_persons, half_house, notes, privacy_accepted_at, status,
waitlisted_at, approved_at, declined_at, created_at) VALUES
-- Fresh pending enquiry with a single date option
('enq_pottery', 'v_drawehn', 'Lena Hoffmann', 'lena.hoffmann@example.com', '+49 170 1112233', 'https://tonundton.example.com',
'Pottery & Clay Retreat', 'A hands-on weekend of wheel-throwing and glazing.', 'Small group ceramics retreat with daily guided sessions.',
12, 0, 'Would need kiln access if possible.', '2026-05-18T14:30:00Z', 'pending',
NULL, NULL, NULL, '2026-05-18T14:30:00Z'),
-- Pending enquiry, no dates yet (flexible) — zero date options
('enq_songwriting', 'v_drawehn', 'Marco Velten', 'marco@vamp-music.example.com', NULL, NULL,
'Songwriting Intensive', 'Collaborative songwriting and recording.', 'Looking for a quiet space, flexible on timing in autumn.',
8, 1, NULL, '2026-05-20T09:10:00Z', 'pending',
NULL, NULL, NULL, '2026-05-20T09:10:00Z'),
-- Waitlisted (dates clash with an existing booking) — two date options
('enq_breathwork', 'v_drawehn', 'Sophie Klar', 'sophie.klar@example.com', '+49 151 9988776', NULL,
'Breathwork Weekend', 'Guided breathwork and cold exposure.', 'Preferred dates may overlap another group.',
20, 0, 'Flexible by a week either side.', '2026-05-12T16:45:00Z', 'pending',
'2026-05-15T11:00:00Z', NULL, NULL, '2026-05-12T16:45:00Z');
INSERT OR IGNORE INTO enquiry_date_option
(option_id, enquiry_id, start_date, end_date, priority) VALUES
('edo_pottery_1', 'enq_pottery', '2026-09-18', '2026-09-20', 0),
('edo_breath_1', 'enq_breathwork', '2026-10-09', '2026-10-11', 0),
('edo_breath_2', 'enq_breathwork', '2026-10-16', '2026-10-18', 1);
-- ─── Demo auth accounts (better-auth credentials) ──────────────────────
-- Generated by .tmp-gen-seed-accounts.mjs. These let demo users log in
-- directly from the seed; passwords are documented below. The account.password
-- is a better-auth scrypt hash (salt:hash, self-contained → portable).
-- sarah@seminarhof.example → owner1234
-- christina@seminarhof.example → admin1234
-- demo@yoga-retreat.example → demo1234
-- <all client users> → client1234
INSERT OR IGNORE INTO "user" (id, email, name, locale, role, email_verified, created_at, updated_at) VALUES
('u_sarah', 'sarah@seminarhof.example', 'Sarah Drawehn', 'de', 'owner', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_christina', 'christina@seminarhof.example', 'Christina Admin', 'de', 'admin', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('u_demo', 'demo@yoga-retreat.example', 'Demo Organiser', 'en', 'client', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT OR IGNORE INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at) VALUES
('acct_u_sarah', 'u_sarah', 'credential', 'u_sarah', '3d22bc6eefcbad9f528ec3b3138a9520:c084a3eec4a456ed966e78e77b0f0e3b8cfc1531eb8d06379e8831fc9b97426feacde413767f4a765c7eb907bfe15c84b6ccba26bb3048adc711811f0e45af31', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_christina', 'u_christina', 'credential', 'u_christina', '7d8cccdd9a221d22c77163cf5f386c0b:f0e8f680914ffab822d2ceaf172469c15352c5e5470397d149079c1c077915812beabc8a167f85b59e506f33e769bb1b1018a55820e7b66c13c35965a6ccde71', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_demo', 'u_demo', 'credential', 'u_demo', '8048a18c1e5f5174f7d6741401d0e5da:6cceb3546aec90ad4e27c3fcea0da435eab4d85ecc3deb68f288acc01774b9fe85c1a2d514804d047e01df56435a5bff1affde91196b18883a43e0dea79dbceb', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_breath', 'u_breath', 'credential', 'u_breath', '3042ba9c3b893b91f28cb45fccdd6766:7ddbe83af827f84e83dea9302388d007b96ab484025339eb70260849531d5897f4128bbf3a789bf6b49ddc1997659ccdcd9239e1e5e2dfe1a9f482913f3050c6', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_silent', 'u_silent', 'credential', 'u_silent', 'ebbf8f286058ddf0023424706daed7cd:04286f7173acdc6c6e146eba2b47a21631a964cb7987f354c1a12958379abb74fc89f2c34f6ccb15aa557de969c0b88f88f6169eb380c66c6d75bedd37ee3d95', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_dance', 'u_dance', 'credential', 'u_dance', 'aea4f8e5cd2abb13d4bd311762279a17:67f8a7435161108bd4561c60e8eac1c5ddccac31bdac3b66fb465dee0dd6787feec5a84ca3d2a8fb94370ccdfeac44d3d7417d7c2e1d5e0bc6582c7670b97fdd', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_writers', 'u_writers', 'credential', 'u_writers', 'fdb4f899224c45faaad0eebcc119047a:99107e9232e7114a653bcf29702312ef1ceeb4277135fcc6ff0a69034076a8cb066d3a247478b7844446a5466d32b6402995dfff9efbfa6194e1a8369ed90bf0', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_qigong', 'u_qigong', 'credential', 'u_qigong', '61b26a60ec6ffa7a8c06e21c160a05d3:0dbe469c60e36268152bb32629a14b3502ac88cf812191f92d886d9080e8807e8820dcb8c34999ff6dc9d5fbf109731dfcfc1e483dd9f4d5e2ed3cb0f23edc0a', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_workation', 'u_workation', 'credential', 'u_workation', '6fca2b7798eb61b72f70935fdd8fcd78:8160b10d86917d3e0a36910cc930a47989ca998a5ed3af688873b2ee8dbd82ada7fde4f71578c0a141ae2525d5b1f766ca6616a38b411b841b7d31bd28369580', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('acct_u_choir', 'u_choir', 'credential', 'u_choir', '129e67fd24ef2bb88126febc0bf9a951:3e0700ae7f90a1a623ccac1748fb2d928dd56b0a0b3bb8a84647eda17ac726690b89bdabb9e215d85fb5db2c496e93c51d3e784a6c1096b197080ac4a8507fa9', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');

10
db/sqlite/0001-init.sql Normal file
View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS kanban_card (
card_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo',
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_kanban_card_status_position
ON kanban_card (status, position);

View File

@@ -0,0 +1,214 @@
-- Drop kanban template scaffold.
DROP TABLE IF EXISTS kanban_card;
-- Seminarhof Drawehn core schema.
-- Multi-tenant from day one (venue_id on every domain row).
-- All money stored as integer cents (euro_cents) to avoid float drift.
-- ─────────────────────────────────────────────────────────────────────
-- Tenancy
CREATE TABLE IF NOT EXISTS venue (
venue_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
locale_default TEXT NOT NULL DEFAULT 'de',
meal_window_breakfast TEXT NOT NULL DEFAULT '07:00-10:00',
meal_window_lunch TEXT NOT NULL DEFAULT '12:00-14:00',
meal_window_dinner TEXT NOT NULL DEFAULT '18:00-20:00',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─────────────────────────────────────────────────────────────────────
-- Identity
CREATE TABLE IF NOT EXISTS app_user (
user_id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
locale TEXT NOT NULL DEFAULT 'de',
role TEXT NOT NULL, -- 'admin' | 'client' | 'owner'
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS client (
client_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
name TEXT,
is_stammgruppe INTEGER NOT NULL DEFAULT 0,
billing_address TEXT,
contact_email TEXT,
contact_phone TEXT,
website TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS client_member (
client_id TEXT NOT NULL REFERENCES client(client_id),
user_id TEXT NOT NULL REFERENCES app_user(user_id),
role TEXT NOT NULL DEFAULT 'organiser', -- 'organiser' | 'colleague'
PRIMARY KEY (client_id, user_id)
);
-- ─────────────────────────────────────────────────────────────────────
-- Physical inventory
CREATE TABLE IF NOT EXISTS bathroom (
bathroom_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
label TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS room (
room_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
number INTEGER NOT NULL,
beds INTEGER NOT NULL,
room_type TEXT NOT NULL, -- 'MBZ' | 'DZ' | 'EZ_only'
building TEXT NOT NULL, -- 'gaestehaus' | 'haupthaus'
floor TEXT NOT NULL, -- 'eg' | 'og'
bathroom_id TEXT REFERENCES bathroom(bathroom_id),
wheelchair INTEGER NOT NULL DEFAULT 0,
ground_floor INTEGER NOT NULL DEFAULT 0,
quiet INTEGER NOT NULL DEFAULT 0,
shared_bath INTEGER NOT NULL DEFAULT 0,
dogs_allowed INTEGER NOT NULL DEFAULT 0,
double_bed_140 INTEGER NOT NULL DEFAULT 0,
allergy_friendly INTEGER NOT NULL DEFAULT 0,
surcharge_eur_cents INTEGER NOT NULL DEFAULT 0,
UNIQUE (venue_id, number)
);
CREATE TABLE IF NOT EXISTS seminar_room (
seminar_room_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
name TEXT NOT NULL
);
-- ─────────────────────────────────────────────────────────────────────
-- Bookings
CREATE TABLE IF NOT EXISTS booking (
booking_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
client_id TEXT NOT NULL REFERENCES client(client_id),
event_name TEXT NOT NULL,
start_date TEXT,
end_date TEXT,
status TEXT NOT NULL, -- enquiry | form_received | reserved | deposit_paid | confirmed | ended | cancelled
half_house INTEGER NOT NULL DEFAULT 0,
online_ad INTEGER NOT NULL DEFAULT 0,
cover_image_url TEXT,
short_description TEXT,
organiser_website TEXT,
expected_persons INTEGER,
meal_time_breakfast TEXT,
meal_time_lunch TEXT,
meal_time_dinner TEXT,
daily_plan TEXT,
arrival_time TEXT,
departure_time TEXT,
payment_method TEXT NOT NULL DEFAULT 'transfer', -- 'cash' | 'transfer'
agb_year INTEGER,
agb_accepted_at TEXT,
privacy_accepted_at TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_booking_venue_dates
ON booking (venue_id, start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_booking_client
ON booking (client_id);
CREATE INDEX IF NOT EXISTS idx_booking_status
ON booking (venue_id, status);
CREATE TABLE IF NOT EXISTS booking_extra (
booking_extra_id TEXT PRIMARY KEY,
booking_id TEXT NOT NULL REFERENCES booking(booking_id),
kind TEXT NOT NULL, -- 'cake' | 'second_seminar_room' | 'early_arrival' | 'bedlinen' | 'massage_bench' | 'sauna'
qty INTEGER NOT NULL DEFAULT 1,
on_dates TEXT, -- JSON array of dates for cake-day picker
unit_price_cents INTEGER NOT NULL DEFAULT 0,
note TEXT
);
-- ─────────────────────────────────────────────────────────────────────
-- Participants
CREATE TABLE IF NOT EXISTS participant (
participant_id TEXT PRIMARY KEY,
booking_id TEXT NOT NULL REFERENCES booking(booking_id),
name TEXT NOT NULL,
email TEXT,
age INTEGER,
kind TEXT NOT NULL DEFAULT 'overnight', -- 'overnight' | 'day_guest'
dietary_tag TEXT, -- 'omnivore' | 'vegetarian' | 'vegan' | 'gluten_free' | 'lactose_free' | 'pescatarian' | 'unknown'
free_text TEXT,
self_form_token TEXT UNIQUE,
self_form_submitted_at TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_participant_booking
ON participant (booking_id);
CREATE TABLE IF NOT EXISTS participant_allergy (
participant_id TEXT NOT NULL REFERENCES participant(participant_id),
allergen TEXT NOT NULL, -- 'nuts' | 'soy' | 'gluten_strict' | 'dairy' | 'egg' | 'fish' | 'other'
severity TEXT NOT NULL DEFAULT 'standard', -- 'standard' | 'separate_prep'
note TEXT,
PRIMARY KEY (participant_id, allergen)
);
CREATE TABLE IF NOT EXISTS room_assignment (
booking_id TEXT NOT NULL REFERENCES booking(booking_id),
participant_id TEXT NOT NULL REFERENCES participant(participant_id),
room_id TEXT NOT NULL REFERENCES room(room_id),
PRIMARY KEY (booking_id, participant_id)
);
CREATE INDEX IF NOT EXISTS idx_room_assignment_room
ON room_assignment (room_id);
-- ─────────────────────────────────────────────────────────────────────
-- Invoicing
CREATE TABLE IF NOT EXISTS invoice (
invoice_id TEXT PRIMARY KEY,
booking_id TEXT NOT NULL REFERENCES booking(booking_id),
kind TEXT NOT NULL, -- 'deposit' | 'final'
invoice_number TEXT NOT NULL UNIQUE,
issued_on TEXT NOT NULL,
due_on TEXT,
paid_on TEXT,
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'outstanding', -- 'outstanding' | 'paid' | 'cancelled'
pdf_url TEXT,
datev_ref TEXT
);
CREATE INDEX IF NOT EXISTS idx_invoice_booking
ON invoice (booking_id);
-- ─────────────────────────────────────────────────────────────────────
-- Audit
CREATE TABLE IF NOT EXISTS audit_log (
audit_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
user_id TEXT REFERENCES app_user(user_id),
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
field TEXT,
before_value TEXT,
after_value TEXT,
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_entity
ON audit_log (entity, entity_id);

View File

@@ -0,0 +1,12 @@
-- Auth sessions: opaque cookie token → user_id mapping.
-- We avoid JWT to keep the dependency footprint small; revocation is just DELETE.
CREATE TABLE session (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES app_user(user_id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX session_user_idx ON session(user_id);
CREATE INDEX session_expires_idx ON session(expires_at);

View File

@@ -0,0 +1,4 @@
-- Password support for app_user. Format stored: pbkdf2:<iterations>:<salt_b64>:<hash_b64>
-- Existing rows have NULL password_hash and cannot log in until one is set.
ALTER TABLE app_user ADD COLUMN password_hash TEXT;

View File

@@ -0,0 +1,4 @@
-- Rename short_description -> description (the field is multi-sentence prose, not a
-- "short" blurb) and add a separate one-line event_outline captured on the enquiry form.
ALTER TABLE booking RENAME COLUMN short_description TO description;
ALTER TABLE booking ADD COLUMN event_outline TEXT;

View File

@@ -0,0 +1,17 @@
-- Booking lifecycle milestones.
--
-- The status enum is reshaped to a lean "bucket": enquiry | reserved |
-- confirmed | ended | cancelled. The contract + deposit progression that
-- runs inside `reserved` (over ~1 year) is tracked as the columns below plus the
-- deposit row on the invoice table — NOT as extra statuses. A daily cron reads
-- these columns; nothing here runs a long-lived workflow.
ALTER TABLE booking ADD COLUMN contract_sent_at TEXT; -- ISO; day-0 anchor + R1 idempotency guard
ALTER TABLE booking ADD COLUMN contract_response TEXT; -- 'approved' | 'declined' | null (pending)
ALTER TABLE booking ADD COLUMN contract_response_at TEXT; -- ISO
ALTER TABLE booking ADD COLUMN deposit_reminder_sent_at TEXT; -- ISO; R2 idempotency guard
ALTER TABLE booking ADD COLUMN flagged_at TEXT; -- ISO; set when admin review is needed
ALTER TABLE booking ADD COLUMN flag_reason TEXT; -- 'deposit_overdue' | 'short_window' | 'contract_declined'
-- Collapse the two retired statuses onto the new bucket (for any non-reset DB).
UPDATE booking SET status = 'reserved' WHERE status = 'form_received';
UPDATE booking SET status = 'confirmed' WHERE status = 'deposit_paid';

View File

@@ -0,0 +1,6 @@
-- Status transition timestamps. Set by applyBookingTransition when each
-- bucket is entered for the first time. No backfill — existing rows get NULL.
ALTER TABLE booking ADD COLUMN reserved_at TEXT;
ALTER TABLE booking ADD COLUMN confirmed_at TEXT;
ALTER TABLE booking ADD COLUMN ended_at TEXT;
ALTER TABLE booking ADD COLUMN cancelled_at TEXT;

View File

@@ -0,0 +1,64 @@
-- Public enquiries get their own table and lifecycle, separate from bookings.
-- A public submission lands here as `pending`. An admin then either:
-- approve → status=approved, a booking is created (copy) referencing this
-- enquiry via booking.enquiry_id (admin picks start/end + a client)
-- waitlist → waitlisted_at set (requires ≥1 date option); stays pending so it
-- can still be approved or declined later
-- declined → status=declined
--
-- `waitlisted_at` is a parallel timestamp on a pending row, NOT a status — a
-- waitlisted enquiry is still pending and can be approved or declined.
--
-- Dates live in `enquiry_date_option` (1-to-N, priority-ordered), NOT on the
-- enquiry row — a visitor can propose several date ranges in priority order
-- (E6.1). The enquiry has no single start/end of its own; reads that need a
-- "preferred" date pick the lowest-priority option.
CREATE TABLE IF NOT EXISTS enquiry (
enquiry_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
-- Contact details from the public form. No client row exists yet — one is
-- selected or created on approval.
contact_name TEXT,
contact_email TEXT NOT NULL,
contact_phone TEXT,
website TEXT,
-- Event details.
event_name TEXT NOT NULL,
event_outline TEXT,
description TEXT,
expected_persons INTEGER,
half_house INTEGER NOT NULL DEFAULT 0,
notes TEXT,
privacy_accepted_at TEXT,
-- Lifecycle.
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | declined
waitlisted_at TEXT,
approved_at TEXT,
declined_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_enquiry_venue_status
ON enquiry (venue_id, status);
-- Prioritized date options for an enquiry (E6.1). `priority` is 0-based; 0 is
-- the visitor's first choice. Optional: an enquiry may have zero options.
CREATE TABLE IF NOT EXISTS enquiry_date_option (
option_id TEXT PRIMARY KEY,
enquiry_id TEXT NOT NULL REFERENCES enquiry(enquiry_id) ON DELETE CASCADE,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_enquiry_date_option_enquiry
ON enquiry_date_option (enquiry_id, priority);
-- A booking may originate from an approved enquiry. NULL for admin-created
-- bookings. UNIQUE so an enquiry maps to at most one booking (SQLite allows
-- many NULLs in a unique index).
ALTER TABLE booking ADD COLUMN enquiry_id TEXT REFERENCES enquiry(enquiry_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_booking_enquiry
ON booking (enquiry_id);

View File

@@ -0,0 +1,94 @@
-- Migrate from custom app_user/session to Better Auth.
-- Drop tables with FKs to app_user, drop app_user, recreate with better-auth schema.
-- Drop tables that reference app_user
DROP TABLE IF EXISTS client_member;
DROP TABLE IF EXISTS session;
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS app_user;
-- Better Auth core tables
CREATE TABLE IF NOT EXISTS "user" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"email_verified" INTEGER NOT NULL,
"image" TEXT,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'client',
"locale" TEXT NOT NULL DEFAULT 'de'
);
CREATE TABLE IF NOT EXISTS "session" (
"id" TEXT NOT NULL PRIMARY KEY,
"expires_at" TEXT NOT NULL,
"token" TEXT NOT NULL UNIQUE,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL,
"ip_address" TEXT,
"user_agent" TEXT,
"user_id" TEXT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "account" (
"id" TEXT NOT NULL PRIMARY KEY,
"account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"access_token" TEXT,
"refresh_token" TEXT,
"id_token" TEXT,
"access_token_expires_at" TEXT,
"refresh_token_expires_at" TEXT,
"scope" TEXT,
"password" TEXT,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "verification" (
"id" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expires_at" TEXT NOT NULL,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL
);
-- Booking-client cookie sessions (kept separate from better-auth sessions)
CREATE TABLE IF NOT EXISTS booking_session (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Recreate client_member referencing user(id)
CREATE TABLE IF NOT EXISTS client_member (
client_id TEXT NOT NULL REFERENCES client(client_id),
user_id TEXT NOT NULL REFERENCES "user" ("id"),
role TEXT NOT NULL DEFAULT 'organiser',
PRIMARY KEY (client_id, user_id)
);
-- Recreate audit_log referencing user(id)
CREATE TABLE IF NOT EXISTS audit_log (
audit_id TEXT PRIMARY KEY,
venue_id TEXT NOT NULL REFERENCES venue(venue_id),
user_id TEXT REFERENCES "user" ("id"),
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
field TEXT,
before_value TEXT,
after_value TEXT,
action TEXT NOT NULL,
at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS session_user_id_idx ON "session" ("user_id");
CREATE INDEX IF NOT EXISTS account_user_id_idx ON "account" ("user_id");
CREATE INDEX IF NOT EXISTS verification_identifier_idx ON "verification" ("identifier");
CREATE INDEX IF NOT EXISTS booking_session_user_idx ON booking_session (user_id);
CREATE INDEX IF NOT EXISTS booking_session_expires_idx ON booking_session (expires_at);
CREATE INDEX IF NOT EXISTS audit_log_entity_idx ON audit_log (entity, entity_id);

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

168
docs/booking-lifecycle.md Normal file
View File

@@ -0,0 +1,168 @@
# Booking Lifecycle — Design Spec
Status: **draft for sign-off** · Date: 2026-05-21 · Phase: lifecycle state model + admin overview UI + email service shell + daily cron.
Decisions locked with the owner:
- **Milestone columns** model (lean status enum; contract/deposit tracked as columns + the existing `invoice` row) — not extra statuses, not a long-running workflow.
- **Admin records everything.** No client-facing UI this phase.
- **Deadline anchor = contract email send date** (the 1-year-before-start mark = day 0).
- **On 14-day unpaid: flag for admin review only.** No auto-cancel, no auto-revert; soft reservation stays.
- Email = **service shell** (named methods that log + no-op). Real templates later.
---
## 1. Status enum (the "bucket")
Reshaped from the current 7 states down to 4 active + 1 terminal. `form_received` and `deposit_paid` are **removed** — those moments become milestone columns / derived facts.
| State | Meaning |
|---|---|
| `enquiry` | Client submitted an enquiry; awaiting admin approve/decline. |
| `reserved` | Dates soft-reserved (block others). The whole contract/deposit dance happens **inside** this state. |
| `confirmed` | Admin recorded deposit received → reservation guaranteed. |
| `completed` | Event has taken place. |
| `cancelled` | Terminal. Declined enquiry, or cancelled at any non-terminal stage. |
### Transitions (server-enforced, audited)
| From | To | Driver |
|---|---|---|
| `enquiry` | `reserved` | MANUAL — admin approves |
| `enquiry` | `cancelled` | MANUAL — admin declines |
| `reserved` | `confirmed` | MANUAL — admin records deposit received |
| `reserved` | `cancelled` | MANUAL — admin cancels (e.g. after overdue flag, or contract declined) |
| `confirmed` | `completed` | AUTOMATIC — daily cron when `endDate < today` (admin may also force) |
| `confirmed` | `cancelled` | MANUAL — late cancellation (refunds out of scope) |
| `completed` / `cancelled` | — | terminal |
`cancelled` reachable from every non-terminal state. Every transition writes an `auditLog` row (existing format).
---
## 2. New milestone columns on `booking` (migration 0006)
All nullable ISO-8601 TEXT (matching `created_at` style). camelCase ↔ snake_case.
| Column (TS) | Type | Meaning |
|---|---|---|
| `contractSentAt` | TEXT NULL | When contract+deposit email was sent. **Day-0 anchor** + cron idempotency guard. |
| `contractResponse` | TEXT NULL | `'approved' \| 'declined' \| null` (null = pending). Admin-recorded. |
| `contractResponseAt` | TEXT NULL | When admin recorded the response. |
| `depositReminderSentAt` | TEXT NULL | Day-7 reminder sent (idempotency guard). |
| `flaggedAt` | TEXT NULL | Set when something needs admin review (deposit overdue / short window / contract declined). |
| `flagReason` | TEXT NULL | `'deposit_overdue' \| 'short_window' \| 'contract_declined'`. |
**Deposit lives on the existing `invoice` table** (no duplicate columns):
- Deposit invoice: `kind='deposit'`, created by cron R1.
- Due date = `invoice.dueOn` = `contractSentAt + 14d`.
- Received = `invoice.paidOn IS NOT NULL` (+ `status='paid'`).
On contract approval, also stamp the existing `agbAcceptedAt` / `agbYear` (the contract IS the AGB acceptance).
**Data remap** (migration + seed): `form_received → reserved`; `deposit_paid → confirmed` (with its deposit invoice marked paid). Migration includes `UPDATE booking SET status=... WHERE status IN ('form_received','deposit_paid')` for safety on any non-reset DB.
---
## 3. Daily cron — `booking-lifecycle-daily`
`wireScheduler({ name: 'booking-lifecycle-daily', schedule: '0 6 * * *', func })`. All date math in **Europe/Berlin** local calendar days. Idempotent (safe to run twice) via the `*SentAt IS NULL` / `flaggedAt IS NULL` guards.
For each booking where `status NOT IN ('cancelled','completed')`:
```
R1 Contract dispatch (≈1yr before start, evaluated daily with <=, not ==)
if reserved and contractSentAt is null and (startDate - today) <= 365d:
create deposit invoice (dueOn = today + 14d)
email.sendContractAndDepositEmail(bookingId)
set contractSentAt = now
R2 Day-7 reminder
if reserved and contractSentAt set and depositReminderSentAt is null
and deposit unpaid and (today - contractSentAt) >= 7d:
email.sendDepositReminderEmail(bookingId)
set depositReminderSentAt = now
R3 Day-14 expiry → FLAG ONLY
if reserved and contractSentAt set and deposit unpaid
and flaggedAt is null and (today - contractSentAt) >= 14d:
set flaggedAt = now, flagReason = 'deposit_overdue'
R4 Auto-complete
if confirmed and endDate < today:
setBookingStatus(bookingId, 'completed') // system
```
Using `<=` for R1 means a booking approved *inside* the 1-year window sends on the next daily tick (handles "approved late → send soon" for free). If `startDate` is < ~14d away at approval, flag `short_window` instead of running a deadline that lands past the event.
---
## 4. Email service shell
A singleton `email` service (registered in `services.ts`, added to `SingletonServices`), each method logs + no-ops for now.
| Method | Fires when | Driver |
|---|---|---|
| `sendEnquiryReceivedEmail(bookingId)` | enquiry submitted | enquiry-create RPC |
| `sendEnquiryDeclinedEmail(bookingId)` | admin declines enquiry | `setBookingStatus enquiry→cancelled` |
| `sendReservationConfirmedEmail(bookingId)` | admin approves → reserved | `setBookingStatus enquiry→reserved` |
| `sendContractAndDepositEmail(bookingId)` | day 0 (cron R1) | cron |
| `sendDepositReminderEmail(bookingId)` | day 7 unpaid (cron R2) | cron |
| `sendBookingConfirmedEmail(bookingId)` | admin records deposit received | `recordDepositReceived` |
| `sendCancellationEmail(bookingId)` | admin cancels a non-enquiry booking | `setBookingStatus → cancelled` |
Day-14 expiry sends **no** client email (flag only).
---
## 5. Backend RPCs
| Action | RPC | New/reuse | Effects |
|---|---|---|---|
| Approve enquiry | `setBookingStatus(id,'reserved')` | reshape | + reservation email; flag `short_window` if start < ~14d |
| Decline enquiry | `setBookingStatus(id,'cancelled')` | reshape | + declined email |
| Record contract response | `recordContractResponse(id, 'approved'\|'declined')` | **NEW** | sets `contractResponse(_at)`; approved → `agbAcceptedAt`/`agbYear`; declined → `flaggedAt`/`flagReason='contract_declined'` |
| Mark deposit received | `recordDepositReceived(id)` | **NEW** | `invoice.paidOn`+`status='paid'`; clear `flaggedAt`; → `confirmed`; + confirmed email |
| Cancel (any stage) | `setBookingStatus(id,'cancelled')` | reshape | deposit invoice → `cancelled`; + cancellation email |
| Mark completed (override) | `setBookingStatus(id,'completed')` | reshape | normally cron R4 |
| (Optional) Send contract now | `triggerContractEmail(id)` | **NEW (small)** | runs R1 immediately — useful for testing + early sends; otherwise the 1-yr wait blocks all manual flows |
`setBookingStatus` fires the email matching the transition it owns; milestone side-effects (deposit, contract response, AGB) live in the dedicated `record*` RPCs. All `record*` RPCs write audit rows.
`getAdminBookingDetail` output extends with: `contractSentAt`, `contractResponse`, `contractResponseAt`, `depositReminderSentAt`, `flaggedAt`, `flagReason`, and a deposit-invoice summary (`dueOn`, `paidOn`) to drive the UI time cues.
### `nextAction(booking, depositInvoice)` — derived, drives the UI CTA
Priority order: enquiry→"Approve or decline" (admin) · flagged→`flagReason` (admin) · reserved+no contractSentAt→"Awaiting 1-yr mark" (system) · contractSentAt+no response→"Client to sign contract" (client) · approved+unpaid→"Client to pay deposit" (client, due `invoice.dueOn`) · declined→"Cancel — contract declined" (admin) · confirmed→"Awaiting event" (system) · terminal→none.
---
## 6. Admin overview UI — `BookingStatusPanel`
New `<Paper>` panel mounted **above the Tabs**, below the header, in `admin.bookings.$bookingId.tsx`. Header `Badge` swapped for `StatusPill`. Lift the transition map into `lib/status.ts` (`BOOKING_TRANSITIONS`) and reuse in both the panel and the existing `StatusChanger`; trim `BookingStatus` type + `BOOKING_STATUS` palette to the 5 states.
Layout: `SimpleGrid cols={{ base:1, md:2 }}`**Timeline left**, **Next-action + transitions right**.
- **Timeline (Mantine `Timeline`)** shows milestone progression (richer than the enum): `Enquiry → Reserved → Contract sent → Contract signed → Deposit paid → Confirmed → Completed`, each completed step with its date; current = plum ring; upcoming = gray hollow; `cancelled` = terminal red item replacing the unreached tail.
- **Next-action `Alert`** — single most important outstanding item from `nextAction()`, color/icon by urgency, with the inline action button + "Owner: Admin/Organiser".
- **Transition controls** — one `Button` per valid transition (data-driven off `BOOKING_TRANSITIONS`); destructive (cancel/decline) separated below a `Divider`, red outline; reuse the existing confirm `Modal` for terminal transitions.
- **Time cues** (from `contractSentAt`): "Day X of 14" + `Progress` bar (plum ≤6, yellow 713, red ≥14 unpaid); reminder-sent `Badge`; overdue → prominent red `Alert` at top of panel + "Needs review" pill by the header, booking stays `reserved`.
- **Empty/early states**: contract not yet due (start >1yr out) → gray info alert "Contract email scheduled ~{date}"; cancelled/completed → terminal note, no actions; reserved but no deposit invoice yet → link to invoices tab.
i18n under `pages.adminBooking.status.*` (panelTitle, nextAction.*, actions.*, cues.*, empty.*, party.*), reusing existing `pages.bookingStatus.label.*`. Mirror en + de. Responsive: timeline stacks above actions on `base`; transition buttons `fullWidth` on narrow.
---
## 7. Build order
1. Migration 0006 (milestone columns + status remap) + seed remap → `db reset` + regen.
2. `lib/status.ts` trim + `BOOKING_TRANSITIONS`; update `set-booking-status` enum/transitions/emails.
3. Email service shell (`services/email.ts`) + register + `SingletonServices` type.
4. New RPCs: `recordContractResponse`, `recordDepositReceived`, `triggerContractEmail`; extend `getAdminBookingDetail`.
5. Daily cron `booking-lifecycle-daily` + scheduler wiring.
6. `BookingStatusPanel` component + mount + header pill swap + i18n (en/de).
7. Typecheck, smoke-test cron + transitions.
## Open points (defaults chosen; flag if you disagree)
- **Auto-complete** `confirmed → completed` via cron when the event has passed (admin can still force). Default: **yes, automatic.**
- **`triggerContractEmail`** manual admin action included (otherwise the 1-year wait blocks all manual/testing flows). Default: **include.**
- `deposit_paid` seed/data rows remap to **`confirmed`** (deposit was received).

255
docs/design/admin.md Normal file
View File

@@ -0,0 +1,255 @@
# Admin Portal — UI Design
Audience: Sarah (owner) and Christina (admin assistant). Christina is the
heaviest user (booking admin all day). Sarah uses it for oversight,
pricing/AGB updates, and high-stakes status decisions.
Theme: shared Mantine tokens with the client portal (dark surface, red
primary), but **denser components** — admin is data-heavy, not card-warm.
## Navigation
Sidebar (collapsible to icons-only on smaller screens):
- Dashboard (default landing)
- Bookings
- Calendar
- Cooks (rota + roster)
- Organisations (incl. Stammgruppen)
- Pricing (year-versioned)
- AGB (versioned PDFs)
- Venues (multi-tenant — switch venue here)
- Settings
## 1. Dashboard (landing)
Static layout for v1. Configurable widget mosaic deferred to v1.5.
```
┌──────────────────────────────────────────────────────────────┐
│ Action queue [N items] │ full-width
│ Overdue Orga-Mails, missing deposits, missing room plans, │
│ missing dietary info. Each row → one-click action. │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────┐ ┌──────────────────────────────┐
│ This week │ │ Recent client activity │
│ Arrivals + departures │ │ Last 48h booking edits │
│ next 7 days, completeness│ │ across all bookings │
│ %, flags │ │ │
└──────────────────────────┘ └──────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ Pipeline / │ │ Cook rota │ │ Quick actions │
│ financial │ │ status │ │ + New booking │
│ KPIs │ │ unassigned │ │ + New enquiry │
└──────────────┘ └──────────────┘ │ + Send Orga-Mail │
└──────────────────────┘
```
12-column CSS Grid. Top hero spans 12. Mid row 6+6. Bottom row 4+4+4.
Mobile collapses to 1 column.
### Widget contracts (v1.5 readiness)
Each section is built as a self-contained React component with a stable
key. When the configurable mosaic ships, these become draggable widgets
with no contract changes.
## 2. Bookings list
Table-first. Replaces the Buchungsübersicht spreadsheet.
- **Library:** Mantine `DataTable` or `mantine-react-table`
- **Columns:** Year · Date range · Group / Organisation · Status pill ·
Persons · Half-house · Orga-Mail sent · Deposit · Final invoice · Notes
(truncated)
- **Filters** (sidebar drawer): status, year, half-house, has-deposit,
has-final-invoice, organisation, missing-info (room plan / dietary /
headcount)
- **Sticky header** + virtualised rows
- **Bulk actions** (with confirmation modals): send Orga-Mail to
selected, export DATEV CSV, mark deposit received
- **Full-text search:** organisation name + course name
- **Default sort:** date ASC, current year first
- **Row click:** opens booking detail in same tab
## 3. Calendar view
Replaces the Belegungsplan spreadsheet.
- **Views:** month (default), quarter, year
- **Status colouring** mapped to theme tokens:
- `enquiry` — pink
- `reserved` — amber
- `form_received` — yellow
- `deposit_paid` — light green
- `confirmed` — green
- `completed` — grey
- `cancelled` — strikethrough red
- **Half-house** rendering: day cell splits visually into top/bottom
halves when two groups overlap
- **Click cell or booking** → open booking detail
- **Drag-and-drop reschedule** opens a modal that surfaces the
cancellation-policy implications (e.g. "this falls within 12 weeks of
arrival — fees may apply")
- **Year header strip:** occupancy %, free-day count
- **Filter chips above:** org, status (multi-select)
## 4. Single booking detail
The workhorse. Tabbed organisation.
### Sticky header
- Group name · status pill · dates (large) · headcount · half-house badge
- Status-transition button (contextual)
- Quick actions: edit, send Orga-Mail, send reminder, attach invoice,
cancel
### Main column (left, ~70%) — tabs
1. **Overview**
- Completeness checklist (Orga-Mail sent · deposit · room plan ·
dietary · headcount · schedule)
- Key dates timeline
- Flags (flying changeover · dogs · wheelchair · individual invoicing)
- Pricing summary (line items + total)
2. **Participants**
- Editable table: name · age · `kind` (overnight | day_guest) ·
dietary tags · allergies · room (if overnight)
3. **Room plan** — split view, grid as primary editor + live floor-plan
visualisation. Both backed by the same `room_assignments` model.
- **Left — assignment grid:** rows = overnight participants;
columns = name · age · dietary tags · room (combobox with live
capacity counter) · notes. Bulk-select participants → assign to a
room. Keyboard-fast (Tab / type-to-search / Enter to commit).
Inline validation: overcapacity, conflicting flags (e.g.
wheelchair user only assignable to Rooms 1 and 2).
- **Right — floor plan visualisation:** SVG overlay on top of
`grundrisse.pdf`, hot-spotted with room rectangles. Each room
shows number, capacity dots filled per occupant, flag icons.
Live-updates as the grid changes. Click a room → highlights its
occupants in the grid. Hover a participant → highlights their room.
- **v1.5 enrichment:** click-room-to-assign-selected (spatial
click-select pattern matching the rest of admin)
- **Post-MVP:** drag a participant card onto a room on the floor plan
4. **Schedule & meals**
- Structured times for Frühstück / Mittagessen / Abendessen
(validated against allowed-window config)
- Free-text daily plan
5. **Extras**
- Booking-time extras (cake, second seminar room, linen, early
arrival, etc.)
- On-site extras logged by staff (sauna, drinks, coffee) with
`payment_method` flag (`cash` default · `invoice`)
6. **Invoices**
- Deposit invoice (PDF + DATEV ref + paid status)
- Final invoice (PDF + DATEV ref + paid status)
- DATEV line-item export button (CSV/JSON)
7. **Audit log**
- Full change history: who · when · field · before → after
### Side column (right, ~30%) — always visible
- Organiser contact (name · email · phone · billing address ·
organisation link)
- Internal notes (Bemerkungen) — markdown, per-booking
- Recent activity (last 5 changes, link to full audit)
- Linked enquiry (if any)
- Assigned KOCH/IN per event day
## 5. Cook rota grid
Replaces the Küchenplan spreadsheet.
- **Layout:** one row per day, one column per cook
- **Left columns** (locked / sticky): day-of-month · seminar room
occupants (Mandala · Yogaraum) · PAX · KOCH/IN · KOMMENTARE
- **Right columns:** one per active staff cook + EXTERN
- **Cells:** edit-on-click, code-aware text input with autocomplete from
the **`shift_code` enum**
- **Shift code enum** (DB-backed, not freeform):
- `F` — Frühstück
- `M` — Mittagessen
- `A` — Abendessen
- `S` — Spät / extra shift
- `R-K` — Reinigung Küche
- `R-HH` — Reinigung Haupthaus
- `R-HH-Zi` — Reinigung Haupthaus Zimmer
- `GR-K` — Grundreinigung Küche
- `nur-A` — only dinner
- `off` — explicit off
- Plus optional explicit time (`HH:mm`) and per-cell free-text note
- **Multiple codes per cell:** combine via `/` (e.g. `F/M`); UI renders
each as a coloured chip
- **Bracket convention (`(...)`)** marking event start/end is
**auto-rendered** by the grid from each booking's start/end dates —
not typed
- **Filters above grid:** month, cook, event
- **Conflict highlighting:** when a cook has overlapping shifts on the
same day, cell shows a warning badge
### Schema implication
```
shift_codes
code text PK -- 'F' | 'M' | 'A' | 'S' | 'R-K' | …
label text
color text -- theme token
cook_assignments
id uuid
venue_id uuid → venues
booking_id uuid → bookings (nullable for non-event days e.g. GR-K)
cook_id uuid → cooks
date date
shift_codes text[] → shift_codes.code
explicit_time time
note text
```
## 6. Auxiliary admin screens (CRUD, low design effort)
- **Organisations** — list + detail; Stammgruppe boolean toggle; merge
duplicates
- **Pricing** — year-versioned editor (one form per year, fields for EZ
/ DZ / MBZ / day guest / kids 310 / cake / sauna / linen / etc.)
- **AGB** — upload PDF per year; list previous versions
- **Venues** — switch active venue (multi-tenant); edit venue settings
(allowed meal-time windows, default Half House split, etc.)
- **Cooks** — roster: name · kind (`staff` | `external_vendor` |
`inactive`) · compensation model + amount · notes
- **Settings** — current user profile, notifications preferences,
language preference, email signature
## Styling & component approach
**Mantine** as the component library. Theme tokens (red primary on dark
surface from the client-portal screenshot) shared across all four
surfaces — same tokens, surface-specific compositions. Admin uses
denser Mantine compositions; client uses warmer card-based ones.
Mantine ecosystem packages used:
- `@mantine/core` — components
- `@mantine/dates` — date pickers (`de` locale via `dayjs`)
- `@mantine/hooks` — utilities
- `mantine-react-table` (or `mantine-datatable`) — bookings list
virtualisation, filtering, bulk select
**No drag-and-drop in MVP.** Interactions previously sketched with DnD
become click-select-then-target. DnD is a post-MVP enrichment, not a
load-bearing primitive — the click pattern works without it.
- **Calendar reschedule** — "Reschedule" button → date-picker modal
that surfaces cancellation-policy implications inline. Drag-to-
reschedule possible later as enrichment.
- **Room plan** — split view: assignment grid + live floor-plan SVG.
Grid is the primary editor; floor plan visualises live. v1.5 adds
click-room-to-assign; post-MVP adds participant drag onto the plan.
- **Cook rota grid** — click cell → autocomplete input for shift codes.
## Non-goals (defer to v1.5+)
- Configurable widget mosaic dashboard
- Real-time websocket updates (polled refresh in v1)
- Per-user dashboard layout
- WhatsApp staff notifications (email + in-app only)
- HR / applicant tracking

161
docs/design/client.md Normal file
View File

@@ -0,0 +1,161 @@
# Client Portal — UI Design
Audience: retreat organisers (e.g. Yoga Retreat e.V.) and any colleagues
they invite (multi-user per org, locked earlier).
Theme: Mantine, dark surface, red primary — the warm, card-based tone
from the reference screenshot. Same tokens as admin/staff but
distinctly card-driven layouts (admin is denser).
## Sidebar
Two-group sidebar. The **MY EVENT** group is conditionally visible —
shown only when the user has selected an active event.
```
SEMINARHOF DRAWEHN
Client Portal
─────────────────
[org-switcher] Yoga Retreat e.V. ▾ ← visible if user is in >1 org
MY BOOKINGS
Overview
My bookings
Invoices
MY EVENT — Summer Yoga 2026 ▾ ← only when active event set
Participants
Room plan
Dietary info
Schedule & extras
─────────────────
[avatar] User name (role on the org)
```
Active event is set by clicking a booking in Overview or My bookings.
The dropdown switches between events the user has access to.
## 1. Overview (landing)
Reference screenshot is canonical. Cards: upcoming bookings (with
completeness %), invoices preview, current event details snapshot.
Booking info-complete % drives prompts elsewhere in the portal.
## 2. My bookings
Three vertical sections:
- **Upcoming** — full card per booking, large dates, status pill,
completeness %, primary CTA "Complete booking info →"
- **Past** — collapsed list, one compact row each. Tap to expand
+ download invoices
- **Cancelled** — only rendered if any exist; collapsed by default
No filters, no bulk actions, no search. Org clients have small lists.
## 3. Invoices
- Two tabs: **Deposit** · **Final**
- Per-row: booking name · invoice number · issue date · amount · status
(paid / outstanding) · download PDF
- Implicitly grouped by booking (booking title as subheader)
- Empty state: "No invoices yet — your invoice will appear once Christina
has issued it."
## 4. Participants (per event)
Where the team builds the participant list.
- Top: progress bar `17 / 28 added`
- Add row form: name (req) · email (optional) · age (req if child) ·
kind (overnight | day_guest) · notes
- **Bulk paste from CSV/TSV** — common for organisers with a Google
Sheet. Paste into modal, map columns, import.
- Per-row inline editing, dietary chip preview, allergy preview
- **"Send dietary self-form to all guests"** — sends each guest a
tokenised link to fill in their own dietary preferences without
needing an account. Organiser sees status per guest (pending / done).
## 5. Room plan (per event)
Split view, same model as admin's room-plan tab.
- **Left — assignment grid:** rows = overnight participants. Click a
participant to select, click a room to assign. Capacity counter
per room.
- **Right — floor-plan visualisation:** SVG overlay on `grundrisse.pdf`,
hot-spotted with room rectangles. Room number, capacity dots filled
per occupant, flag icons.
- **Top banner** explaining the click-select pattern (no DnD in v1).
- **Auto-suggest button** ("Suggest a layout") — assigns participants
by priority: special-needs flags first, couples grouped if marked,
fill smallest rooms last.
- **Flag legend** above the floor plan (wheelchair, dogs allowed, quiet,
shared bath, ground floor, allergy-friendly bedding).
- **Validation inline:** can't assign over capacity; wheelchair-flagged
participants only assignable to wheelchair-accessible rooms (1, 2).
- Implicit autosave, "Saved" indicator.
## 6. Dietary info (per event)
Aggregate view + per-participant editor. Reuses the kitchen view's
dietary breakdown component, but editable.
- **Top:** dietary breakdown donut + missing-info count
("3 participants haven't told us their diet yet")
- **Quick action:** "Mark everyone as default vegan" — bulk set
- **Allergies block** — flat list with "+ Add allergy" picker
- **Pending list** — participants without dietary info, with prompt
- **Self-form CTA** mirrored from Participants page
## 7. Schedule & extras (per event)
### Schedule card
- Three time pickers: Frühstück · Mittagessen · Abendessen —
validated against venue's allowed-window config
- Free-text daily plan textarea
- Hint: "Kitchen needs your meal times to plan portions."
### Extras card
Toggle list with descriptions and gross (brutto) prices:
- Cake (€3 / person / day) → per-day calendar picker
- Second seminar room (€80 / day for the whole stay)
- Early arrival before lunch (€30 / person flat)
- Bedlinen & towel set (€14 / person) — toggle "provided by Seminarhof"
vs "guests bring their own"
- Massage bench hire (€15 each + qty)
- Sauna note: "Logged on departure — €10 / person, min €50 total"
- Subtotal preview at bottom
- Special requests textarea — "Anything else we should know?"
## Cross-page UX
- **Completeness pill** in the corner of every page — same "Booking
info complete %" indicator as the Overview, so the client always
knows what's left
- **Implicit autosave** — no manual save button, "Saved" indicator
next to the field after each change
- **Per-event switcher** at the top of the sidebar, lists upcoming
events first, past events below
- **Empty states everywhere** — written empty state with a one-line
"what should I do" prompt
- **Locale-aware** — German default, EN per user `locale` field
- **Mobile** — sidebar collapses to a bottom-nav-style icon strip; all
pages stack to single column
## Accessibility
- Keyboard navigation across all interactive elements (no DnD means no
keyboard fallback gap)
- ARIA-correct selects, dialogs, tabs (Mantine handles most by default)
- Sufficient contrast on the dark surface (verify with theme tokens
before shipping)
- Floor-plan SVG has text labels per room, not just visual rectangles
## Components shared with admin/staff
- Dietary breakdown donut + counts (kitchen view also uses)
- Floor-plan SVG component (admin booking detail also uses)
- Status pill with venue-theme colour tokens
- Completeness pill / progress component
- Room-flag icons set

131
docs/design/public.md Normal file
View File

@@ -0,0 +1,131 @@
# Public Surfaces — UI Design
Audience: prospective clients browsing for dates, returning organisers
checking the calendar, individual guests of a confirmed event filling in
their own dietary info via a tokenised link.
Theme: same Mantine tokens as admin/client/staff (dark surface, red
primary), but **lighter compositions** — more whitespace, larger
typography, hero-style sections. Public is read-only or single-form.
All public pages support an `?embed=1` query param that strips the
shell (header/footer) for WordPress iframe inclusion. Embedded pages
emit `postMessage('resize', { height })` so the WP iframe wrapper can
auto-fit content height.
DE default, EN toggle in the header. Browser locale auto-detected on
first visit; preference persisted.
## 1. Availability calendar — `/availability`
Replaces the public Google Sheet.
- 12 months ahead by default, horizontally scrollable
- Day cells coloured by status (token palette shared with admin):
- `free` — neutral pale
- `enquiry` — soft pink (semi-transparent)
- `reserved` — amber
- `confirmed` — green
- **Privacy:** event name visible on hover/tap **only** for bookings
with `online-ad=true` consent; all other booked days show status
only, never group name. (Q1 lock: "Status + opt-in event names")
- Click a `free` day → "Reserve this date" CTA → opens `/enquiry`
with that date pre-filled
- View toggle: month · quarter · year
- Year-header strip: occupancy %, free-day count
- Embeddable: `/availability?embed=1` returns just the grid
## 2. Events page — `/events`
Lists upcoming events whose organiser opted into public listing
(`online-ad=true`).
- Card per event:
- Cover image (organiser-uploaded, optional)
- Event name
- Dates
- Organiser name
- Short description (`course-info` form field)
- External link to organiser's website (`your-website`)
- Filter chips: This year · Next year · All
- Search (event name / organiser)
- SEO: server-rendered, meta tags, JSON-LD `Event` schema
- Empty state: "No events listed at this moment — please check our
retreat calendar for available dates."
- Cross-link to `/availability` in header
## 3. Booking form — `/enquiry`
The iframe-able binding booking form. Replaces the current WordPress
form long-term; short-term we accept WP webhook posts to the same
backend (Phase 1 of public-entry plan).
### Sections (all sticky on scroll)
1. **Your event** — name, description, website, dates, est. participants
2. **Organiser** — name, email, phone, full legal name, organisation
3. **Billing** — invoice address, payment preference (cash | transfer)
4. **Schedule** — desired arrival / departure times, structured meal
times (Frühstück / Mittag / Abend, validated against venue's
allowed-window config), free-text daily plan
5. **Extras** — cake / 2nd seminar room / early arrival / bedlinen /
massage bench (gross prices)
6. **Setup** — equipment needed (beamer, flipchart, piano, chair count)
7. **Public listing consent** — opt in to show on `/events`
8. **Terms** — AGB acceptance (versioned by event year), privacy
notice acceptance
### Behaviour
- Inline validation; submit failure highlights incomplete sections
- On submit:
- Status = `form_received` (per locked status enum)
- €300 deposit due notice within 14 days
- Auto confirmation email in user's locale (DE/EN)
- Embeddable: `/enquiry?embed=1`, postMessage height resize
## 4. Guest self-form — `/d/:token`
Tokenised link a guest receives from the organiser to fill in their
own dietary info — no account required.
- Single page: name (pre-filled, editable), dietary tag picker,
allergy list, free-text "anything else"
- Token expires when booking is `completed`
- DE/EN locale-aware, auto-detected from browser
- One-time submit with confirmation page; re-openable until cutoff
- Mobile-first (most guests fill from phones)
## 5. Authenticated landing — `/`
- Unauthenticated users → thin welcome page with links to
`/availability`, `/events`, sign-in CTA
- Authenticated users → role-based redirect to their portal
No marketing copy on the platform itself — Sarah keeps the WordPress
site as the brand front door.
## SEO and structured data
- `/availability` — meta description, no indexable detail
- `/events` — JSON-LD `Event` schema per event card; `<title>`
per-card on detail expansion if added later
- `/enquiry``noindex` (form, not content)
- `/d/:token``noindex` (private)
- `/` — minimal `<title>` and `og:` tags
## Components shared with the rest of the platform
- Status pill (theme tokens)
- Day-cell calendar component (admin calendar reuses, with edit
affordances)
- AGB / privacy acceptance component (also used in client portal
on first login)
- DE/EN locale toggle
## Non-goals
- No marketing copy / about / blog — handled by WordPress
- No public booking detail page (events page is summary-only; clients
see full detail in their portal once authenticated)
- No public reviews / testimonials in v1
- No payment processing (deposits are bank transfer, per §6)

172
docs/design/staff.md Normal file
View File

@@ -0,0 +1,172 @@
# Staff Portal — UI Design
Two sub-views: **Kitchen** and **Cleaning**. Same mobile-first shell,
different content compositions.
Audience: kitchen cooks, housekeepers. Read-only. Mobile/tablet first
(phones over the counter, tablets in the housekeeping cupboard).
Polled refresh, no websockets in v1.
Theme: same Mantine tokens as admin/client (red primary on dark
surface), but with a simpler layout — large touch targets, generous
spacing, fewer columns.
## Auth and routing
- Same `js-auth` flow as admin/client
- After login, role-based redirect: kitchen → `/staff/kitchen`,
cleaning → `/staff/cleaning`
- A staff user can hold both roles (small team); UI offers a switcher
in the header
## Shell
- Top bar: venue name · current date · user avatar · role switcher (if
applicable) · refresh button
- Body: list / detail
- No sidebar (mobile-first)
---
## Kitchen view
### 1. Event list (landing)
- One card per upcoming event, chronological
- Card content: dates · group name · headcount · today's meal-count
badge · "info missing" alert badge if any dietary / headcount
fields are still pending
- Filter chips: Today · This week · Upcoming
- Tap a card → event detail
### 2. Event detail
The workhorse. Visual-heavy.
#### Sticky header
- Group name · course name · headcount · dates
- Meal times: Frühstück / Mittagessen / Abendessen (single line)
#### Dietary breakdown card (top of body)
- `RingProgress` or `DonutChart` showing % per dietary tag (vegan,
vegetarian, gluten-free, lactose-free, etc.)
- Counts per slice
- Same colour tokens reused on the participant list below for visual
continuity
#### Today's meals card
- Small KPI tiles per meal: Frühstück / Mittagessen / Abendessen
- Each tile shows: count for today, delta vs. headcount when
participants arrive late or leave early ("26 (2)")
- Cake tile shows yes/no for today + €3/person/day total when on
#### Allergies & specials card
- Flat list grouped by allergen (nuts, soy, strict gluten, dairy, etc.)
- Each row: icon · allergen · count · participant initials
- Severity tier:
- ⚠ standard allergy (avoid in dishes)
- ★ requires fully separate prep (the rare 1% that triggers the
manual special-diet surcharge)
#### Cake days card
- Calendar strip (5 days for a 5-night booking)
- Cake-on days highlighted; €3/person/day shown
#### Notes card
- Per-booking admin notes filtered to kitchen-relevant only
(`notes.audience=kitchen`). Personnel notes never appear here.
#### Daily plan (free-text)
- The free-text portion of the schedule the client submitted
- Read-only
### Data sources
All read-side aggregations on existing tables — no new schema:
- Dietary breakdown: `SUM(participants) GROUP BY dietary_tag` for the
booking
- Today's meals: `participants WHERE booking_id = X AND kind IN
(overnight, day_guest) AND arrival_date <= today AND departure_date >
today`
- Allergies: flat list from `participant_allergies` joined to allergen
reference table
- Cake days, meal times: from `bookings.extras` and
`bookings.meal_times`
---
## Cleaning view
### 1. Event list (landing)
- One row per event sorted by changeover date (departure or arrival)
- Row content: changeover type icon (`departure` / `arrival` /
`flying`) · date · group name · room count · flying-changeover badge
- Tap → cleaning detail
### 2. Cleaning detail
Spatial / temporal, not dietary.
#### Sticky header
- Group · dates · room count · flying-changeover badge if applicable
("Rooms cleared by 10:00, ready by 17:00")
#### Arrivals / departures card
- Arrival time and departure time
- Number of rooms in use
- Whether the previous group's departure overlaps (flying changeover)
#### Room status table (the workhorse)
| Room | Type | Beds | Flags | Status |
|------|------|------|-------|--------|
| 5 | MBZ | 3 | quiet | clean for arrival 15:00 |
| 8 | DZ | 2 | shared bath, quiet | clean for arrival 15:00 |
| 10 | DZ | 2 | dogs allowed | extra deep clean |
- Driven by the booking's room assignments
- Flags surfaced as icons (dogs, wheelchair, allergy-friendly,
shared bath, double bed 140cm)
- "Extra deep clean" auto-shown for `dogs_allowed` rooms when occupied
- Per-room admin note shown inline if present
#### Notes card
- Per-booking admin notes filtered to cleaning-relevant only
(`notes.audience=cleaning`)
### Data sources
All on existing tables:
- Room assignments → which rooms are occupied
- Room flags → from `rooms`
- Flying changeover → derived from booking start_date == previous
booking end_date
---
## Shared shell components
- `<StaffEventCard />` — card used in both kitchen and cleaning
landing lists
- `<StickyEventHeader />` — same shell, different content props
- `<NotesCard audience="kitchen|cleaning" />` — filters notes by
audience tag
## Non-goals
- No editing anywhere in the staff view (read-only by definition)
- No real-time updates in v1 — manual refresh button + a "last fetched
X minutes ago" indicator
- No WhatsApp integration in v1 — handover §6 explicit
- No HR features (cook rota assignment is admin-side)

View File

@@ -0,0 +1,446 @@
# Discovery & Scoping Playbook
A repeatable method for going from "handover document + a folder of legacy
spreadsheets" to "buildable platform with locked decisions, persisted memory,
and a vetted business-owner sign-off". Designed to be driven by a human
with an AI coding assistant (Claude Code, Cursor, etc.) — not by the AI
alone.
The method works because each phase produces a **durable artifact** that
the next phase consumes. No phase relies on conversation history; if a
session ends, the next one resumes from the artifacts.
A worked example using this method is in `seminarhof-walkthrough.md`.
---
## Phase 0 — Repo init from template
**Goal.** Get a working repo on disk with the template's stack already
wired, plus a private working tree where production data can land
without ever touching git.
**Steps**
1. Create the empty remote repo (org-scoped, private by default).
2. Copy the template into a temp dir, init git, push to the new remote.
3. Move the working copy out of the temp dir into a permanent location.
4. **Before anything else**: extend `.gitignore` to cover all paths that
will hold production data, handover documents, or scrubbed data
derived from production data:
```
data/seed/
data/seed-scrubbed/
docs/handover/
```
5. Create empty `data/seed/` and `data/seed-scrubbed/` directories so
the AI can drop scripts referencing them without errors.
**Artifacts.** A pushed-to-main empty-template repo + gitignore lockdown.
**Anti-pattern.** Letting the AI commit a CSV before gitignore is set up.
Once production data hits a public remote, you can't un-leak it — even
after a force-push, the data is in clones and CI logs.
---
## Phase 1 — Inventory the inputs
**Goal.** Convert every source document (handover PDF, sample
spreadsheets, screenshots, hand-drawn diagrams) into a single
**structured inventory document** before designing anything.
**Why first.** Every later phase references the inventory. If you skip
straight to schema design, you'll model what you assume the source data
looks like rather than what it actually contains, and you'll re-discover
columns by accident two weeks in.
**Steps**
1. For each spreadsheet / source workbook, capture:
- Tab names and tab purposes
- Per-tab column list with your best-guess interpretation
- Cell value vocabulary if the cells aren't simple values (codes,
bracketed conventions, abbreviations, etc.)
2. For each source PDF (floor plans, AGB, etc.), extract structure into
tables — e.g. one row per room with whatever attributes are visible.
3. For each artifact, record what's **confirmed** vs **best-guess**.
Best-guesses become open questions later.
4. Cross-reference between artifacts to surface contradictions (e.g.
"this name appears here but not on the floor plan").
**Artifacts.**
- `docs/handover/csv-inventory.md` — structure of every source spreadsheet
- `docs/handover/rooms-inventory.md` (or similar per major artifact)
- Scribbled best-guess tables marked clearly as such
**Tip — anchoring.** When you later ask the business owner about a
column or value, anchor every question to its source artifact ("In
**Belegungsplan** the column `auto-renewal-2`…"). It eliminates
ambiguity for the owner and lets you trace each decision back to its
trigger.
---
## Phase 2 — Privacy-first data handling
**Goal.** Get useful structural insight from production data without
ever letting raw personal data reach an LLM API.
**Why this matters.** Most LLM APIs retain inputs for ~30 days for
trust & safety. A single conversational `cat` of a customer CSV
becomes a GDPR Art. 9 (special-category) breach if dietary, allergy,
or health data is in it. The fix is structural, not policy: the AI
cannot leak data it never saw.
**Method**
1. Owner exports CSVs / Excel from source systems and drops them in
`data/seed/` (gitignored).
2. AI writes a **scrubber script** — pure stdlib, no deps — that:
- Reads every CSV in `data/seed/`
- Detects PII columns by header heuristics in the local language
(German + English in our case)
- Detects PII patterns regardless of column (email, phone, IBAN
regex)
- Replaces values with **deterministic fakes** (hash → fake, so the
same input maps to the same fake — preserves foreign-key integrity
and repeated-organisation grouping)
- Blanks notes/comments columns by default (highest-risk free text)
- Outputs to `data/seed-scrubbed/` (also gitignored)
3. AI never reads `data/seed/`. Only ever reads `data/seed-scrubbed/`.
**What survives in scrubbed output:** column names, row counts, value
patterns, dates, statuses, room numbers, dietary text values, prices.
Enough to design schema honestly.
**What never reaches the AI:** real names, emails, phones, addresses,
IBANs, free-text notes.
**Artifacts.**
- `scripts/scrub-csvs.mjs` (or equivalent)
- `data/seed/` and `data/seed-scrubbed/` populated locally; both
gitignored
**Anti-pattern.** "I'll just paste a few rows in chat to show structure."
A few rows is enough to identify individuals via correlation; resist.
Structure can always be conveyed via column names + 2 obviously-fake
sample rows (`Max Mustermann`).
**Edge case the owner will raise.** "But the structure is messy and you
need to see real values to make sense of it." This is when the scrubber
earns its keep — they get to keep the messiness, you get to read the
output safely.
---
## Phase 3 — Question discipline
**Goal.** Convert ambiguity into locked decisions one at a time, with
rationale, anchored to source.
**The core rule.** Ask **one** question per turn. Multi-question batches
encourage thin answers and mask cross-question contradictions.
**Question shape that works**
```
In <source artifact>, <specific anchor> — <plain question>?
[24 options, each with a one-line consequence]
```
Each option must spell out what *changes in the platform* if it's
chosen. "Cleaner" is not a consequence; "no `house_half` column on
rooms" is.
**When the owner picks an option** → lock it. Move on.
**When the owner says "I don't know"** → log to a single deferred-items
file with the consequence, and move on. Don't argue.
**When the owner gives a free-text answer that doesn't match the
options** → restate it in one line ("Got it — X."), confirm, lock,
move on. The options were a forcing function, not a constraint.
**When the owner asks "what would you recommend?"** → answer in one
sentence with the trade-off, then bring them back to deciding. Don't
make the decision for them.
**Artifacts.**
- Running `docs/handover/open-questions.md` — every deferred question
with its rationale (so anyone can pick it up later without re-deriving
the context)
- Memory entries for locked decisions (Phase 4)
**Anti-pattern.** Asking "are you sure?" after every answer. Lock the
decision and move forward; if it turns out wrong, the audit log of
locked decisions makes it cheap to revisit.
**Anti-pattern.** Asking infrastructure questions (DB engine, hosting,
file storage) when the chosen framework already provides them. Skip
those entirely; they aren't business decisions.
---
## Phase 4 — Lock decisions in memory
**Goal.** Make decisions survive session boundaries.
**Why.** Conversation context disappears when the session ends. A
locked decision that lives only in conversation is a decision that
will be re-litigated the next time someone (you, the AI, a teammate)
opens a new session.
**Steps**
1. As decisions are locked in Phase 3, mirror them into a memory file
under `~/.claude/projects/<project-key>/memory/`.
2. The memory file is a *living* artifact — append new locks, update
superseded ones (don't pile contradictions), remove decisions that
were rolled back.
3. Reference the memory file from `MEMORY.md` so future sessions
auto-load it.
4. Distinguish **business decisions** (locked, in memory) from
**infra decisions** (defer to framework, don't pretend to lock).
**Artifacts.**
- `~/.claude/projects/<project-key>/memory/project_<name>.md`
- `MEMORY.md` link
**What goes in memory.** Locked rules: status enums, pricing logic,
auth model, multi-tenancy stance, scope exclusions, deferred items,
privacy guardrails. The shape of decisions, not the implementation.
**What does *not* go in memory.** Code, file paths, line numbers, the
contents of any source data file. Those rot fast and you can re-read
them from disk any time.
---
## Phase 5 — Generate a business-owner interview prompt
**Goal.** Hand the open questions back to the non-technical business
owner in a form they can answer without a developer babysitting them.
**The artifact.** A single self-contained markdown file the owner
pastes into a fresh AI session. The file contains:
1. A how-to-use header (paste this whole file into Claude/ChatGPT, etc.)
2. Instructions to the LLM (interview tone, ask one at a time, restate
in plain language, push back on vagueness, don't accept "we'll
figure it out")
3. Background section: what the business is, what the platform replaces,
what's already decided (so the LLM doesn't re-open settled ground)
4. Each open question, complete with a *why-it-matters* rationale the
LLM reuses verbatim
5. Output spec — the LLM must produce a single answers document at the
end, with a specific structure, that the owner sends back
**Why this works.** The owner can answer at their own pace, in their
own language. The LLM acts as a patient interviewer rather than a
form. Vague answers get probed automatically. The output document is
mechanical to consume back into the project.
**Artifacts.**
- `docs/handover/business-requirements-prompt.md`
- (later) `docs/handover/business-requirements-answers.md`
**Anti-pattern.** Sending a 20-row spreadsheet to the owner and asking
them to fill it in. Spreadsheets get half-answered or skipped on the
hard items. A guided conversational interview surfaces edge cases the
owner wouldn't have thought to flag.
---
## Phase 6 — Receive answers, propagate, re-lock
**Goal.** Take the owner's answers and propagate them through every
artifact built so far.
**Steps**
1. Save the raw answers verbatim as `business-requirements-answers.md`.
2. For each answered question:
- If it overrides a best-guess in an inventory document, update the
inventory with the authoritative value (mark the source: "confirmed
by owner on <date>").
- Move the question out of `open-questions.md`.
- Add the locked rule to memory (Phase 4 file).
3. Items marked `DEFER` stay in `open-questions.md` with the next
decision-maker named. They are the only thing that file should
contain after Phase 6.
**Artifacts.**
- `business-requirements-answers.md` (verbatim)
- Updated inventories with `confirmed by` notes
- Slimmed-down `open-questions.md`
- Updated memory file
**The discipline test.** After Phase 6, a brand-new AI session opening
this repo should be able to design the schema purely from the
artifacts in the repo and memory — no conversation history needed. If
that's not true, an artifact is missing.
---
## Phase 7 — UI design before code
**Goal.** Decide what each portal *looks like* and *contains* before
writing components.
**Why this is its own phase.** The data model is now locked, but a
data model alone doesn't tell you what the UI is. Two valid designs
can sit on the same schema and feel like different products.
**Method**
1. Identify the surfaces (in this template's case: client portal, admin,
staff, public).
2. For each surface, decide:
- Audience and primary jobs-to-be-done
- Default landing screen (what they see when they log in)
- Density and tone (warm/dense/minimal/marketing)
- Mobile vs desktop priority
- Shared theme tokens vs surface-specific overrides
3. For the **highest-stakes surface** (usually admin), drill into the
landing screen's sections one at a time, the same way Phase 3 drills
into business questions.
**Artifacts.**
- `docs/design/<surface>.md` per portal — sections, components,
interactions
- Reference screenshots from the owner where they exist
**Anti-pattern.** "Same theme, same components everywhere." A
client-facing card-based dashboard makes admin Christina cry when she
needs to manage 200 bookings across 5 years. Same tokens; different
component vocabularies per surface.
---
## Phase 7.5 — Acceptance scenarios before code
**Goal.** Every workflow that's about to be built has a `.feature` file
*before* a line of implementation code is written.
**Why this is its own phase.** Acceptance scenarios written *after* the
code rationalise whatever shipped — they validate the implementation,
not the requirement. Written *before*, they pin the requirement and
double as the build's definition of done. They also surface
contradictions in the locked decisions that schema design and UI
mockups didn't catch (e.g. "the feature says clients can do X, but role
matrix in memory says only admins can").
**Method**
1. For each surface from Phase 7, list the workflows that have to work
end-to-end (login, view-own-bookings, room-assignment,
create-invoice, etc.).
2. For each workflow, write a Cucumber `.feature` file with at least
one happy-path scenario and one role-gating / permission scenario.
Use the generic step library from the e2e harness; project-specific
steps come later.
3. Auth is **always the first feature**. Without it the rest of the
scenarios can't even reach a logged-in state.
4. Don't write the step *implementations* yet beyond the generic ones
(`I visit`, `I log in as`, `I see`). Project-specific steps land in
Phase 8 alongside the code they exercise.
**Artifacts.**
- `e2e/tests/features/auth.feature` — first, always
- `e2e/tests/features/<workflow>.feature` — one per locked workflow
- A failing test run (red) — the baseline against which Phase 8
progress is measured
**Anti-pattern.** "We'll add tests once it's working." Tests added
after-the-fact tend to mirror the code's shape rather than the
requirement's shape, and they don't catch the gaps the requirement
process should have caught. The whole point of Phase 7.5 is that the
test is the requirement, in executable form.
**Anti-pattern.** Writing 40 detailed scenarios up front. Cover the
locked workflows with **one happy path + one critical edge each**.
Exhaustive scenario coverage is a Phase 9 activity (regression as the
product grows), not a Phase 7.5 one.
---
## Phase 8 — Build (vertical slice)
**Goal.** Ship a slice that touches every layer (DB → backend → API →
UI) for one real workflow, instead of completing a full layer at a
time.
**Why.** Layer-at-a-time builds discover integration problems last,
when they're most expensive. Vertical-slice builds discover them in
week one.
**Build auth first.** Before any feature work: real login, real
sessions, real role enforcement. Every later function takes its
`userId` and `role` from the session, never from a `DEMO_USER`
constant or an input parameter. Skipping this and "wiring auth at the
end" sounds cheap but isn't — every function signature, every UI page,
every permission check has to be retrofitted, contracts re-versioned,
seeds re-hashed, and route guards added across the whole app. Doing it
first costs a day; doing it last costs a week and leaves footguns in
every file you didn't revisit.
**What "auth first" means concretely:**
1. Sessions table + cookie middleware (or equivalent for the chosen
stack) live before the first feature function is written.
2. Password hashing uses a runtime-portable primitive (WebCrypto
PBKDF2, Argon2-wasm, etc.) — not bcrypt/node:crypto if the target
includes edge runtimes.
3. Seed data includes hashed passwords for every demo role from day
one. Demo passwords are documented in seed comments only, never in
memory or repo docs.
4. `pikkuFunc` (or the equivalent session-required handler) is the
default; sessionless handlers are the explicit, justified
exception.
5. UI has a `RequireAuth` wrapper with a `roles` prop *before* the
first protected page is built.
**Steps**
1. Land auth as the first vertical slice (login → cookie → `me` → role
guard on one trivial admin page).
2. Pick the next workflow that exercises the trickiest parts of the
schema (in our case: client logs in → views their booking → fills
room plan → admin sees the update → kitchen view reflects it).
3. Build only what that workflow needs at every layer.
4. Once it's working end-to-end, expand sideways.
**Artifacts.**
- A demoable URL
- A short loom-style "what works, what doesn't" note for the owner
**Anti-pattern.** "We'll wire real auth at the end, just hard-code
`DEMO_USER` for now." Every function written under that assumption
takes a `userId` input parameter that has to be deleted later, and
every UI mutation passes it explicitly. Retrofitting means touching
every file twice and re-versioning every contract.
---
## Cross-phase rules
**Auto mode.** When the owner has set the AI to "auto" / "execute
without confirming", still pause for irreversible actions: deleting
data, force-pushing, or actions that touch shared systems. Authorisation
to "go fast" is not authorisation to be careless.
**Memory hygiene.** When a locked decision is later overridden, *update*
the memory file rather than appending a contradiction. Stale memory is
worse than no memory.
**Confidentiality.** Treat any owner-shared spreadsheet, screenshot, or
PDF as containing data the org has not authorised to share with an AI
provider. Either scrub it (Phase 2) or extract structure manually before
ingesting.
**Documentation order.** Inventory → questions → answers → memory →
design → acceptance scenarios → code. Skipping inventory or questions
costs more downstream than the time saved. Skipping acceptance
scenarios means the build's definition of done is "feels right when I
click around" — which is how `DEMO_USER` constants ship to production.

122
e2e/README.md Normal file
View File

@@ -0,0 +1,122 @@
# E2E Test Harness
Cucumber + Playwright end-to-end tests for the project. The intent is that
**every locked workflow has a `.feature` file before it gets a line of
implementation code** (see "Phase 7.5 — Acceptance scenarios" in the
discovery playbook).
## Layout
```
e2e/
package.json — cucumber, @playwright/test, tsx
cucumber.mjs — runner config
tests/
features/*.feature — Gherkin scenarios (one feature per locked workflow)
steps/*.steps.ts — step definitions; common.steps.ts is generic
support/
world.ts — AppWorld (browser, page, login, gotoApp, expectText)
hooks.ts — spawns servers (optional), DB reset hook, browser lifecycle
types.ts — config (URLs, timeouts) read from env
reports/ — generated HTML reports
```
## Running
The harness assumes the app is reachable. Either:
**A. Start servers yourself** (recommended during dev):
```sh
yarn dev # in repo root — starts frontend + backend
yarn workspace @project/e2e test
```
**B. Let the harness manage the servers**:
```sh
E2E_MANAGE_SERVERS=1 \
E2E_BACKEND_CMD="yarn dev:backend" \
E2E_FRONTEND_CMD="yarn dev" \
yarn workspace @project/e2e test
```
## Configuration
All env vars optional; defaults match the kanban-board-template's ports.
| Var | Default | Purpose |
|----------------------|----------------------------|-----------------------------------------------|
| `APP_URL` | `http://localhost:5001` | Vite frontend URL |
| `API_URL` | `http://localhost:5003` | Pikku backend URL |
| `E2E_TIMEOUT` | `30000` | Per-step Playwright timeout (ms) |
| `E2E_MANAGE_SERVERS` | unset | Set to `1` to have hooks spawn dev servers |
| `E2E_BACKEND_CMD` | `yarn dev:backend` | Used when `E2E_MANAGE_SERVERS=1` |
| `E2E_FRONTEND_CMD` | `yarn dev` | Used when `E2E_MANAGE_SERVERS=1` |
| `E2E_RESET_URL` | unset | POST URL that resets DB to seed between tests |
| `HEADED` | unset | Set to `1` to run with a visible browser |
## DB reset between scenarios
Scenarios pollute each other. To get a deterministic DB state per scenario,
implement a `__test_reset` Pikku function gated behind a test-only flag, and
point `E2E_RESET_URL` at it. The harness will POST to it in the `Before`
hook. If the URL is unset or unreachable the harness logs a warning and
continues.
Example shape:
```ts
export const testReset = pikkuSessionlessFunc({
expose: true,
description: 'Reset DB to seed state. Test-only — gated by NODE_ENV.',
func: async ({ kysely, config }) => {
if (config.env !== 'test') throw new Error('forbidden')
await kysely.deleteFrom('booking').execute()
// ... drop + reseed all mutable tables
return { ok: true as const }
},
})
```
## Writing a feature
```gherkin
Feature: <one locked workflow>
Scenario: <one happy path>
Given I am logged in as "demo@yoga-retreat.example" with password "demo1234"
When I visit "/bookings"
Then I see "Summer Yoga Retreat"
```
Generic steps live in `tests/steps/common.steps.ts`:
- `Given I visit "<path>"`
- `Given I am logged in as "<email>" with password "<password>"`
- `When I log in as "..." with password "..."`
- `When I log out`
- `When I fill "<selector>" with "<value>"`
- `When I click "<button label>"`
- `Then I see "<text>"`
- `Then I do not see "<text>"`
- `Then the URL contains "<fragment>"`
Project-specific steps go in their own `*.steps.ts` files (e.g.
`bookings.steps.ts`). Keep `common.steps.ts` framework-agnostic so the next
project that copies this harness inherits a clean baseline.
## Tags
Run a subset by tag:
```sh
yarn workspace @project/e2e test:tag '@auth'
yarn workspace @project/e2e test:tag 'not @slow'
```
Mark scenarios as `@skip` to exclude from the default run.
## Reports
After a run, open `tests/reports/cucumber-report.html`.

20
e2e/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@project/e2e",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "End-to-end test harness — Cucumber + Playwright.",
"scripts": {
"test": "E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
"test:headed": "HEADED=1 E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
"tsc": "tsc --noEmit"
},
"devDependencies": {
"@cucumber/cucumber": "^11.0.0",
"@playwright/test": "^1.50.0",
"@types/node": "^22",
"tsx": "^4.21.0",
"typescript": "~5.8.0"
}
}

7
e2e/tests/cucumber.mjs Normal file
View File

@@ -0,0 +1,7 @@
export default {
requireModule: ['tsx'],
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
paths: ['tests/features/**/*.feature'],
format: ['progress', 'html:tests/reports/cucumber-report.html'],
forceExit: true,
}

View File

View File

@@ -0,0 +1,102 @@
@admin @bookings
Feature: Admin booking detail — read & edit
The admin booking-detail page replaces the spreadsheet admins must be able
to *edit* every column they used to edit, not just look at it.
Background:
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
When I visit "/admin/bookings/b_yoga_2026_summer"
# ── Read ────────────────────────────────────────────────────────────────
Scenario: admin opens the summer retreat detail
Then I see "Summer Yoga Retreat 2026"
And I see "Yoga Retreat e.V."
And I see "Teilnehmende"
Scenario: admin sees the seeded participant list
When I click "Teilnehmende"
Then I see "Anja Weber"
And I see "Bernd Krause"
# ── Edit booking metadata ───────────────────────────────────────────────
Scenario: admin edits the course name and persists across reload
When I fill "courseName" with "Summer Yoga Retreat 2026 revised"
And I click "Änderungen speichern"
Then I see "Gespeichert"
When I visit "/admin/bookings/b_yoga_2026_summer"
Then I see "Summer Yoga Retreat 2026 revised"
Scenario: admin updates expected participants
When I fill "expectedPersons" with "32"
And I click "Änderungen speichern"
Then I see "Gespeichert"
Scenario: admin toggles half-house and online listing flags
When I select "Ja" from "halfHouse"
And I select "Ja" from "onlineAd"
And I click "Änderungen speichern"
Then I see "Gespeichert"
Scenario: admin cannot save when no field changed
Then I see "Änderungen speichern"
# Save button is disabled until a field is touched — clicking has no effect.
# ── Edit organisation ──────────────────────────────────────────────────
Scenario: admin edits organisation contact email and phone
When I fill "organisation-email" with "ops@yoga-retreat.example"
And I fill "organisation-phone" with "+49 30 12345678"
And I click "Organisation speichern"
Then I see "Organisation gespeichert"
When I visit "/admin/bookings/b_yoga_2026_summer"
Then the field "organisation-email" has value "ops@yoga-retreat.example"
And the field "organisation-phone" has value "+49 30 12345678"
Scenario: organisation save rejects an invalid email
When I fill "organisation-email" with "not-an-email"
And I click "Organisation speichern"
Then I do not see "Organisation gespeichert"
# ── Booking status transitions ─────────────────────────────────────────
Scenario: admin cancels a booking
When I accept the next confirmation
And I click "Buchung stornieren"
Then I see "Storniert"
And I do not see "Buchung stornieren"
# ── Participants CRUD ──────────────────────────────────────────────────
Scenario: admin adds a participant
When I click "Teilnehmende"
And I fill "new-participant-name" with "Ingrid Vogel"
And I click "Hinzufügen"
Then I see "Ingrid Vogel"
Scenario: admin removes a participant
When I click "Teilnehmende"
And I accept the next confirmation
And I click "remove-participant-p_s_08"
Then I do not see "Hans Becker"
Scenario: admin changes a participant's diet
When I click "Teilnehmende"
And I select "Pescetarisch" from "participant-diet-p_s_06"
Then the field "participant-diet-p_s_06" has value "Pescetarisch"
# ── Allergies CRUD ─────────────────────────────────────────────────────
Scenario: admin adds an allergy to a participant
When I click "Teilnehmende"
And I fill "new-allergy-p_s_02" with "shellfish"
And I click "add-allergy-p_s_02"
Then I see "Meeresfrüchte"
Scenario: admin removes an existing allergy
When I click "Teilnehmende"
And I click "remove-allergy-p_s_01-nuts"
Then I do not see "nuts"
# ── Rooms assign / unassign ────────────────────────────────────────────
Scenario: admin assigns and then unassigns a participant to a room
When I click "Zimmer"
And I select "Anja Weber" from "assign-room-r_1"
Then I see "Anja Weber"
When I click "Entfernen"
Then the field "assign-room-r_1" has value ""

View File

@@ -0,0 +1,41 @@
@auth
Feature: Authentication
Users authenticate with email + password. Sessions are cookie-based.
Roles (client / admin / owner) gate access to portals.
Background:
Given I visit "/login"
Scenario: client logs in and lands on their portal
When I log in as "demo@yoga-retreat.example" with password "demo1234"
Then the URL contains "/"
And I see "Seminarhof Drawehn"
Scenario: admin logs in and reaches the admin bookings list
When I log in as "christina@seminarhof.example" with password "admin1234"
And I visit "/admin/bookings"
Then I see "Buchungen"
And I see "Summer Yoga Retreat"
Scenario: owner logs in and reaches the admin bookings list
When I log in as "sarah@seminarhof.example" with password "owner1234"
And I visit "/admin/bookings"
Then I see "Buchungen"
Scenario: wrong password is rejected
When I fill "input[type=email]" with "demo@yoga-retreat.example"
And I fill "input[type=password]" with "wrong-password"
And I click "Anmelden"
Then I see "E-Mail oder Passwort ist falsch."
Scenario: client is redirected away from the admin portal
When I log in as "demo@yoga-retreat.example" with password "demo1234"
And I visit "/admin/bookings"
Then the URL does not contain "/admin/"
Scenario: logout clears the session
Given I am logged in as "demo@yoga-retreat.example" with password "demo1234"
When I log out
Then I see "Sie wurden abgemeldet. Bis bald."
When I click "Zurück zur Anmeldung"
Then the URL contains "/login"

View File

@@ -0,0 +1,8 @@
@public @availability
Feature: Public availability calendar
Visitors can see which days the venue is free to book.
Scenario: visitor opens the availability calendar
Given I visit "/availability"
Then I see "Seminarhof Drawehn"
And I see "2026"

View File

@@ -0,0 +1,42 @@
@booking @flow
Feature: Full booking flow — enquiry through confirmed
A client submits an enquiry via the public form. The admin then drives
the booking through every step: approval, contract, deposit, confirmation.
# Guest: submit enquiry
Scenario: client submits an enquiry via the public form
Given I visit "/enquiry"
When I fill "courseName" with "E2E Full Flow Test"
And I fill "startDate" with "2028-06-15"
And I fill "endDate" with "2028-06-22"
And I fill "organisationName" with "E2E Organisation"
And I fill "contactEmail" with "e2e@example.com"
And I check "agbAccepted"
And I check "privacyAccepted"
And I click "Anfrage absenden"
Then I see "Ihre Anfrage ist eingegangen"
# ── Admin: full flow from enquiry to confirmed ──────────────────────────
Scenario: admin drives a booking from enquiry through to confirmed
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
# 1. Open the seeded enquiry directly
When I visit "/admin/bookings/b_lc_enquiry"
# 2. Approve the enquiry → reserved
When I trigger booking action "action-approve-enquiry"
Then I see "Reserviert"
And the audit log tab includes "Reservierung bestätigt"
# 3. Send the contract manually (bypasses the 365-day cron window)
When I trigger booking action "action-send-contract"
Then the audit log tab includes "Vertrag & Anzahlungsrechnung"
# 4. Record the client's contract signature
When I trigger booking action "action-contract-approved"
Then I see "Anzahlung als erhalten markieren"
# 5. Record the deposit received → confirmed
When I trigger booking action "action-deposit-received"
Then I see "Bestätigt"
And the audit log tab includes "Buchung bestätigt"

View File

@@ -0,0 +1,25 @@
@admin @lifecycle
Feature: Booking lifecycle — rule-based cron assertions
Verifies each cron rule fires against pre-seeded bookings whose dates are
computed relative to today in testReset, so the rules always trigger.
Background:
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
# ── R1: contract + deposit email ────────────────────────────────────────
Scenario: lifecycle R1 — cron sends contract and deposit email to a reserved booking
# b_lc_reserved: reserved, no contractSentAt, startDate 200 days away
Then the lifecycle cron sent 1 contracts
And the audit log for "b_lc_reserved" includes "Vertrag & Anzahlungsrechnung"
# ── R2: deposit reminder ─────────────────────────────────────────────────
Scenario: lifecycle R2 — cron sends deposit reminder when deposit is 7+ days unpaid
# b_lc_contract_sent: contractSentAt 8 days ago, deposit invoice unpaid
Then the lifecycle cron sent 1 reminders
And the audit log for "b_lc_contract_sent" includes "Anzahlungserinnerung"
# ── R4: auto-complete ────────────────────────────────────────────────────
Scenario: lifecycle R4 — cron auto-completes a confirmed booking after it ends
# b_lc_confirmed: confirmed, endDate yesterday
Then the lifecycle cron completed 2 bookings
And the booking "b_lc_confirmed" has status "Beendet"

View File

@@ -0,0 +1,13 @@
@public @events
Feature: Public events listing
Anyone can browse the public retreats listing without logging in. Only
bookings flagged for online listing appear here.
Scenario: visitor sees the published summer retreat
Given I visit "/events"
Then I see "Summer Yoga Retreat 2026"
And I see "Yoga Retreat e.V."
Scenario: unpublished retreats stay hidden
Given I visit "/events"
Then I do not see "Winter Stille 2026"

View File

@@ -0,0 +1,30 @@
@admin @invoices
Feature: Invoice creation and payment recording
Admin issues invoices against a booking and records payments when they
arrive. Both actions create audit trail entries.
Background:
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
When I visit "/admin/bookings/b_yoga_2026_summer"
And I click "Rechnungen"
Scenario: admin creates a final invoice
When I create an invoice numbered "2026-FIN-002" for 1500.00
Then I see "2026-FIN-002"
And I see "Offen"
Scenario: admin creates an invoice with an attached PDF
When I attach invoice PDF "invoice-sample.pdf"
And I create an invoice numbered "2026-PDF-001" for 500.00
Then I see "PDF öffnen"
Scenario: admin records a payment on an outstanding invoice
When I create an invoice numbered "2026-PAY-001" for 250.00
And I mark invoice "2026-PAY-001" as paid on "2026-05-10"
Then I see "Bezahlt"
Scenario: admin cancels an outstanding invoice
When I create an invoice numbered "2026-CXL-001" for 100.00
And I accept the next confirmation
And I click "invoice-cancel-2026-CXL-001"
Then I see "Storniert"

36
e2e/tests/fixtures/invoice-sample.pdf vendored Normal file
View File

@@ -0,0 +1,36 @@
%PDF-1.1
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 18 Tf
36 120 Td
(Invoice Sample) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000010 00000 n
0000000063 00000 n
0000000122 00000 n
0000000248 00000 n
0000000342 00000 n
trailer
<< /Root 1 0 R /Size 6 >>
startxref
412
%%EOF

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,270 @@
import { Given, When, Then } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { AppWorld } from '../support/world.js'
/**
* Generic step library — shared across every project that copies this harness.
*
* Project-specific business steps (e.g. "I create a booking for <date>")
* belong in their own *.steps.ts files. Keep this file framework-agnostic.
*/
Given('I visit {string}', async function (this: AppWorld, path: string) {
await this.gotoApp(path)
})
Given(
'I am logged in as {string} with password {string}',
async function (this: AppWorld, email: string, password: string) {
await this.login(email, password)
},
)
When('I log in as {string} with password {string}', async function (
this: AppWorld,
email: string,
password: string,
) {
await this.login(email, password)
})
When('I log out', async function (this: AppWorld) {
await this.logout()
})
/**
* Resolve a "field" by trying multiple strategies. CSS selectors win when the
* name looks like one. Otherwise: data-testid, then accessible label, then
* placeholder. Mantine sometimes parks data-testid on the wrapper div, so we
* descend to the inner control when present.
*/
const looksLikeCss = (s: string) => /[\[#.>:\s]/.test(s) || s.startsWith('input')
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
async function field(world: AppWorld, name: string) {
if (looksLikeCss(name)) return world.page.locator(name).first()
// Wait for the testid to attach — page often paints before React Query
// resolves, so a synchronous count() check fires too early.
const byId = world.page.locator(`[data-testid="${name}"]`).first()
try {
await byId.waitFor({ state: 'attached', timeout: 5000 })
const inner = byId.locator('input, textarea, select').first()
if (await inner.count()) return inner
return byId
} catch {
// Fall through to label / placeholder.
}
const byLabel = world.page.getByLabel(name, { exact: false })
if (await byLabel.count()) return byLabel.first()
return world.page.getByPlaceholder(name, { exact: false }).first()
}
async function isVisibleWithin(
target: ReturnType<AppWorld['page']['locator']>,
timeout = 5000,
) {
try {
await target.waitFor({ state: 'visible', timeout })
return true
} catch {
return false
}
}
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5000) {
try {
await target.click({ timeout })
return
} catch {
await target.evaluate((node) => {
;(node as HTMLElement).click()
})
}
}
async function settleAfterClick(world: AppWorld, previousUrl?: string) {
if (previousUrl) {
await world.page
.waitForURL((url) => url.toString() !== previousUrl, { timeout: 3000 })
.catch(() => {})
}
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(750)
}
async function clickAndConfirmIfNeeded(world: AppWorld, target: ReturnType<AppWorld['page']['locator']>) {
await activate(target)
await settleAfterClick(world)
if (
await isVisibleWithin(
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
1000,
)
) {
await activate(
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
1000,
)
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(250)
return
}
if (
await isVisibleWithin(
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
1000,
)
) {
await activate(
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
1000,
)
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(250)
}
}
When(
'I fill {string} with {string}',
async function (this: AppWorld, name: string, value: string) {
const target = await field(this, name)
await target.fill('')
await target.fill(value)
},
)
When('I check {string}', async function (this: AppWorld, name: string) {
const target = await field(this, name)
await target.check()
})
When('I uncheck {string}', async function (this: AppWorld, name: string) {
const target = await field(this, name)
await target.uncheck()
})
When(
'I select {string} from {string}',
async function (this: AppWorld, value: string, name: string) {
console.log(`[select] resolving "${name}"`)
const target = await field(this, name)
const tag = await target
.evaluate((el) => (el as HTMLElement).tagName.toLowerCase())
.catch(() => '')
console.log(`[select] resolved → ${tag}`)
if (tag === 'select') {
await target.selectOption({ label: value })
return
}
console.log(`[select] clicking to open`)
await target.click()
console.log(`[select] clicking option "${value}"`)
await this.page
.getByRole('option', { name: value, exact: false })
.first()
.click()
console.log(`[select] done`)
},
)
Then(
'in row {string} I do not see {string}',
async function (this: AppWorld, rowText: string, text: string) {
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
await expect(row.getByText(text, { exact: false }).first()).toBeHidden()
},
)
When(
'in row {string} I click {string}',
async function (this: AppWorld, rowText: string, label: string) {
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
const roles = ['button', 'link', 'menuitem'] as const
const previousUrl = this.page.url()
for (const role of roles) {
const candidate = row.getByRole(role, { name: new RegExp(label, 'i') }).first()
if (await isVisibleWithin(candidate)) {
await activate(candidate)
await settleAfterClick(this, previousUrl)
return
}
}
const byText = row.getByText(label, { exact: false }).first()
if (await isVisibleWithin(byText)) {
await activate(byText)
await settleAfterClick(this, previousUrl)
return
}
throw new Error(`Could not click "${label}" inside row "${rowText}"`)
},
)
When('I click {string}', async function (this: AppWorld, label: string) {
// 1) data-testid first — feature files use this for testid'd row actions.
const byId = this.page.getByTestId(label).first()
if (await isVisibleWithin(byId)) {
await clickAndConfirmIfNeeded(this, byId)
return
}
// 2) Common interactive roles. Mantine renders tabs with role=tab,
// anchors and buttons-as-links with role=link, etc.
const fuzzyName = new RegExp(escapeRegex(label), 'i')
const roles = ['button', 'tab', 'link', 'menuitem'] as const
for (const role of roles) {
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
if (await isVisibleWithin(candidate)) {
await clickAndConfirmIfNeeded(this, candidate)
return
}
}
// 3) Plain text fallback for click-handler-on-div widgets.
const byText = this.page.getByText(label, { exact: false }).first()
if (await isVisibleWithin(byText)) {
await clickAndConfirmIfNeeded(this, byText)
return
}
throw new Error(`Could not click "${label}"`)
})
When('I accept the next confirmation', async function (this: AppWorld) {
this.page.once('dialog', (d) => void d.accept())
})
When(
'I answer the next prompt with {string}',
async function (this: AppWorld, value: string) {
await this.page.evaluate((v) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).prompt = () => v
}, value)
},
)
Then('I see {string}', async function (this: AppWorld, text: string) {
await this.expectText(text)
})
Then(
'the field {string} has value {string}',
async function (this: AppWorld, name: string, value: string) {
const target = await field(this, name)
await expect(target).toHaveValue(value)
},
)
Then('I do not see {string}', async function (this: AppWorld, text: string) {
await expect(
this.page.getByText(text, { exact: false }).first(),
).toBeHidden()
})
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
await this.page.waitForURL((url) => url.toString().includes(fragment))
})
Then(
'the URL does not contain {string}',
async function (this: AppWorld, fragment: string) {
await this.page.waitForURL((url) => !url.toString().includes(fragment))
},
)

View File

@@ -0,0 +1,61 @@
import { When } from '@cucumber/cucumber'
import path from 'node:path'
import type { AppWorld } from '../support/world.js'
/**
* Project-specific invoice steps. Talk to the Invoices tab on the admin
* booking-detail page (Mantine form). Locale-aware: the form uses German
* labels by default in this app.
*/
When(
'I create an invoice numbered {string} for {float} €',
async function (this: AppWorld, invoiceNumber: string, amountEuro: number) {
// The form sits in the "Neue Rechnung" panel — use data-testid hooks so we
// are not coupled to localized labels.
const numberField = this.page
.getByTestId('invoice-create-form')
.locator('input[data-testid="invoice-number"], [data-testid="invoice-number"] input')
.first()
await numberField.fill(invoiceNumber)
const amount = this.page
.getByTestId('invoice-create-form')
.locator('input[data-testid="invoice-amount"], [data-testid="invoice-amount"] input')
.first()
await amount.fill('')
await amount.fill(String(amountEuro.toFixed(2)))
await this.page
.getByRole('button', { name: /rechnung erstellen|create invoice/i })
.click()
// Wait for the row to appear in the invoices table above the form.
await this.page.getByText(invoiceNumber, { exact: false }).first().waitFor({
state: 'visible',
})
},
)
When(
'I attach invoice PDF {string}',
async function (this: AppWorld, filename: string) {
const filePath = path.resolve(process.cwd(), 'tests', 'fixtures', filename)
await this.page.getByTestId('invoice-pdf').setInputFiles(filePath)
},
)
When(
'I mark invoice {string} as paid on {string}',
async function (this: AppWorld, invoiceNumber: string, paidOn: string) {
// Stub the prompt before clicking — the UI uses window.prompt to collect
// the payment date.
await this.page.evaluate((value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).prompt = () => value
}, paidOn)
const row = this.page.getByRole('row', { name: new RegExp(invoiceNumber) })
await row
.getByTestId(`invoice-mark-paid-${invoiceNumber}`)
.click()
await row.getByText(/bezahlt|paid/i).waitFor({ state: 'visible' })
},
)

Some files were not shown because too many files have changed in this diff Show More