chore: seminarhof customer project
This commit is contained in:
10
apps/app/.gitignore
vendored
Normal file
10
apps/app/.gitignore
vendored
Normal 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.
|
||||
33
apps/app/.stylelintrc.json
Normal file
33
apps/app/.stylelintrc.json
Normal 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
49
apps/app/README.md
Normal 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
49
apps/app/eslint.config.js
Normal 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
13
apps/app/index.html
Normal 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
507
apps/app/messages/de.json
Normal 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
507
apps/app/messages/en.json
Normal 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
53
apps/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
apps/app/postcss.config.js
Normal file
5
apps/app/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
11
apps/app/project.inlang/settings.json
Normal file
11
apps/app/project.inlang/settings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
apps/app/public/brand/logo-plum.svg
Normal file
1
apps/app/public/brand/logo-plum.svg
Normal 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 |
1
apps/app/public/brand/logo-white.svg
Normal file
1
apps/app/public/brand/logo-white.svg
Normal 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 |
1
apps/app/public/favicon.svg
Normal file
1
apps/app/public/favicon.svg
Normal 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 |
14
apps/app/src/auth.config.ts
Normal file
14
apps/app/src/auth.config.ts
Normal 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
47
apps/app/src/auth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/app/src/auth/providers/_types.ts
Normal file
20
apps/app/src/auth/providers/_types.ts
Normal 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
|
||||
}
|
||||
22
apps/app/src/auth/providers/auth0.ts
Normal file
22
apps/app/src/auth/providers/auth0.ts
Normal 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'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/github.ts.inactive
Normal file
13
apps/app/src/auth/providers/github.ts.inactive
Normal 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'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/google.ts.inactive
Normal file
13
apps/app/src/auth/providers/google.ts.inactive
Normal 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'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/microsoft.ts.inactive
Normal file
13
apps/app/src/auth/providers/microsoft.ts.inactive
Normal 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'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/passwordless.ts.inactive
Normal file
13
apps/app/src/auth/providers/passwordless.ts.inactive
Normal 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'
|
||||
},
|
||||
}
|
||||
270
apps/app/src/components/AppLayout.tsx
Normal file
270
apps/app/src/components/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
apps/app/src/components/BookingActions.tsx
Normal file
176
apps/app/src/components/BookingActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
apps/app/src/components/BookingStatusPanel.tsx
Normal file
263
apps/app/src/components/BookingStatusPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
apps/app/src/components/Brand.tsx
Normal file
34
apps/app/src/components/Brand.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
apps/app/src/components/ConfirmButton.tsx
Normal file
72
apps/app/src/components/ConfirmButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/app/src/components/DateBar.tsx
Normal file
69
apps/app/src/components/DateBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
apps/app/src/components/EventCard.tsx
Normal file
86
apps/app/src/components/EventCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
181
apps/app/src/components/FileUploader.tsx
Normal file
181
apps/app/src/components/FileUploader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
apps/app/src/components/MarketingShell.tsx
Normal file
34
apps/app/src/components/MarketingShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
601
apps/app/src/components/ParticipantsTab.tsx
Normal file
601
apps/app/src/components/ParticipantsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
apps/app/src/components/PublicShell.tsx
Normal file
62
apps/app/src/components/PublicShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
492
apps/app/src/components/RoomsTab.tsx
Normal file
492
apps/app/src/components/RoomsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
469
apps/app/src/components/SeminarhofChrome.tsx
Normal file
469
apps/app/src/components/SeminarhofChrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
apps/app/src/components/StatusPill.tsx
Normal file
58
apps/app/src/components/StatusPill.tsx
Normal 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
40
apps/app/src/direction.ts
Normal 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
101
apps/app/src/i18n/config.ts
Normal 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
|
||||
}
|
||||
12
apps/app/src/i18n/ident.ts
Normal file
12
apps/app/src/i18n/ident.ts
Normal 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()
|
||||
57
apps/app/src/i18n/messages.ts
Normal file
57
apps/app/src/i18n/messages.ts
Normal 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
24
apps/app/src/lib/auth.ts
Normal 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
24
apps/app/src/lib/dates.ts
Normal 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)
|
||||
}
|
||||
35
apps/app/src/lib/status.ts
Normal file
35
apps/app/src/lib/status.ts
Normal 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' },
|
||||
}
|
||||
98
apps/app/src/pages/__root.tsx
Normal file
98
apps/app/src/pages/__root.tsx
Normal 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
|
||||
}
|
||||
1029
apps/app/src/pages/admin.bookings.$bookingId.tsx
Normal file
1029
apps/app/src/pages/admin.bookings.$bookingId.tsx
Normal file
File diff suppressed because it is too large
Load Diff
259
apps/app/src/pages/admin.bookings.index.tsx
Normal file
259
apps/app/src/pages/admin.bookings.index.tsx
Normal 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,
|
||||
})
|
||||
10
apps/app/src/pages/admin.bookings.tsx
Normal file
10
apps/app/src/pages/admin.bookings.tsx
Normal 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>
|
||||
),
|
||||
})
|
||||
563
apps/app/src/pages/admin.enquiries.tsx
Normal file
563
apps/app/src/pages/admin.enquiries.tsx
Normal 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,
|
||||
})
|
||||
311
apps/app/src/pages/admin.index.tsx
Normal file
311
apps/app/src/pages/admin.index.tsx
Normal 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>
|
||||
),
|
||||
})
|
||||
402
apps/app/src/pages/availability.tsx
Normal file
402
apps/app/src/pages/availability.tsx
Normal 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,
|
||||
})
|
||||
337
apps/app/src/pages/booking-form.$token.tsx
Normal file
337
apps/app/src/pages/booking-form.$token.tsx
Normal 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,
|
||||
})
|
||||
477
apps/app/src/pages/booking.$bookingId.tsx
Normal file
477
apps/app/src/pages/booking.$bookingId.tsx
Normal 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,
|
||||
})
|
||||
382
apps/app/src/pages/enquiry.tsx
Normal file
382
apps/app/src/pages/enquiry.tsx
Normal 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
|
||||
// 0–3 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,
|
||||
}),
|
||||
})
|
||||
113
apps/app/src/pages/events.tsx
Normal file
113
apps/app/src/pages/events.tsx
Normal 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,
|
||||
})
|
||||
276
apps/app/src/pages/index.tsx
Normal file
276
apps/app/src/pages/index.tsx
Normal 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,
|
||||
})
|
||||
164
apps/app/src/pages/kanban.tsx
Normal file
164
apps/app/src/pages/kanban.tsx
Normal 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,
|
||||
})
|
||||
92
apps/app/src/pages/login.tsx
Normal file
92
apps/app/src/pages/login.tsx
Normal 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,
|
||||
})
|
||||
36
apps/app/src/pages/logout.tsx
Normal file
36
apps/app/src/pages/logout.tsx
Normal 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,
|
||||
})
|
||||
353
apps/app/src/routeTree.gen.ts
Normal file
353
apps/app/src/routeTree.gen.ts
Normal 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
16
apps/app/src/router.tsx
Normal 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
129
apps/app/src/specs.md
Normal 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
71
apps/app/src/theme.css
Normal 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
75
apps/app/src/theme.ts
Normal 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
24
apps/app/tsconfig.json
Normal 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
41
apps/app/vite.config.ts
Normal 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',
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user