Prima Get an API key

Module kit / Calendar and booking

Calendar Settings Panel

calendar-booking/ui/calendar-settings-panellive

Kind frontend
Surface SPA
Maturity prod
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-dom-7, tailwindcss-3.4, alchemy-ui, axios-1, lucide-react-0.563
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders calendar-booking/calendar-connection, calendar-booking/meeting-type-configuration, calendar-booking/availability-computation, calendar-booking/booking-confirmation-flow, calendar-booking/embed-widget-delivery
Entry alchemy_labs/frontend/src/pages/CalendarSettings.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/CalendarSettings.tsx
  • alchemy_labs/frontend/src/pages/AdminCalendarSettings.tsx
  • alchemy_labs/frontend/src/components/calendar/AlternativeCalendarConnect.tsx
  • alchemy_labs/frontend/src/components/prima/PrimaMachtsButton.tsx
  • alchemy_labs/frontend/src/components/forms/TallyFormBuilder/MessageStepsEditor.tsx
  • alchemy_labs/frontend/src/utils/calendarEmbed.ts
  • alchemy_labs/frontend/src/utils/messageOrigin.ts
  • alchemy_labs/frontend/src/contexts/WorkspaceContext.tsx
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/components/AdminNavigation.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/App.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/backend/api/routers/calendar_router.py
  • alchemy_labs/backend/models/calendar_integration_model.py
  • alchemy_labs/backend/app_fastapi.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

The operator's own calendar page: connect a Google Calendar (or, workspace-side, an iCal feed or CalDAV calendar without OAuth), define the bookable meeting types customers embed on their site, and see who has booked, is booking today, or already met. The invariant that makes it trustworthy: every card is a straight read of the three GET /api/calendar/{workspace|admin}/* endpoints this page calls on mount, so what an operator sees here - whether Google is actually connected, which meeting types actually exist, whose booking actually landed - is never a locally-computed guess; every write (connect, disconnect, save or delete a meeting type, save the default messages) is followed by the same refetch, the same pattern billing-credits/ui/ subscription-manager documents for its own balance.

It makes visible four of its five sibling backend recipes directly - the OAuth/iCal/CalDAV connect flow (calendar-connection), the meeting-type CRUD and its default/per-type message inheritance (meeting-type-configuration), the confirmation/reminder/cancellation side of a booking once it exists (booking-confirmation-flow), and the <iframe> code this page hands the operator to paste on their site (embed-widget-delivery) - plus, indirectly, the fifth (availability-computation): the schedule this page lets an operator type in per weekday is the input that recipe turns into bookable slots on the public embed, which this page never itself computes or previews.

When to use

Trigger phrases: "the calendar page", "connect Google Calendar", "meeting types", "the embed code", "who's booked today", "Kalender-Einstellungen", "Kalender-Integration". Surface: SPA, two private routes behind two different guards - /calendar (PrivateRoute, any signed-in workspace member, "Bookings" domain) and /admin/calendar (AdminRoute, platform admins only, meant for the "Operate" domain).

Not this recipe: the public booking page a meeting type's embed code actually opens (/api/calendar/embed/ {slug}, server-rendered, no React here, embed-widget-delivery's territory); the OAuth popup's own screens (Google's, not Labs'); the day-by-day slot computation the operator never sees rendered (availability-computation); the confirmation/reminder/follow-up e-mails a booking triggers (booking-confirmation-flow); the actual message-step editor a click on either Nachrichten toggle would open (MessageStepsEditor, the forms domain, a different recipe entirely, Frontend); the iCal/CalDAV tab forms (AlternativeCalendarConnect, Frontend).

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19.1, react-router-dom 7.8, tailwindcss 3.4, lucide-react 0.563, axios 1.11. Component library: @prima-modules/alchemy-ui, of which the page uses Button, Heading, Text, Card, Input, Textarea, Spinner, Badge and Toggle

  • Toggle is genuinely new to this domain versus billing-credits/ui/subscription-manager's atom list. Two lucide icons are imported and never drawn in the workspace file: User (line 22) and Copy (line 15, the workspace embed button uses Code/Check - Copy is the admin file's icon for the same button, Gotchas). Data-fetch style: the api singleton (S2), Bearer header from accessToken. Copy language: German (de-CH) throughout both files, with one real, unfixed asymmetry: the workspace file's LOCATION_TYPES labels "Telefon" and "Vor Ort" in German where the admin file's identical-looking list (same four values) labels the same two "Phone Call" and "In Person" in English (workspace 98-99, admin 91-92).

Data model

Client state, all in the page component, nothing persisted beyond S2's two tokens:

interface CalendarIntegration {                    // GET .../integration, unchanged from the route's JSON
  id: string; workspace_id: string | null; provider: string;       // 'google' | 'ical_feed' | 'caldav'
  provider_account_id: string | null; provider_account_name: string | null;
  calendar_id: string; timezone: string; is_active: boolean;
  connected_at: string | null; last_sync_at: string | null; is_platform_level: boolean;
}
interface MeetingType {
  id: string; calendar_integration_id: string; slug: string; name: string; description: string | null;
  duration_minutes: number; buffer_before_minutes: number; buffer_after_minutes: number;
  min_notice_hours: number; max_days_ahead: number; availability_schedule: Record<string, string[]>;
  location_type: string; custom_location: string | null; zoom_link: string | null; phone_number: string | null;
  color: string; booking_questions: any[]; embed_settings: Record<string, any>; is_active: boolean;
  messages: any[] | null;                          // workspace scope ONLY; absent from the admin interface
  created_at: string | null;
}
interface Booking {
  id: string; meeting_type_id: string; starts_at: string; ends_at: string;
  attendee_email: string; attendee_name: string; meeting_link: string | null; location: string | null;
  status: string; google_event_link: string | null;
}
// workspace file only:
integration, meetingTypes, bookings, isLoading, isConnecting, message, showPastBookings: boolean;
showMeetingTypeForm, editingMeetingType, meetingTypeForm (name/slug/description/duration_minutes/
  buffer_before_minutes/buffer_after_minutes/min_notice_hours/max_days_ahead/location_type/custom_location/
  zoom_link/phone_number/color/availability_schedule/messages), isSavingMeetingType;
defaultMessages: any[] | null, isSavingDefaults, defaultsLoaded;   // calendar-wide Nachrichten default, new
                                                                    // since 2e1b47d (see BRAIN.md's session log)
expandedMeetingType, copiedId;
categorizedBookings = useMemo(() => { today, upcoming, past }, [bookings]);   // reads new Date() live, Gotchas

The demo's props (demo.tsx): { viewer, workspace, scope: 'workspace' | 'platform-admin', status: 'loading' | 'ok', integration, meetingTypes, bookings, now, message?, isConnecting?, showPastBookings?, defaultMessages?, showMeetingTypeForm?, editingMeetingType?, meetingTypeForm?, isSavingMeetingType?, expandedMeetingTypeId? }; now replaces the source's live new Date() read inside categorizedBookings (Frontend, Gotchas) so the demo categorises bookings deterministically across a server render and a later client hydration. showPastBookings, defaultMessages, showMeetingTypeForm, the form fields and expandedMeetingTypeId all seed local useState in the demo the same way billing-credits/ui/subscription-manager seeds its voucher input, so the toggles, the form and the row-expand chevron are genuinely interactive in the docs frame even though nothing reaches a network.

Browser storage: nothing of its own beyond S2's two tokens.

API surface

Contracts consumed, one shared read pattern and five writes, workspace paths on the left, the admin file's own separate mounts on the right (calendar_router.py, both under LABS_SURFACES):

CallWorkspace routeAdmin routeAuth / tenancyRendered as
readGET /workspace/integration?workspace_id=GET /admin/integrationworkspace member vs AdminRoutethe connection-status card
readGET /workspace/meeting-typesGET /admin/meeting-typessamethe meeting-types list
readGET /workspace/bookingsGET /admin/bookings?limit=10samethe bookings section (admin: server-capped at 10, no client-side categorisation to hide the cap)
readGET /workspace/integration/default-messages- (no admin equivalent)samethe Standard-Nachrichten toggle's seed
writePOST /workspace/connectPOST /admin/connectsameOAuth popup opened with the response's authorization_url
writeDELETE /workspace/disconnectDELETE /admin/disconnectsameintegration cleared client-side on 200
writePOST / PUT /workspace/meeting-types[/:id]POST / PUT /admin/meeting-types[/:id]samethe form closes, fetchData() refetches
writeDELETE /workspace/meeting-types/:idDELETE /admin/meeting-types/:idsamerow removed from local state directly (no refetch)
writePUT /workspace/integration/default-messages-samesuccess/error message card
writePATCH /workspace/integration/timezone-sameintegration's timezone patched client-side; failure is console.error only, no user-facing message at all (Gotchas)

Error bodies as both files read them: every refusal from these routes is HTTPException(detail=...) (calendar_router.py), wrapped by app_fastapi.py's handler (line 719-767 at 62ba4fc) into the Labs envelope under the key error, not detail (create_error_response, error_schemas.py line 130). Both files' six catch blocks read error.response?.data?.detail - the same mismatch billing-credits/ui/subscription-manager and identity-auth/ui/admin-users-console document for their own pages - so every specific server message ("workspace_id is required to connect a calendar", "No calendar connected. Connect Google, iCal, or CalDAV first.", "Meeting type with slug 'x' already exists", and so on) is discarded and replaced by the page's own fixed fallback sentence. No WebSocket. window.addEventListener('message', ...) listens for the OAuth popup's postMessage({type: 'CALENDAR_CONNECTED', success, error}), checked against isTrustedMessageOrigin before use in both files.

Application logic

  1. Workspace file only: on mount, guards for isLoadingWorkspaces and a workspace-less account render before any calendar UI (lines 500-544, out of this recipe's scope - identity-auth/ui/workspace-switcher-and- bootstrap's territory).
  2. fetchData() (workspace 174-212, admin 151-177) fires once per file's own mount effect: GET .../ integration first; if it returns an integration, meeting types and bookings are fetched in parallel (workspace additionally fetches default-messages in the same Promise.all, its own .then/.catch setting defaultMessages/defaultsLoaded independently of the other two calls - a deliberate positional ordering the source comments warn about, lines 188-189, so a future fourth call must be appended last, not inserted).
  3. googleConnected (workspace 559) is integration?.provider === 'google' - an iCal or CalDAV integration still unlocks meeting types below but renders the connection card as "nicht verbunden". The admin file has no such distinction: it only ever connects Google, so integration truthy is enough (admin has no iCal/ CalDAV path at all - AlternativeCalendarConnect is workspace-only).
  4. Connect: opens a popup to the response's authorization_url; a blocked popup sets an error and re-enables the button; the popup's own eventual postMessage is what actually flips isConnecting back off and refetches (step 2's listener), not the connect call's own response.
  5. Disconnect: window.confirm first (both files, same German sentence); on confirm, clears integration and meetingTypes client-side (admin also clears bookings, workspace does not - a minor, real asymmetry) and shows a success card, no refetch needed since the cleared state already matches "disconnected".
  6. Meeting-type save: custom_location/zoom_link/phone_number are nulled server-side unless location_type matches (both files, same three-line spread); on success the form resets and fetchData() refetches; on failure the fixed fallback sentence appears and the form stays open with whatever was typed.
  7. Meeting-type delete: window.confirm first; on confirm, the row is filtered out of local state directly - no refetch, so a delete that raced a concurrent edit by someone else would not be caught here.
  8. Standard-Nachrichten (workspace only): the Toggle switches defaultMessages between null (inherit nothing, calendar sends nothing by default) and [] (on, empty - the operator has not written a step yet); the actual step content is edited by MessageStepsEditor, not part of this recipe (Frontend). Saving PUTs the whole array; the comment on screen is explicit that a save applies to new bookings only, not ones already scheduled (line 727-729).
  9. Per-meeting-type Nachrichten (workspace only, inside the form): the same null/[] toggle, but null here means "inherit the calendar's Standard-Nachrichten" rather than "send nothing" - the two toggles share a shape but not a meaning, and the form's own comment (lines 1049-1051) is the only place that says so.
  10. copyEmbedCode writes generateEmbedCode(slug, integration?.id) (workspace) or generateEmbedCode(slug) with no integration id (admin, calendarEmbed.ts's integrationId param is optional) to the clipboard and flips a 2-second checkmark; the admin embed URL is therefore not disambiguated by integration id the way the workspace one is, harmless today since a platform admin has exactly one platform-level integration.
  11. Bookings: categorizedBookings (workspace only, admin has no categorisation) filters out status === 'cancelled', then buckets by comparing each starts_at's calendar day against new Date()'s; today and upcoming sort ascending, past sorts descending and is sliced to 10 behind a collapsed toggle, default closed.

Invariants

  • Every card is a refetch away from the server, never a locally-computed write: connect, disconnect, save and delete a meeting type all either refetch (fetchData()) or splice local state to match what the server just confirmed (steps 5, 7) rather than optimistically rendering an assumed result.
  • A meeting type's own Nachrichten and the calendar's Standard-Nachrichten are two independently toggled null/[] states with two different meanings for null (step 9) - a genuinely easy invariant to break by accident in a rebuild, since both toggles look and read identically.
  • Deleting a meeting type does not refetch, only filters local state (step 7); a rebuild that adds optimistic UI elsewhere should not assume this page already refetches after every write - it does not, uniformly.
  • The raw server error is never shown on either file, only one of six fixed fallback sentences, or nothing at all for a failed timezone patch (API surface, Gotchas); an operator never learns why a save, a connect or a disconnect actually failed.

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

First fetch: the bare spinner inside the shell, before the integration is known (line 546-554) loading server rendered, hydrated in view
Laden...
No calendar connected: the Verbinden card plus the closed iCal/CalDAV alternative-connect header disconnected server rendered, hydrated in view

Kalender

Verbinde deinen Google Calendar und erstelle buchbare Termine

Google Calendar nicht verbunden

Andere Kalender (iCal · CalDAV)

Hostpoint, Fastmail, mailbox.org, ProtonCalendar — ohne OAuth.

Google connected, no meeting types or bookings yet: the two empty-state cards, Standard-Nachrichten off connected-empty server rendered, hydrated in view

Kalender

Verbinde deinen Google Calendar und erstelle buchbare Termine

Google Calendar verbunden

[email protected] (acct_gcal_sample_1)

Standard-Nachrichten

Gelten für alle Meeting-Typen, die keine eigenen Nachrichten haben. Platzhalter: {{name}}, {{meeting}}, {{date}}, {{time}}, {{location}}.

Keine Standard-Nachrichten. Einschalten, um eine Bestätigung, eine Erinnerung oder ein Follow-up für alle Termine zu schreiben.

Meeting-Typen

Noch keine Meeting-Typen. Erstelle deinen ersten buchbaren Termin.

Gebuchte Termine

Noch keine Buchungen vorhanden.

Three meeting types, bookings across today/upcoming/past (collapsed), Standard-Nachrichten on populated server rendered, hydrated in view

Kalender

Verbinde deinen Google Calendar und erstelle buchbare Termine

Google Calendar verbunden

[email protected] (acct_gcal_sample_1)

Standard-Nachrichten

Gelten für alle Meeting-Typen, die keine eigenen Nachrichten haben. Platzhalter: {{name}}, {{meeting}}, {{date}}, {{time}}, {{location}}.

Bestätigung

bei Terminbeginn

Erinnerung

24 Stunden vorher

Der Editor (MessageStepsEditor, forms-Domäne) ist nicht Teil dieses Kits.

Meeting-Typen

Erstgespräch

30 Min.•Google Meet•Vorlauf 24h•Puffer 0/15 Min.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/erstgespraech" width="100%" height="600" frameborder="0"></iframe>

Strategie-Call

60 Min.•Zoom•Vorlauf 48h•Puffer 10/15 Min.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/strategie-call" width="100%" height="600" frameborder="0"></iframe>

Vor-Ort-Termin

45 Min.•Vor Ort•Vorlauf 72h•Puffer 15/15 Min.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/vor-ort-termin" width="100%" height="600" frameborder="0"></iframe>

Gebuchte Termine

2 Termine heute

Nächster: 09:00 - Lea Furrer

Heute (2)

09:00

Lea Furrer

[email protected]

Erstgespräch - 30 Min.

13:00

Noah Meier

[email protected]

Strategie-Call - 60 Min.

Bevorstehend (2)

Morgen

10:00

Sina Baumann

[email protected]

Erstgespräch

Mi, 16.09.

15:00

Jonas Rieder

[email protected]

Vor-Ort-Termin

Creating a meeting type: the form open over the existing list, per-type Nachrichten inheriting the default meeting-type-form-open server rendered, hydrated in view

Kalender

Verbinde deinen Google Calendar und erstelle buchbare Termine

Google Calendar verbunden

[email protected] (acct_gcal_sample_1)

Standard-Nachrichten

Gelten für alle Meeting-Typen, die keine eigenen Nachrichten haben. Platzhalter: {{name}}, {{meeting}}, {{date}}, {{time}}, {{location}}.

Bestätigung

bei Terminbeginn

Erinnerung

24 Stunden vorher

Der Editor (MessageStepsEditor, forms-Domäne) ist nicht Teil dieses Kits.

Meeting-Typen

Neuer Meeting-Typ

Alle Verfügbarkeitszeiten beziehen sich auf diese Zeitzone.

Montag

Dienstag

Mittwoch

Donnerstag

Freitag

Samstag

Sonntag

Format: HH:MM-HH:MM. Mehrere Zeitfenster mit Komma trennen. Leer lassen = nicht verfügbar.

Bestätigung, Erinnerung und Follow-up per E-Mail. Platzhalter: {{name}}, {{meeting}}, {{date}}, {{time}}, {{location}}.

Verwendet die Standard-Nachrichten des Kalenders. Einschalten, um für diesen Meeting-Typ eigene zu schreiben.

Erstgespräch

30 Min.•Google Meet•Vorlauf 24h•Puffer 0/15 Min.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/erstgespraech" width="100%" height="600" frameborder="0"></iframe>

Strategie-Call

60 Min.•Zoom•Vorlauf 48h•Puffer 10/15 Min.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/strategie-call" width="100%" height="600" frameborder="0"></iframe>

Vor-Ort-Termin

45 Min.•Vor Ort•Vorlauf 72h•Puffer 15/15 Min.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/vor-ort-termin" width="100%" height="600" frameborder="0"></iframe>

Gebuchte Termine

2 Termine heute

Nächster: 09:00 - Lea Furrer

Heute (2)

09:00

Lea Furrer

[email protected]

Erstgespräch - 30 Min.

13:00

Noah Meier

[email protected]

Strategie-Call - 60 Min.

Bevorstehend (2)

Morgen

10:00

Sina Baumann

[email protected]

Erstgespräch

Mi, 16.09.

15:00

Jonas Rieder

[email protected]

Vor-Ort-Termin

Platform-admin at /admin/calendar: no Standard-Nachrichten, one row expanded, a flat Aktuelle-Buchungen list admin-variant server rendered, hydrated in view

Kalender-Integration

Google Calendar verbinden und Meeting-Typen für das Onboarding verwalten

Google Calendar verbunden

[email protected] (acct_gcal_sample_1)• Verbunden am 03.08.2026, 09:12

Meeting-Typen

Onboarding-Call

30 Min.•Google Meet•/onboarding-call

Mindestvorlauf

24h

Max. Tage voraus

28

Puffer vorher

0 Min.

Puffer nachher

15 Min.

Beschreibung

Erstgespräch für neue Plattform-Kund:innen.

Embed-Code

<iframe src="https://api.example.com/api/calendar/embed/onboarding-call" width="100%" height="600" frameborder="0"></iframe>

Partner-Check-in

Inaktiv

45 Min.•Phone Call•/partner-check-in

Aktuelle Buchungen

Agentur Nordlicht

[email protected]

12.09.2026, 11:00

confirmed

Kollektiv Aare

[email protected]

15.09.2026, 14:00

pending

Studio Winkel

[email protected]

08.09.2026, 09:00

cancelled
calendar-booking/ui/calendar-settings-panel/demo.tsx
/**
 * Live demo for calendar-booking/ui/calendar-settings-panel.
 *
 * Primary source: Labs' CalendarSettings.tsx (branch main, 62ba4fc, 1369
 * lines) — the workspace-scoped operator page at `/calendar`, mounted under
 * `PrivateRoute` ("Bookings" domain, nav.tsx line 133, `App.tsx` line 455).
 * Its one read (`fetchData`, three parallel GETs) and five writes (connect,
 * disconnect, save/delete a meeting type, save the calendar-wide default
 * messages) are replaced by props; the meeting-type form and the default-
 * messages toggle keep their own local `useState`, seeded from props, the
 * same convention identity-auth/ui/admin-users-console's demo uses for its
 * search box and billing-credits/ui/subscription-manager's for its voucher
 * input, so typing and toggling in the docs frame still works.
 *
 * The `admin-variant` state approximates a SECOND, genuinely separate real
 * file: `AdminCalendarSettings.tsx` (969 lines), the platform-admin page at
 * `/admin/calendar` (`AdminRoute`, `App.tsx` line 799, "Operate" domain).
 * It is not a prop branch of one source file the way, say, an admin flag
 * changes one Labs page elsewhere — it is its own component, its own fetch
 * calls (`/api/calendar/admin/*`, no `workspace_id`), and a `MeetingType`
 * type with no `messages` field at all. This recipe reproduces the real,
 * measured differences (named below and in Gotchas) rather than inventing a
 * single unified admin/workspace branch that does not exist in Labs; the
 * admin file is a secondary `reference_impl` entry, not the one this recipe
 * is measured against pixel-for-pixel.
 *
 * What is kept byte-faithful (workspace scope, the primary source): the
 * header, the connection-status card's copy and layout, the meeting-types
 * list row (color bar, duration/location/notice/buffer summary line, the
 * embed-code box always visible under the row), the meeting-type form's
 * field set and its one genuine gap (Data model / Gotchas), the
 * Standard-Nachrichten toggle, the bookings section's today/upcoming/past
 * categorisation with the pulsing "today" summary banner and the collapsed
 * past list sliced to 10, and every string of copy, German (de-CH),
 * including the two dead icon imports (`User`, `Copy`) the source carries
 * and this demo does not re-import.
 *
 * What is different, and why:
 * - `new Date()` (the source's `categorizedBookings` useMemo, line 454) is
 *   replaced by an injected `now` prop: a server-rendered frame and a later
 *   client hydration of the same demo could otherwise categorise a booking
 *   into "today" on one render and "past" on the other if a real clock
 *   crossed midnight between them. Sample data is dated around `now` so the
 *   three buckets are non-empty.
 * - `toLocaleString('de-CH', …)` / `toLocaleDateString('de-CH', …)` (lines
 *   414-450) are replaced with fixed formatters reading UTC getters against
 *   a lookup table, the same class of fix identity-auth/ui/admin-users-
 *   console's demo applies to `toLocaleString()` and this domain's sibling
 *   billing-credits/ui/subscription-manager applies to `toLocaleDateString`:
 *   the browser's ICU data does not have to match Node's for the frame to
 *   render the same text twice.
 * - `AlternativeCalendarConnect` (the iCal/CalDAV card, `disconnected`
 *   state): its header row is reproduced exactly (same `Card`, same copy,
 *   same closed-by-default `Button`), because that row is what the real
 *   page shows before a click. Its open state — two tab forms that call
 *   `GET /api/calendar/workspace/integrations` on mount and POST a feed URL
 *   or CalDAV credentials — is not rendered: it is a separate component, not
 *   part of the four packages a demo may import from (AUTHORING.md), and it
 *   reaches the network on mount, which this demo must not do.
 * - `PrimaMachtsButton` (the AI-companion button beside "Neu" in the
 *   workspace file, `usePrima`/`PrimaContext`-dependent) is not rendered,
 *   the same way billing-credits/ui/subscription-manager does not mount
 *   `SubscriptionModal`: it needs a provider this kit does not carry.
 * - `MessageStepsEditor` (the actual message-step editor, `forms` domain,
 *   not part of the four packages) is not mounted. The Standard-Nachrichten
 *   and per-meeting-type Nachrichten `Toggle`s are real and interactive
 *   (both are `@prima-modules/alchemy-ui` `Toggle`s, nothing page-local);
 *   when a toggle is on, a read-only summary row per configured step (its
 *   type as a `Badge`, its timing as plain text) stands in for the editor.
 * - `window.confirm` before disconnect/delete and `window.open` for the
 *   OAuth popup are not wired — a demo has no user to confirm with and
 *   reaches no network — the same convention subscription-manager's demo
 *   uses for its own `window.confirm`/`window.location.href`. The
 *   `window.addEventListener('message', …)` OAuth-callback listener has
 *   nothing to listen to without a real popup, so it is not attached either.
 * - `navigator.clipboard.writeText` on "copy embed code" is not called; the
 *   copied-checkmark swap it drives is reproduced from local state alone, so
 *   clicking it in the docs frame still shows the checkmark.
 * - Real, structural admin-vs-workspace differences reproduced in
 *   `admin-variant`: no Standard-Nachrichten section and no per-type
 *   Nachrichten toggle (the admin `MeetingType` interface has no `messages`
 *   field, AdminCalendarSettings.tsx lines 57-70); no `AlternativeCalendar
 *   Connect` card (admin is Google-only); the "Neu" button reads "Neuer
 *   Meeting-Typ" (admin line 502) not "Neu"; each meeting-type row expands
 *   on a chevron click into a details grid instead of always showing the
 *   embed box (admin lines 856-905); an "Inaktiv" `Badge` on a disabled
 *   meeting type (admin has this, workspace has no such badge); English
 *   location labels ("Phone Call", "In Person", admin lines 91-96) against
 *   the workspace file's German ones ("Telefon", "Vor Ort") — a real,
 *   unfixed inconsistency between the two files, reproduced not corrected;
 *   bookings render as a flat "Aktuelle Buchungen" list with a raw status
 *   `Badge` (admin lines 912-967) instead of the workspace file's
 *   today/upcoming/past breakdown. The admin meeting-type FORM itself
 *   (which does add address/phone fields the workspace form lacks, drops
 *   the timezone selector for static text, and caps buffers/lookahead
 *   lower) is not rendered here — no state opens it, so it is documented in
 *   this recipe's Gotchas and Reference implementation rather than built,
 *   the same restraint billing-credits/ui/subscription-manager applies to
 *   `SubscriptionModal`'s own body.
 * - Sample data (workspace name, attendee names and e-mails, calendar
 *   account name, every id) is fictional; no id from a real Labs workspace,
 *   Google account or booking is reproduced (AUTHORING.md house rules).
 */
import React, { useState } from 'react';
import {
  Calendar,
  Check,
  ChevronDown,
  ChevronUp,
  Clock,
  Code,
  Copy,
  Edit,
  ExternalLink,
  Link2,
  Link2Off,
  MapPin,
  Phone,
  Plus,
  RefreshCw,
  Trash2,
  Video,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Button, Card, Heading, Input, Spinner, Text, Textarea, Toggle } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';

/* ---- types (CalendarSettings.tsx lines 43-93, AdminCalendarSettings.tsx lines 37-79) ---------- */

export interface CalendarIntegration {
  id: string;
  workspace_id: string | null;
  provider: string;
  provider_account_id: string | null;
  provider_account_name: string | null;
  calendar_id: string;
  timezone: string;
  is_active: boolean;
  connected_at: string | null;
  last_sync_at: string | null;
  is_platform_level: boolean;
}

export interface MessageStep {
  id: string;
  type: 'confirmation' | 'reminder' | 'followup';
  enabled: boolean;
  timing?: { anchor: 'on_event' | 'before_event' | 'after_event'; value: number; unit: 'minutes' | 'hours' | 'days' | 'weeks' };
}

export interface MeetingType {
  id: string;
  calendar_integration_id: string;
  slug: string;
  name: string;
  description: string | null;
  duration_minutes: number;
  buffer_before_minutes: number;
  buffer_after_minutes: number;
  min_notice_hours: number;
  max_days_ahead: number;
  availability_schedule: Record<string, string[]>;
  location_type: string;
  custom_location: string | null;
  zoom_link: string | null;
  phone_number: string | null;
  color: string;
  is_active: boolean;
  /** Workspace scope only: null = inherit the calendar default; [] = deliberately none; [...] = own.
   *  The admin `MeetingType` interface has no such field (AdminCalendarSettings.tsx 57-70) — left
   *  undefined for an admin-scope row. */
  messages?: MessageStep[] | null;
  created_at: string | null;
}

export interface Booking {
  id: string;
  meeting_type_id: string;
  starts_at: string;
  ends_at: string;
  attendee_email: string;
  attendee_name: string;
  meeting_link: string | null;
  location: string | null;
  status: string;
  google_event_link: string | null;
}

export interface MeetingTypeFormState {
  name: string;
  slug: string;
  description: string;
  duration_minutes: number;
  buffer_before_minutes: number;
  buffer_after_minutes: number;
  min_notice_hours: number;
  max_days_ahead: number;
  location_type: string;
  custom_location: string;
  zoom_link: string;
  phone_number: string;
  color: string;
  availability_schedule: Record<string, string[]>;
  messages: MessageStep[] | null;
}

export interface Props {
  /** The signed-in operator, for the shell's account row. `isAdmin` also picks the shell's rail. */
  viewer: ShellUser;
  workspace: ShellWorkspace;
  /** Which real file this state approximates: CalendarSettings.tsx (workspace) or AdminCalendarSettings.tsx (platform-admin). */
  scope: 'workspace' | 'platform-admin';
  /** `loading` is the first fetch (line 546-554, a bare spinner inside the shell); `ok` is any state after. */
  status: 'loading' | 'ok';
  integration: CalendarIntegration | null;
  meetingTypes: MeetingType[];
  bookings: Booking[];
  /** Fixed "now" the bookings categorisation reads instead of `new Date()` (see header comment). */
  now: string;
  message?: { type: 'success' | 'error'; text: string } | null;
  isConnecting?: boolean;
  showPastBookings?: boolean;
  /** Workspace scope only. `null` seeds the toggle off; a non-null array (possibly empty) seeds it on. */
  defaultMessages?: MessageStep[] | null;
  showMeetingTypeForm?: boolean;
  editingMeetingType?: MeetingType | null;
  meetingTypeForm?: MeetingTypeFormState;
  isSavingMeetingType?: boolean;
  /** Admin scope only: which row's details panel starts expanded. */
  expandedMeetingTypeId?: string | null;
}

/* ---- constants (CalendarSettings.tsx 95-120, AdminCalendarSettings.tsx 87-113) ---------------- */

/** The workspace file's labels (German). The admin file's are English (below) — a real, unfixed inconsistency. */
const LOCATION_TYPES_WORKSPACE = [
  { value: 'google_meet', label: 'Google Meet', icon: Video },
  { value: 'zoom', label: 'Zoom', icon: Video },
  { value: 'phone', label: 'Telefon', icon: Phone },
  { value: 'in_person', label: 'Vor Ort', icon: MapPin },
];
const LOCATION_TYPES_ADMIN = [
  { value: 'google_meet', label: 'Google Meet', icon: Video },
  { value: 'zoom', label: 'Zoom', icon: Video },
  { value: 'phone', label: 'Phone Call', icon: Phone },
  { value: 'in_person', label: 'In Person', icon: MapPin },
];

const DAY_LABELS: Record<string, string> = {
  mon: 'Montag', tue: 'Dienstag', wed: 'Mittwoch', thu: 'Donnerstag', fri: 'Freitag', sat: 'Samstag', sun: 'Sonntag',
};
const DEFAULT_SCHEDULE: Record<string, string[]> = {
  mon: ['09:00-17:00'], tue: ['09:00-17:00'], wed: ['09:00-17:00'], thu: ['09:00-17:00'], fri: ['09:00-17:00'], sat: [], sun: [],
};

const MESSAGE_TYPE_LABEL: Record<MessageStep['type'], string> = { confirmation: 'Bestätigung', reminder: 'Erinnerung', followup: 'Follow-up' };
function formatTiming(step: MessageStep): string {
  if (!step.timing) return '';
  const { anchor, value, unit } = step.timing;
  const UNIT_LABEL: Record<string, string> = { minutes: 'Minuten', hours: 'Stunden', days: 'Tage', weeks: 'Wochen' };
  if (anchor === 'on_event') return 'bei Terminbeginn';
  const dir = anchor === 'before_event' ? 'vorher' : 'danach';
  return `${value} ${UNIT_LABEL[unit]} ${dir}`;
}

/* ---- date helpers (replace the source's locale-dependent formatting; see header comment) ------ */

const WEEKDAY_SHORT_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const pad = (n: number) => String(n).padStart(2, '0');
function formatTime(iso: string): string {
  const d = new Date(iso);
  return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function formatDateTime(iso: string): string {
  const d = new Date(iso);
  return `${pad(d.getUTCDate())}.${pad(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}, ${formatTime(iso)}`;
}
function utcDayStart(iso: string): number {
  const d = new Date(iso);
  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function formatDateShort(iso: string, nowIso: string): string {
  const dayStart = utcDayStart(iso);
  const today = utcDayStart(nowIso);
  const tomorrow = today + 86_400_000;
  if (dayStart === today) return 'Heute';
  if (dayStart === tomorrow) return 'Morgen';
  const d = new Date(iso);
  return `${WEEKDAY_SHORT_DE[d.getUTCDay()]}, ${pad(d.getUTCDate())}.${pad(d.getUTCMonth() + 1)}.`;
}

/** Port of CalendarSettings.tsx's categorizedBookings useMemo (lines 453-483), `now` injected. */
function categorizeBookings(bookings: Booking[], nowIso: string) {
  const todayStart = utcDayStart(nowIso);
  const tomorrowStart = todayStart + 86_400_000;
  const today: Booking[] = [];
  const upcoming: Booking[] = [];
  const past: Booking[] = [];
  const active = bookings.filter((b) => b.status !== 'cancelled');
  for (const b of active) {
    const start = utcDayStart(b.starts_at);
    if (start < todayStart) past.push(b);
    else if (start < tomorrowStart) today.push(b);
    else upcoming.push(b);
  }
  today.sort((a, b) => +new Date(a.starts_at) - +new Date(b.starts_at));
  upcoming.sort((a, b) => +new Date(a.starts_at) - +new Date(b.starts_at));
  past.sort((a, b) => +new Date(b.starts_at) - +new Date(a.starts_at));
  return { today, upcoming, past };
}

function generateSlug(name: string): string {
  return name
    .toLowerCase()
    .replace(/[äöü]/g, (m) => ({ ä: 'ae', ö: 'oe', ü: 'ue' } as Record<string, string>)[m] || m)
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

/* ---- sample data ----------------------------------------------------------------------------- */

const viewer: ShellUser = { name: 'Talia Wenger', email: '[email protected]', isAdmin: false };
const adminViewer: ShellUser = { name: 'Dario Keller', email: '[email protected]', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_42', name: 'Studio Feuerstein', slug: 'studio-feuerstein' };

const NOW = '2026-09-12T08:00:00Z';

const googleIntegration: CalendarIntegration = {
  id: 'cal_int_1',
  workspace_id: 'ws_42',
  provider: 'google',
  provider_account_id: 'acct_gcal_sample_1',
  provider_account_name: '[email protected]',
  calendar_id: 'primary',
  timezone: 'Europe/Zurich',
  is_active: true,
  connected_at: '2026-08-03T09:12:00Z',
  last_sync_at: '2026-09-12T07:40:00Z',
  is_platform_level: false,
};

const platformIntegration: CalendarIntegration = {
  ...googleIntegration,
  id: 'cal_int_platform_1',
  workspace_id: null,
  provider_account_name: '[email protected]',
  is_platform_level: true,
};

const meetingTypesSample: MeetingType[] = [
  {
    id: 'mt_1', calendar_integration_id: 'cal_int_1', slug: 'erstgespraech', name: 'Erstgespräch',
    description: 'Kurzes Kennenlernen für neue Anfragen.', duration_minutes: 30,
    buffer_before_minutes: 0, buffer_after_minutes: 15, min_notice_hours: 24, max_days_ahead: 28,
    availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'google_meet', custom_location: null,
    zoom_link: null, phone_number: null, color: '#6844C4', is_active: true, messages: null,
    created_at: '2026-08-05T10:00:00Z',
  },
  {
    id: 'mt_2', calendar_integration_id: 'cal_int_1', slug: 'strategie-call', name: 'Strategie-Call',
    description: 'Vertiefte Beratung für bestehende Kund:innen.', duration_minutes: 60,
    buffer_before_minutes: 10, buffer_after_minutes: 15, min_notice_hours: 48, max_days_ahead: 45,
    availability_schedule: { ...DEFAULT_SCHEDULE, sat: ['10:00-12:00'] }, location_type: 'zoom',
    custom_location: null, zoom_link: 'https://zoom.us/j/00000000000', phone_number: null,
    color: '#2E8B57', is_active: true, messages: [
      { id: 'msg_1', type: 'confirmation', enabled: true, timing: { anchor: 'on_event', value: 0, unit: 'days' } },
      { id: 'msg_2', type: 'reminder', enabled: true, timing: { anchor: 'before_event', value: 24, unit: 'hours' } },
    ],
    created_at: '2026-08-11T14:30:00Z',
  },
  {
    id: 'mt_3', calendar_integration_id: 'cal_int_1', slug: 'vor-ort-termin', name: 'Vor-Ort-Termin',
    description: null, duration_minutes: 45,
    buffer_before_minutes: 15, buffer_after_minutes: 15, min_notice_hours: 72, max_days_ahead: 21,
    availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'in_person',
    custom_location: 'Bahnhofstrasse 12, 8001 Zürich', zoom_link: null, phone_number: null,
    color: '#C4844C', is_active: true, messages: [],
    created_at: '2026-08-20T08:15:00Z',
  },
];

const adminMeetingTypesSample: MeetingType[] = [
  {
    id: 'mt_admin_1', calendar_integration_id: 'cal_int_platform_1', slug: 'onboarding-call', name: 'Onboarding-Call',
    description: 'Erstgespräch für neue Plattform-Kund:innen.', duration_minutes: 30,
    buffer_before_minutes: 0, buffer_after_minutes: 15, min_notice_hours: 24, max_days_ahead: 28,
    availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'google_meet', custom_location: null,
    zoom_link: null, phone_number: null, color: '#6844C4', is_active: true,
    created_at: '2026-07-14T09:00:00Z',
  },
  {
    id: 'mt_admin_2', calendar_integration_id: 'cal_int_platform_1', slug: 'partner-check-in', name: 'Partner-Check-in',
    description: 'Vierteljährliches Review mit Agentur-Partnern.', duration_minutes: 45,
    buffer_before_minutes: 10, buffer_after_minutes: 10, min_notice_hours: 48, max_days_ahead: 60,
    availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'phone', custom_location: null,
    zoom_link: null, phone_number: '+41 44 000 00 00', color: '#8C8C8C', is_active: false,
    created_at: '2026-06-02T11:00:00Z',
  },
];

const bookingsSample: Booking[] = [
  { id: 'bk_1', meeting_type_id: 'mt_1', starts_at: '2026-09-12T09:00:00Z', ends_at: '2026-09-12T09:30:00Z', attendee_email: '[email protected]', attendee_name: 'Lea Furrer', meeting_link: 'https://meet.example.com/erstgespraech-lea', location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/1' },
  { id: 'bk_2', meeting_type_id: 'mt_2', starts_at: '2026-09-12T13:00:00Z', ends_at: '2026-09-12T14:00:00Z', attendee_email: '[email protected]', attendee_name: 'Noah Meier', meeting_link: 'https://zoom.us/j/00000000000', location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/2' },
  { id: 'bk_3', meeting_type_id: 'mt_1', starts_at: '2026-09-13T10:00:00Z', ends_at: '2026-09-13T10:30:00Z', attendee_email: '[email protected]', attendee_name: 'Sina Baumann', meeting_link: null, location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/3' },
  { id: 'bk_4', meeting_type_id: 'mt_3', starts_at: '2026-09-16T15:00:00Z', ends_at: '2026-09-16T15:45:00Z', attendee_email: '[email protected]', attendee_name: 'Jonas Rieder', meeting_link: null, location: 'Bahnhofstrasse 12, 8001 Zürich', status: 'confirmed', google_event_link: null },
  { id: 'bk_5', meeting_type_id: 'mt_2', starts_at: '2026-09-10T09:00:00Z', ends_at: '2026-09-10T10:00:00Z', attendee_email: '[email protected]', attendee_name: 'Mira Suter', meeting_link: null, location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/5' },
  { id: 'bk_cancelled', meeting_type_id: 'mt_1', starts_at: '2026-09-14T09:00:00Z', ends_at: '2026-09-14T09:30:00Z', attendee_email: '[email protected]', attendee_name: 'Elias Roth', meeting_link: null, location: null, status: 'cancelled', google_event_link: null },
  // 11 more past bookings, literal and fictional, so the collapsed list demonstrates its "+N weitere" overflow (source line 1341).
  ...Array.from({ length: 11 }, (_, i) => ({
    id: `bk_past_${i}`,
    meeting_type_id: i % 2 === 0 ? 'mt_1' : 'mt_2',
    starts_at: `2026-08-${String(20 - i).padStart(2, '0')}T09:00:00Z`,
    ends_at: `2026-08-${String(20 - i).padStart(2, '0')}T09:30:00Z`,
    attendee_email: `kontakt${i}@example.com`,
    attendee_name: `Kontakt ${i + 1}`,
    meeting_link: null,
    location: null,
    status: 'confirmed',
    google_event_link: null,
  })),
];

const adminBookingsSample: Booking[] = [
  { id: 'bk_admin_1', meeting_type_id: 'mt_admin_1', starts_at: '2026-09-12T11:00:00Z', ends_at: '2026-09-12T11:30:00Z', attendee_email: '[email protected]', attendee_name: 'Agentur Nordlicht', meeting_link: null, location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/a1' },
  { id: 'bk_admin_2', meeting_type_id: 'mt_admin_2', starts_at: '2026-09-15T14:00:00Z', ends_at: '2026-09-15T14:45:00Z', attendee_email: '[email protected]', attendee_name: 'Kollektiv Aare', meeting_link: null, location: null, status: 'pending', google_event_link: null },
  { id: 'bk_admin_3', meeting_type_id: 'mt_admin_1', starts_at: '2026-09-08T09:00:00Z', ends_at: '2026-09-08T09:30:00Z', attendee_email: '[email protected]', attendee_name: 'Studio Winkel', meeting_link: null, location: null, status: 'cancelled', google_event_link: null },
];

const defaultMeetingTypeForm: MeetingTypeFormState = {
  name: '', slug: '', description: '', duration_minutes: 60, buffer_before_minutes: 0, buffer_after_minutes: 15,
  min_notice_hours: 24, max_days_ahead: 28, location_type: 'google_meet', custom_location: '', zoom_link: '',
  phone_number: '', color: '#6844C4', availability_schedule: { ...DEFAULT_SCHEDULE }, messages: null,
};

const base: Props = {
  viewer,
  workspace,
  scope: 'workspace',
  status: 'ok',
  integration: googleIntegration,
  meetingTypes: meetingTypesSample,
  bookings: bookingsSample,
  now: NOW,
  message: null,
  isConnecting: false,
  showPastBookings: false,
  defaultMessages: [
    { id: 'default_1', type: 'confirmation', enabled: true, timing: { anchor: 'on_event', value: 0, unit: 'days' } },
    { id: 'default_2', type: 'reminder', enabled: true, timing: { anchor: 'before_event', value: 24, unit: 'hours' } },
  ],
  showMeetingTypeForm: false,
  editingMeetingType: null,
  meetingTypeForm: defaultMeetingTypeForm,
  isSavingMeetingType: false,
};

export const states = defineStates<Props>({
  loading: {
    label: 'First fetch: the bare spinner inside the shell, before the integration is known (line 546-554)',
    props: { ...base, status: 'loading', integration: null, meetingTypes: [], bookings: [] },
    height: 560, viewport: 1440,
  },
  disconnected: {
    label: 'No calendar connected: the Verbinden card plus the closed iCal/CalDAV alternative-connect header',
    props: { ...base, integration: null, meetingTypes: [], bookings: [], defaultMessages: null },
    height: 620, viewport: 1440,
  },
  'connected-empty': {
    label: 'Google connected, no meeting types or bookings yet: the two empty-state cards, Standard-Nachrichten off',
    props: { ...base, meetingTypes: [], bookings: [], defaultMessages: null },
    height: 1100, viewport: 1440,
  },
  populated: {
    label: 'Three meeting types, bookings across today/upcoming/past (collapsed), Standard-Nachrichten on',
    props: base,
    height: 2400, viewport: 1440,
  },
  'meeting-type-form-open': {
    label: 'Creating a meeting type: the form open over the existing list, per-type Nachrichten inheriting the default',
    props: { ...base, showMeetingTypeForm: true, meetingTypeForm: { ...defaultMeetingTypeForm, name: 'Discovery Call', slug: 'discovery-call' } },
    height: 2800, viewport: 1440,
  },
  'admin-variant': {
    label: 'Platform-admin at /admin/calendar: no Standard-Nachrichten, one row expanded, a flat Aktuelle-Buchungen list',
    props: {
      ...base,
      viewer: adminViewer,
      scope: 'platform-admin',
      integration: platformIntegration,
      meetingTypes: adminMeetingTypesSample,
      bookings: adminBookingsSample,
      defaultMessages: null,
      expandedMeetingTypeId: 'mt_admin_1',
    },
    height: 1500, viewport: 1440,
  },
});

/* ---- the page (Kalender/Kalender-Integration) ---------------------------------------------- */

type PageProps = Omit<Props, 'viewer' | 'workspace'>;

function CalendarPanel({
  scope,
  status,
  integration,
  meetingTypes,
  bookings,
  now,
  message = null,
  isConnecting = false,
  showPastBookings: showPastBookingsInitial = false,
  defaultMessages: defaultMessagesInitial = null,
  showMeetingTypeForm: showMeetingTypeFormInitial = false,
  editingMeetingType = null,
  meetingTypeForm: meetingTypeFormInitial = defaultMeetingTypeForm,
  isSavingMeetingType = false,
  expandedMeetingTypeId: expandedInitial = null,
}: PageProps) {
  const [showPastBookings, setShowPastBookings] = useState(showPastBookingsInitial);
  const [defaultMessages, setDefaultMessages] = useState<MessageStep[] | null>(defaultMessagesInitial);
  const [showMeetingTypeForm, setShowMeetingTypeForm] = useState(showMeetingTypeFormInitial);
  const [form, setForm] = useState<MeetingTypeFormState>(meetingTypeFormInitial);
  const [expandedMeetingTypeId, setExpandedMeetingTypeId] = useState<string | null>(expandedInitial);
  const [copiedId, setCopiedId] = useState<string | null>(null);

  const isAdmin = scope === 'platform-admin';
  const LOCATION_TYPES = isAdmin ? LOCATION_TYPES_ADMIN : LOCATION_TYPES_WORKSPACE;
  const locationLabel = (value: string) => LOCATION_TYPES.find((lt) => lt.value === value)?.label;

  if (status === 'loading') {
    return (
      <div className="flex items-center justify-center h-[480px]">
        <Spinner size="xl" color="primary" label="Laden..." />
      </div>
    );
  }

  const googleConnected = isAdmin ? !!integration : integration?.provider === 'google';
  const { today, upcoming, past } = categorizeBookings(bookings, now);

  return (
    <div className={`${isAdmin ? 'max-w-6xl' : 'max-w-4xl'} mx-auto p-6`}>
      {/* Header */}
      <div className="mb-8 flex items-center justify-between">
        <div>
          <Heading level={1} className="mb-2 flex items-center gap-3">
            <Calendar className="w-8 h-8 text-[#6844C4]" />
            {isAdmin ? 'Kalender-Integration' : 'Kalender'}
          </Heading>
          <Text muted>
            {isAdmin
              ? 'Google Calendar verbinden und Meeting-Typen für das Onboarding verwalten'
              : 'Verbinde deinen Google Calendar und erstelle buchbare Termine'}
          </Text>
        </div>
        {integration && (
          <Button variant="ghost" size="sm">
            <RefreshCw className="w-4 h-4 mr-2" />
            Aktualisieren
          </Button>
        )}
      </div>

      {message && (
        <Card variant="glass" padding="md" className={`mb-6 ${message.type === 'success' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
          <Text className={message.type === 'success' ? 'text-green-800' : 'text-red-800'}>{message.text}</Text>
        </Card>
      )}

      {/* Connection status */}
      <Card variant="glass" padding="lg" className="mb-8">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-4">
            <div className={`w-12 h-12 rounded-xl flex items-center justify-center ${googleConnected ? 'bg-green-100' : 'bg-stone-100'}`}>
              {googleConnected ? <Link2 className="w-6 h-6 text-green-600" /> : <Link2Off className="w-6 h-6 text-ink-60" />}
            </div>
            <div>
              <Text weight="semibold" size="lg">{googleConnected ? 'Google Calendar verbunden' : 'Google Calendar nicht verbunden'}</Text>
              {googleConnected && integration && (
                <Text muted size="sm">
                  {integration.provider_account_name} ({integration.provider_account_id})
                  {isAdmin && integration.connected_at && <span className="ml-2">• Verbunden am {formatDateTime(integration.connected_at)}</span>}
                </Text>
              )}
            </div>
          </div>
          <div>
            {googleConnected ? (
              <Button variant="outline" size="sm">
                <Link2Off className="w-4 h-4 mr-2" />
                Trennen
              </Button>
            ) : (
              <Button variant="primary" size="sm" disabled={isConnecting}>
                <Link2 className="w-4 h-4 mr-2" />
                {isAdmin ? 'Google Calendar verbinden' : 'Verbinden'}
              </Button>
            )}
          </div>
        </div>
      </Card>

      {/* Alternative (iCal/CalDAV) connect — workspace only, and only while Google is not the active provider. */}
      {!isAdmin && !googleConnected && (
        <Card variant="glass" padding="lg" className="mb-8">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-4">
              <div className="w-12 h-12 rounded-xl flex items-center justify-center bg-stone-100">
                <Calendar className="w-6 h-6 text-ink-60" />
              </div>
              <div>
                <Text weight="semibold" size="lg">Andere Kalender (iCal · CalDAV)</Text>
                <Text muted size="sm">Hostpoint, Fastmail, mailbox.org, ProtonCalendar — ohne OAuth.</Text>
              </div>
            </div>
            <Button variant="outline" size="sm">Verbinden</Button>
          </div>
        </Card>
      )}

      {/* Standard-Nachrichten — workspace only */}
      {!isAdmin && integration && (
        <Card className="mb-6">
          <div className="flex items-start justify-between gap-4 mb-3">
            <div>
              <Heading level={2}>Standard-Nachrichten</Heading>
              <Text size="sm" muted className="mt-1">
                Gelten für alle Meeting-Typen, die keine eigenen Nachrichten haben. Platzhalter: <code>{'{{name}}'}</code>, <code>{'{{meeting}}'}</code>, <code>{'{{date}}'}</code>, <code>{'{{time}}'}</code>, <code>{'{{location}}'}</code>.
              </Text>
            </div>
            <Toggle checked={defaultMessages !== null} onChange={(on: boolean) => setDefaultMessages(on ? [] : null)} />
          </div>
          {defaultMessages === null ? (
            <Text size="sm" muted>Keine Standard-Nachrichten. Einschalten, um eine Bestätigung, eine Erinnerung oder ein Follow-up für alle Termine zu schreiben.</Text>
          ) : (
            <MessageStepsSummary steps={defaultMessages} />
          )}
        </Card>
      )}

      {/* Meeting types */}
      {integration && (
        <>
          <div className="mb-4 flex items-center justify-between">
            <Heading level={2}>Meeting-Typen</Heading>
            <Button variant="primary" size="sm" onClick={() => setShowMeetingTypeForm(true)}>
              <Plus className="w-4 h-4 mr-2" />
              {isAdmin ? 'Neuer Meeting-Typ' : 'Neu'}
            </Button>
          </div>

          {!isAdmin && showMeetingTypeForm && (
            <MeetingTypeForm
              form={form}
              setForm={setForm}
              editing={!!editingMeetingType}
              isSaving={isSavingMeetingType}
              integration={integration}
              onCancel={() => setShowMeetingTypeForm(false)}
            />
          )}

          {meetingTypes.length === 0 ? (
            <Card variant="glass" padding="lg" className={`text-center ${isAdmin ? 'mb-8' : ''}`}>
              <Calendar className="w-12 h-12 mx-auto mb-4 text-ink-40" />
              <Text muted>{isAdmin ? 'Noch keine Meeting-Typen erstellt. Erstelle deinen ersten Meeting-Typ für das Onboarding.' : 'Noch keine Meeting-Typen. Erstelle deinen ersten buchbaren Termin.'}</Text>
            </Card>
          ) : (
            <div className={`space-y-4 ${isAdmin ? 'mb-8' : ''}`}>
              {meetingTypes.map((mt) =>
                isAdmin ? (
                  <AdminMeetingTypeRow
                    key={mt.id}
                    mt={mt}
                    expanded={expandedMeetingTypeId === mt.id}
                    copied={copiedId === mt.id}
                    onToggleExpand={() => setExpandedMeetingTypeId(expandedMeetingTypeId === mt.id ? null : mt.id)}
                    onCopy={() => { setCopiedId(mt.id); setTimeout(() => setCopiedId(null), 2000); }}
                    locationLabel={locationLabel}
                  />
                ) : (
                  <WorkspaceMeetingTypeRow
                    key={mt.id}
                    mt={mt}
                    copied={copiedId === mt.id}
                    onCopy={() => { setCopiedId(mt.id); setTimeout(() => setCopiedId(null), 2000); }}
                    locationLabel={locationLabel}
                  />
                )
              )}
            </div>
          )}

          {/* Bookings */}
          {isAdmin ? (
            <div>
              <div className="mb-4"><Heading level={2}>Aktuelle Buchungen</Heading></div>
              {bookings.length === 0 ? (
                <Card variant="glass" padding="lg" className="text-center">
                  <Calendar className="w-12 h-12 mx-auto mb-4 text-ink-40" />
                  <Text muted>Noch keine Buchungen vorhanden</Text>
                </Card>
              ) : (
                <div className="space-y-3">
                  {bookings.map((booking) => (
                    <Card key={booking.id} variant="glass" padding="md">
                      <div className="flex items-center justify-between">
                        <div>
                          <Text weight="medium">{booking.attendee_name}</Text>
                          <Text muted size="sm">{booking.attendee_email}</Text>
                        </div>
                        <div className="text-right">
                          <Text size="sm">{formatDateTime(booking.starts_at)}</Text>
                          <Badge variant={booking.status === 'confirmed' ? 'success' : booking.status === 'cancelled' ? 'error' : 'secondary'} size="sm">{booking.status}</Badge>
                        </div>
                        {booking.google_event_link && (
                          <a href={booking.google_event_link} target="_blank" rel="noopener noreferrer" className="ml-2">
                            <ExternalLink className="w-4 h-4 text-ink-60 hover:text-[#6844C4]" />
                          </a>
                        )}
                      </div>
                    </Card>
                  ))}
                </div>
              )}
            </div>
          ) : (
            <div className="mt-10">
              <Heading level={2} className="mb-4">Gebuchte Termine</Heading>

              {bookings.length > 0 && (today.length > 0 || upcoming.length > 0) && (
                <Card variant="glass" padding="md" className="mb-6 bg-gradient-to-r from-[#6844C4]/5 to-transparent border-l-4 border-[#6844C4]">
                  <div className="flex items-center gap-4">
                    <div className="w-10 h-10 rounded-full bg-[#6844C4]/10 flex items-center justify-center">
                      <Calendar className="w-5 h-5 text-[#6844C4]" />
                    </div>
                    <div>
                      {today.length > 0 ? (
                        <>
                          <Text weight="semibold">{today.length === 1 ? 'Ein Termin heute' : `${today.length} Termine heute`}</Text>
                          <Text muted size="sm">Nächster: {formatTime(today[0].starts_at)} - {today[0].attendee_name}</Text>
                        </>
                      ) : (
                        <>
                          <Text weight="semibold">Kein Termin heute</Text>
                          <Text muted size="sm">Nächster: {formatDateShort(upcoming[0].starts_at, now)} {formatTime(upcoming[0].starts_at)}</Text>
                        </>
                      )}
                    </div>
                  </div>
                </Card>
              )}

              {bookings.length === 0 ? (
                <Card variant="glass" padding="lg" className="text-center">
                  <Calendar className="w-12 h-12 mx-auto mb-4 text-ink-40" />
                  <Text muted>Noch keine Buchungen vorhanden.</Text>
                </Card>
              ) : (
                <div className="space-y-6">
                  {today.length > 0 && (
                    <div>
                      <Text weight="semibold" size="sm" className="mb-3 text-green-600 flex items-center gap-2">
                        <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
                        Heute ({today.length})
                      </Text>
                      <div className="space-y-2">
                        {today.map((booking) => {
                          const mt = meetingTypes.find((m) => m.id === booking.meeting_type_id);
                          return (
                            <Card key={booking.id} variant="glass" padding="sm" className="border-l-4 border-green-500">
                              <div className="flex items-center justify-between">
                                <div className="flex items-center gap-3">
                                  <div className="text-center min-w-[50px]"><Text weight="semibold" size="lg">{formatTime(booking.starts_at)}</Text></div>
                                  <div>
                                    <Text weight="medium">{booking.attendee_name}</Text>
                                    <Text muted size="sm">{booking.attendee_email}</Text>
                                    {mt && <Text muted size="xs">{mt.name} - {mt.duration_minutes} Min.</Text>}
                                  </div>
                                </div>
                                <div className="flex items-center gap-2">
                                  {booking.meeting_link && <Button variant="primary" size="sm"><Video className="w-4 h-4 mr-1" />Meeting</Button>}
                                  {booking.google_event_link && <Button variant="ghost" size="sm" title="In Google Calendar öffnen"><ExternalLink className="w-4 h-4" /></Button>}
                                </div>
                              </div>
                            </Card>
                          );
                        })}
                      </div>
                    </div>
                  )}

                  {upcoming.length > 0 && (
                    <div>
                      <Text weight="semibold" size="sm" className="mb-3 text-[#6844C4] flex items-center gap-2">
                        <Clock className="w-4 h-4" />
                        Bevorstehend ({upcoming.length})
                      </Text>
                      <div className="space-y-2">
                        {upcoming.map((booking) => {
                          const mt = meetingTypes.find((m) => m.id === booking.meeting_type_id);
                          return (
                            <Card key={booking.id} variant="glass" padding="sm">
                              <div className="flex items-center justify-between">
                                <div className="flex items-center gap-3">
                                  <div className="text-center min-w-[70px]">
                                    <Text weight="semibold" size="sm">{formatDateShort(booking.starts_at, now)}</Text>
                                    <Text muted size="xs">{formatTime(booking.starts_at)}</Text>
                                  </div>
                                  <div>
                                    <Text weight="medium">{booking.attendee_name}</Text>
                                    <Text muted size="sm">{booking.attendee_email}</Text>
                                    {mt && <Text muted size="xs">{mt.name}</Text>}
                                  </div>
                                </div>
                                {booking.google_event_link && <Button variant="ghost" size="sm" title="In Google Calendar öffnen"><ExternalLink className="w-4 h-4" /></Button>}
                              </div>
                            </Card>
                          );
                        })}
                      </div>
                    </div>
                  )}

                  {past.length > 0 && (
                    <div>
                      <button onClick={() => setShowPastBookings(!showPastBookings)} className="w-full flex items-center justify-between py-2 text-left">
                        <Text muted size="sm">Vergangene Termine ({past.length})</Text>
                        {showPastBookings ? <ChevronUp className="w-4 h-4 text-ink-60" /> : <ChevronDown className="w-4 h-4 text-ink-60" />}
                      </button>
                      {showPastBookings && (
                        <div className="space-y-2 mt-2">
                          {past.slice(0, 10).map((booking) => {
                            const mt = meetingTypes.find((m) => m.id === booking.meeting_type_id);
                            return (
                              <Card key={booking.id} variant="glass" padding="sm" className="opacity-60">
                                <div className="flex items-center gap-3">
                                  <div className="text-center min-w-[70px]">
                                    <Text muted size="sm">{formatDateShort(booking.starts_at, now)}</Text>
                                    <Text muted size="xs">{formatTime(booking.starts_at)}</Text>
                                  </div>
                                  <div>
                                    <Text muted weight="medium">{booking.attendee_name}</Text>
                                    <Text muted size="sm">{booking.attendee_email}</Text>
                                    {mt && <Text muted size="xs">{mt.name}</Text>}
                                  </div>
                                </div>
                              </Card>
                            );
                          })}
                          {past.length > 10 && <Text muted size="sm" className="text-center py-2">+ {past.length - 10} weitere vergangene Termine</Text>}
                        </div>
                      )}
                    </div>
                  )}

                  {today.length === 0 && upcoming.length === 0 && past.length === 0 && (
                    <Card variant="glass" padding="lg" className="text-center"><Text muted>Keine Termine gefunden.</Text></Card>
                  )}
                </div>
              )}
            </div>
          )}
        </>
      )}
    </div>
  );
}

function MessageStepsSummary({ steps }: { steps: MessageStep[] }) {
  if (steps.length === 0) {
    return <Text size="xs" muted>Eingeschaltet, aber noch keine Nachricht geschrieben.</Text>;
  }
  return (
    <div className="space-y-2">
      {steps.map((step) => (
        <div key={step.id} className="flex items-center gap-2">
          <Badge variant="secondary" size="sm">{MESSAGE_TYPE_LABEL[step.type]}</Badge>
          <Text size="xs" muted>{formatTiming(step)}</Text>
        </div>
      ))}
      <Text size="xs" muted className="pt-1">Der Editor (MessageStepsEditor, forms-Domäne) ist nicht Teil dieses Kits.</Text>
    </div>
  );
}

function WorkspaceMeetingTypeRow({ mt, copied, onCopy, locationLabel }: { mt: MeetingType; copied: boolean; onCopy: () => void; locationLabel: (v: string) => string | undefined }) {
  const embed = `<iframe src="https://api.example.com/api/calendar/embed/${mt.slug}" width="100%" height="600" frameborder="0"></iframe>`;
  return (
    <Card variant="glass" padding="md">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-4">
          <div className="w-3 h-12 rounded-full" style={{ backgroundColor: mt.color }} />
          <div>
            <Text weight="semibold">{mt.name}</Text>
            <Text muted size="sm" className="flex items-center gap-2 flex-wrap">
              <Clock className="w-3 h-3" />
              {mt.duration_minutes} Min.
              <span className="mx-1">•</span>
              {locationLabel(mt.location_type)}
              <span className="mx-1">•</span>
              Vorlauf {mt.min_notice_hours}h
              <span className="mx-1">•</span>
              Puffer {mt.buffer_before_minutes}/{mt.buffer_after_minutes} Min.
            </Text>
          </div>
        </div>
        <div className="flex items-center gap-2">
          <Button variant="ghost" size="sm" onClick={onCopy} title="Embed-Code kopieren">
            {copied ? <Check className="w-4 h-4 text-green-500" /> : <Code className="w-4 h-4" />}
          </Button>
          <Button variant="ghost" size="sm"><Edit className="w-4 h-4" /></Button>
          <Button variant="ghost" size="sm"><Trash2 className="w-4 h-4 text-red-500" /></Button>
        </div>
      </div>
      <div className="mt-3 p-3 bg-stone-100 rounded-lg">
        <Text muted size="xs" className="mb-1 flex items-center gap-1"><Code className="w-3 h-3" />Embed-Code</Text>
        <code className="text-xs break-all">{embed}</code>
      </div>
    </Card>
  );
}

function AdminMeetingTypeRow({ mt, expanded, copied, onToggleExpand, onCopy, locationLabel }: { mt: MeetingType; expanded: boolean; copied: boolean; onToggleExpand: () => void; onCopy: () => void; locationLabel: (v: string) => string | undefined }) {
  const embed = `<iframe src="https://api.example.com/api/calendar/embed/${mt.slug}" width="100%" height="600" frameborder="0"></iframe>`;
  return (
    <Card variant="glass" padding="md">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-4">
          <div className="w-3 h-12 rounded-full" style={{ backgroundColor: mt.color }} />
          <div>
            <div className="flex items-center gap-2">
              <Text weight="semibold">{mt.name}</Text>
              {!mt.is_active && <Badge variant="secondary" size="sm">Inaktiv</Badge>}
            </div>
            <Text muted size="sm" className="flex items-center gap-2">
              <Clock className="w-3 h-3" />
              {mt.duration_minutes} Min.
              <span className="mx-1">•</span>
              {locationLabel(mt.location_type)}
              <span className="mx-1">•</span>
              /{mt.slug}
            </Text>
          </div>
        </div>
        <div className="flex items-center gap-2">
          <Button variant="ghost" size="sm" onClick={onCopy} title="Embed-Code kopieren">
            {copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
          </Button>
          <Button variant="ghost" size="sm"><Edit className="w-4 h-4" /></Button>
          <Button variant="ghost" size="sm"><Trash2 className="w-4 h-4 text-red-500" /></Button>
          <Button variant="ghost" size="sm" onClick={onToggleExpand}>
            {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
          </Button>
        </div>
      </div>
      {expanded && (
        <div className="mt-4 pt-4 border-t border-line">
          <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
            <div><Text muted size="xs">Mindestvorlauf</Text><Text>{mt.min_notice_hours}h</Text></div>
            <div><Text muted size="xs">Max. Tage voraus</Text><Text>{mt.max_days_ahead}</Text></div>
            <div><Text muted size="xs">Puffer vorher</Text><Text>{mt.buffer_before_minutes} Min.</Text></div>
            <div><Text muted size="xs">Puffer nachher</Text><Text>{mt.buffer_after_minutes} Min.</Text></div>
          </div>
          {mt.description && <div className="mt-3"><Text muted size="xs">Beschreibung</Text><Text size="sm">{mt.description}</Text></div>}
          <div className="mt-4 p-3 bg-stone-100 rounded-lg">
            <Text muted size="xs" className="mb-2">Embed-Code</Text>
            <code className="text-xs break-all">{embed}</code>
          </div>
        </div>
      )}
    </Card>
  );
}

/**
 * The workspace form (CalendarSettings.tsx 758-1086). Genuine, reproduced gap (Data model / Gotchas): only the
 * `zoom_link` field is conditionally rendered for its location type (843-857) — `custom_location` (in_person) and
 * `phone_number` (phone) have no input anywhere in this form, unlike the admin file's form (which has both, not
 * rendered here since no state opens it, see the header comment).
 */
function MeetingTypeForm({ form, setForm, editing, isSaving, integration, onCancel }: {
  form: MeetingTypeFormState; setForm: (f: MeetingTypeFormState) => void; editing: boolean; isSaving: boolean; integration: CalendarIntegration; onCancel: () => void;
}) {
  return (
    <Card variant="glass" padding="lg" className="mb-6">
      <Heading level={3} className="mb-4">{editing ? 'Meeting-Typ bearbeiten' : 'Neuer Meeting-Typ'}</Heading>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Name</Text>
          <Input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, slug: editing ? form.slug : generateSlug(e.target.value) })} placeholder="z.B. Discovery Call" />
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Slug (URL)</Text>
          <Input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="discovery-call" disabled={editing} />
        </div>
        <div className="md:col-span-2">
          <Text as="label" size="sm" weight="medium" className="block mb-2">Beschreibung</Text>
          <Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optionale Beschreibung" rows={2} />
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Dauer (Minuten)</Text>
          <Input type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: parseInt(e.target.value) || 60 })} min={15} max={480} />
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Meeting-Ort</Text>
          <select value={form.location_type} onChange={(e) => setForm({ ...form, location_type: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-line bg-white">
            {LOCATION_TYPES_WORKSPACE.map((lt) => <option key={lt.value} value={lt.value}>{lt.label}</option>)}
          </select>
        </div>
        {form.location_type === 'zoom' && (
          <div className="md:col-span-2">
            <Text as="label" size="sm" weight="medium" className="block mb-2">Zoom Link</Text>
            <Input type="url" value={form.zoom_link} onChange={(e) => setForm({ ...form, zoom_link: e.target.value })} placeholder="https://zoom.us/j/..." />
          </div>
        )}
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Farbe</Text>
          <div className="flex items-center gap-2">
            <input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} className="w-10 h-10 rounded cursor-pointer" />
            <Input type="text" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} className="flex-1" />
          </div>
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Mindestvorlauf (Stunden)</Text>
          <Input type="number" value={form.min_notice_hours} onChange={(e) => setForm({ ...form, min_notice_hours: parseInt(e.target.value) || 24 })} min={0} max={168} />
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Max. Tage im Voraus</Text>
          <Input type="number" value={form.max_days_ahead} onChange={(e) => setForm({ ...form, max_days_ahead: parseInt(e.target.value) || 28 })} min={1} max={365} />
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Puffer vorher (Min.)</Text>
          <Input type="number" value={form.buffer_before_minutes} onChange={(e) => setForm({ ...form, buffer_before_minutes: parseInt(e.target.value) || 0 })} min={0} max={120} />
        </div>
        <div>
          <Text as="label" size="sm" weight="medium" className="block mb-2">Puffer nachher (Min.)</Text>
          <Input type="number" value={form.buffer_after_minutes} onChange={(e) => setForm({ ...form, buffer_after_minutes: parseInt(e.target.value) || 15 })} min={0} max={120} />
        </div>
      </div>

      <div className="mt-6">
        <Text as="label" size="sm" weight="medium" className="block mb-2">Zeitzone</Text>
        <select value={integration.timezone} disabled className="w-full rounded-lg border border-line bg-white px-3 py-2 text-sm">
          <option>{integration.timezone}</option>
        </select>
        <Text size="xs" muted className="mt-1">Alle Verfügbarkeitszeiten beziehen sich auf diese Zeitzone.</Text>
      </div>

      <div className="mt-6">
        <Text as="label" size="sm" weight="medium" className="block mb-3">Verfügbarkeit ({integration.timezone})</Text>
        <div className="space-y-2">
          {Object.entries(DAY_LABELS).map(([day, label]) => (
            <div key={day} className="flex items-center gap-3">
              <Text className="w-24 shrink-0">{label}</Text>
              <Input
                type="text"
                value={(form.availability_schedule[day] || []).join(', ')}
                onChange={(e) => setForm({ ...form, availability_schedule: { ...form.availability_schedule, [day]: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) } })}
                placeholder="z.B. 09:00-12:00, 14:00-17:00"
                className="flex-1"
              />
            </div>
          ))}
          <Text size="xs" muted className="mt-1">Format: HH:MM-HH:MM. Mehrere Zeitfenster mit Komma trennen. Leer lassen = nicht verfügbar.</Text>
        </div>
      </div>

      <div className="mt-6 pt-6 border-t border-line">
        <div className="flex items-start justify-between gap-4 mb-3">
          <div>
            <Text as="label" size="sm" weight="medium" className="block">Nachrichten</Text>
            <Text size="xs" muted className="mt-0.5">Bestätigung, Erinnerung und Follow-up per E-Mail. Platzhalter: <code>{'{{name}}'}</code>, <code>{'{{meeting}}'}</code>, <code>{'{{date}}'}</code>, <code>{'{{time}}'}</code>, <code>{'{{location}}'}</code>.</Text>
          </div>
          <Toggle checked={form.messages !== null} onChange={(on: boolean) => setForm({ ...form, messages: on ? [] : null })} />
        </div>
        {form.messages === null ? (
          <Text size="xs" muted>Verwendet die Standard-Nachrichten des Kalenders. Einschalten, um für diesen Meeting-Typ eigene zu schreiben.</Text>
        ) : (
          <MessageStepsSummary steps={form.messages} />
        )}
      </div>

      <div className="mt-6 flex items-center gap-3">
        <Button variant="primary" disabled={isSaving || !form.name || !form.slug}>
          {editing ? 'Speichern' : 'Erstellen'}
        </Button>
        <Button variant="ghost" onClick={onCancel}>Abbrechen</Button>
      </div>
    </Card>
  );
}

export default function Demo({ viewer: pageViewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
  const activePath = page.scope === 'platform-admin' ? '/admin/calendar' : '/calendar';
  return (
    <Shell domains={DOMAINS} user={pageViewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath={activePath} hasTour={false} hideChrome={galleryMode}>
      <CalendarPanel {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /calendar> PrivateRoute (children)                    ("Bookings" domain, nav.tsx line 133)
<Route /admin/calendar> AdminRoute (children)                 ("Operate" domain in the real app's own nav.tsx;
                                                                the kit's shared appshell-navigation/nav.tsx
                                                                does not list this path yet, Gotchas)
  Navigation | AdminNavigation = AppShell (thin delegators)
    CalendarSettings | AdminCalendarSettings
      [loading]           div.h-screen flex-center > Spinner xl "Laden..."      (546-554 | 366-373)
      [no workspace]      Card: "Workspace einrichten" + create button          (workspace scope only, 512-544,
                                                                                   out of this recipe's scope,
                                                                                   Gotchas)
      Header              Heading "Kalender" | "Kalender-Integration" + Text + [integration] "Aktualisieren"
      [message]           Card glass: green | red banner, plain Text, no icon
      Connection status   Card glass lg
                           icon chip (Link2 green | Link2Off stone) + "verbunden"/"nicht verbunden" + account line
                           [connected] Button outline "Trennen"  |  [else] Button primary "Verbinden"
      [workspace, !google] AlternativeCalendarConnect            (header card reproduced, its open tab forms not
                                                                    part of this recipe's packages, Frontend)
      [workspace, integration] Standard-Nachrichten Card
                           Heading + placeholder-copy Text + Toggle
                           [off] "Keine Standard-Nachrichten..." | [on] per-step summary (MessageStepsEditor
                                                                    itself not part of this recipe's packages)
      [integration] Meeting-Typen
                           Heading + Button primary "Neu" | "Neuer Meeting-Typ"    (PrimaMachtsButton, workspace
                                                                                      only, not rendered, Frontend)
                           [workspace, showForm] Meeting type form (name/slug/description/duration/location/
                                                                      [zoom]link/color/notice/lookahead/buffers/
                                                                      timezone select/availability/Nachrichten
                                                                      toggle, Data model's form gap)
                           [meetingTypes.length===0] empty Card
                           [else] row per type:
                             workspace: color bar + name + duration/location/notice/buffer line +
                                        [copy|edit|delete] + embed code always visible below
                             admin:     color bar + name + [Inaktiv badge] + duration/location/slug line +
                                        [copy|edit|delete|expand] + details grid + embed only when expanded
      Bookings
                           workspace: "Gebuchte Termine" heading, [today||upcoming] summary banner, empty Card,
                                      Heute (pulse dot, video/google-link buttons) / Bevorstehend (google-link) /
                                      Vergangene (collapsed, sliced 10, "+N weitere")
                           admin:     "Aktuelle Buchungen" heading, empty Card, flat list with status Badge and
                                      google-link anchor, no categorisation
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild calendar-settings-panel/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens, rendered by the docs host as one frame per state; the tree is the frontmatter's. The console is rendered inside the shared shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx), the same convention identity-auth/ui/admin-users-console and billing-credits/ui/subscription-manager use, with the "Bookings" domain active on /calendar for the five workspace states and, for admin-variant, viewer. isAdmin: true on /admin/calendar - a path the kit's own shared nav.tsx does not yet list under its "Operate" domain, unlike the real app's nav.tsx (Gotchas), so the shell's active-domain highlight is approximate for that one state, a limitation of the shared shell recipe, not of this one.

States (rendering.states, in order): loading (the bare spinner, no cards at all, before either file knows whether a calendar is connected); disconnected (no integration, the "nicht verbunden" card, the closed iCal/CalDAV header card below it, workspace scope only - admin has no equivalent alternative-connect surface so this state is not meaningfully different there); connected-empty (Google connected, zero meeting types and zero bookings, both empty-state cards, Standard-Nachrichten off); populated (three meeting types spanning all three location types, six active bookings split across today/upcoming/eleven-deep past - enough to show the collapsed list's "+N weitere" overflow line - one cancelled booking present in the data and absent from every bucket, Standard-Nachrichten on with two configured steps); meeting-type-form-open (the create form open over the existing list, name/slug pre-typed, per-type Nachrichten left inheriting the default, the real, reproduced gap that this form never renders a custom_location or phone_number input for any location type - only zoom_link is conditionally shown, Data model); admin-variant (the platform-admin approximation: no Standard-Nachrichten section, no alternative-connect card, "Neuer Meeting-Typ" instead of "Neu", one row shown expanded to prove the chevron-detail branch, an "Inaktiv" badge on the second row, a flat "Aktuelle Buchungen" list with a raw status Badge and one cancelled row visible - the admin file has no categorisation to hide it, unlike the workspace file's filter).

What the demo deliberately does not render, all documented in the demo file's own header comment: the OAuth popup and its postMessage handshake; window.confirm before disconnect/delete; AlternativeCalendarConnect's open iCal/CalDAV tab forms (network-reaching on mount, not part of the four packages a demo may import from); PrimaMachtsButton (needs PrimaContext); MessageStepsEditor itself, replaced by a read-only per-step summary line; the admin file's own meeting-type FORM (address/phone fields it has and the workspace form lacks, a static timezone line instead of a selector, lower buffer/lookahead caps) - no state opens it, so it is named here and in Reference implementation rather than built unused; navigator.clipboard.writeText's actual call (the copied-checkmark swap is reproduced from state alone).

Tokens and classes as the source uses them: Card glass for every top-level section, plain (no variant) for the Standard-Nachrichten card; the connection icon chip bg-green-100/text-green-600 connected, bg-stone-100/text-ink-60 not; today's booking rows border-l-4 border-green-500 with a bg-green-500 animate-pulse dot; the brand purple #6844C4 used as a literal hex throughout (not an S1 token) for the header icon, active-tier accents and the summary banner's gradient border, the same "literal hex, not a token" pattern billing-credits/ui/subscription-manager's amber palette already flags. dark: variants are absent from both source files entirely (unlike the billing and admin-console pages), so there is no "dark: dead" trap to reproduce here - a genuine, measured difference from this domain's siblings, worth noting precisely because it is not the pattern one would expect from the rest of Labs.

Responsive: both files are a single max-w-4xl (workspace) / max-w-6xl (admin) column; the meeting-type form's field grid collapses from two columns to one below md; the bookings/meeting-type rows stay single- column throughout, nothing here builds a DataTable. Empty state: connected-empty for meeting types and bookings together; disconnected for the calendar itself. Loading state: the bare spinner on first mount only; none of the eight writes shows a loading indicator anywhere but its own triggering button (isConnecting, isSavingMeetingType, isSavingDefaults each gate exactly one button).

Security & tenancy

What the browser may hold: S2's two tokens only, never a calendar credential - OAuth happens server-side, the popup only carries Google's own consent screen. import.meta.env.VITE_API_URL is the only baked value (also read directly by calendarEmbed.ts for the embed <iframe src>, outside the api singleton). Origin and CSRF: the api singleton's Bearer header, S2's own; the OAuth-callback listener explicitly checks isTrustedMessageOrigin(event.origin) before trusting any postMessage, the one place on this page that takes browser-side input from something other than a form field or the server. Surface gating: all workspace and admin calendar routes sit under LABS_SURFACES (app_fastapi.py), the same B4 gate the billing routes sit behind; the admin routes are additionally behind AdminRoute client-side and an admin dependency server-side (B16), so a non-admin hitting /admin/calendar directly is refused before any handler runs.

Tenancy: the workspace routes derive their scope from workspace_id (query param, checked against the caller's membership, calendar_router.py 1025 and siblings - a 403 on mismatch); the admin routes have no workspace concept at all, one platform-level integration (is_platform_level: true) shared across the whole platform admin console, the same "not workspace-scoped inside a workspace-scoped shell" shape billing-credits/ ui/subscription-manager's own Security section names for its per-user balance. Attacker-controlled input the page sends: the meeting-type form's every field, re-validated server-side (slug uniqueness, duration/buffer/ lookahead ranges); the OAuth redirect_url is window.location.href, sent to the backend and echoed back by Google, not rendered unescaped anywhere on this page. What the page renders from the server unescaped: nothing directly-dangerous; React escapes every string, no dangerouslySetInnerHTML; the embed <iframe> shown to the operator is a <code> text block, not a live iframe (this page never actually mounts the iframe it hands out).

Tests

  • Colocated with the demo: calendar-settings-panel/demo.test.tsx (vitest, jsdom, @testing-library/react, afterEach(cleanup)): each state's meeting-type row count matches its meetingTypes.length; today/ upcoming/past booking counts in populated match categorizeBookings(bookingsSample, NOW), independent of the component; the cancelled sample booking (bk_cancelled) renders in none of the three buckets; the Standard-Nachrichten Toggle flips the on/off copy in connected-empty (off) versus populated (on); admin-variant renders no Standard-Nachrichten card and no AlternativeCalendarConnect header at all (queryByText returns null); the workspace meeting-type-form-open state renders a zoom_link input when location_type is 'zoom' and no custom_location/phone_number input for any location type (the reproduced gap, Data model); no state's markup contains an access- or refresh-token shape.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs calendar-booking/ui/calendar-settings-panel renders all six states with renderToString, fetch stubbed - run and green before commit (Acceptance checks).
  • Labs at 62ba4fc has no unit test of either CalendarSettings.tsx or AdminCalendarSettings.tsx (grep -rl "CalendarSettings\|AdminCalendarSettings" frontend/src --include='*.test.*' is empty) and no Playwright spec for either route. Backend coverage of the routes both files call belongs to the five backend_modules recipes' own suites, not to this page.
  • What a new app adds: a jsdom test with api mocked asserting the six catch blocks read data.error ?? data.detail (once fixed) instead of data.detail alone; a test that the timezone patch's silent failure (no message card at all, API surface) now shows one; a Playwright spec that connects a real (sandboxed) Google account, creates a meeting type, and asserts its embed code renders a working public booking page - the gap this recipe's Gotchas names for the two files' complete lack of coverage today.

Deploy & ops

Build target: the Labs Vite bundle (S2), two separate lazy chunks (App.tsx lines 73 and 134, lazy(() => import('./pages/CalendarSettings')) and .../AdminCalendarSettings) - the two files are never code-split together, so a rebuild that unified them into one component with a scope prop (as this recipe's demo does, for documentation purposes only) would change Labs' own bundle-splitting, a real deviation named here rather than silently assumed away. Env baked at build: VITE_API_URL, S2's own, also read directly by calendarEmbed.ts. CSP and CORS: nothing of its own beyond the SPA's existing allowance. Deploy path: S13's frontend rsync with the bundle. Nothing to migrate here directly; the schema both files read and write (calendar_integrations, meeting_types, bookings and siblings) belongs to the five backend_modules recipes. Argus: a synthetic session calling GET /workspace/integration (or /admin/integration) returning 200 is this page's liveness; neither file has a health signal of its own beyond its fixed fallback sentences.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): both files' six write-path catch blocks read error.response?.data?. detail (workspace: lines 268, 288, 373, 392, 716; admin: lines 222, 244, 305, 337) against an envelope whose key is error (error_schemas.py create_error_response, line 130), so every specific server message is discarded and replaced by one of six fixed fallback sentences - the same class of bug billing-credits/ui/ subscription-manager and identity-auth/ui/admin-users-console each document independently for their own pages; this is now measured on a third, unrelated router in the same codebase.
  • 2026-09-12 (measured at 62ba4fc): the workspace file's timezone patch (onChange on the integration timezone <select>, lines 959-969) has no user-facing failure path at all - its catch only console.errors. A patch that 403s or 400s (an invalid IANA name) leaves the <select> silently reverted with zero on-screen explanation, worse than the six sentence-fallback writes above, which at least say something wrong happened.
  • 2026-09-12 (measured at 62ba4fc): the workspace meeting-type form conditionally renders an input for exactly one of its three location-dependent fields - zoom_link (lines 843-857) - never custom_location (for in_person) or phone_number (for phone), even though MeetingType and the save payload both carry all three and the admin file's own form (lines 574-611 at 62ba4fc, not rendered in this recipe's demo, Frontend) does render both. An operator picking "Vor Ort" or "Telefon" workspace-side has no way to type the address or number into this form at all; the field can only ever be set by whatever created the row before, or via the admin console's own form on a platform-level integration.
  • 2026-09-12 (measured at 62ba4fc): categorizedBookings (workspace 453-483) reads new Date() live inside a useMemo keyed only on bookings; a tab left open across midnight keeps showing yesterday's "Heute" bucket until something else re-renders the page (no interval, no visibility-change listener). The demo replaces this with an injected now prop for a different reason (deterministic server/client rendering, Frontend) that happens to also route around this trap - reproducing it exactly would need a live clock that a renderToString call cannot meaningfully have.
  • 2026-09-12 (measured at 62ba4fc): two lucide imports are dead in the workspace file - User (line 22, never referenced) and Copy (line 15; the copy-embed button uses Code/Check, not Copy - Copy is what the admin file uses for the same button). The same class of harmless dead import billing-credits/ui/ subscription-manager's Gotchas names for CardContent.
  • 2026-09-12 (measured at 62ba4fc): the two files' LOCATION_TYPES constants list the same four values but label two of them differently - "Telefon"/"Vor Ort" (workspace, lines 98-99) versus "Phone Call"/"In Person" (admin, lines 91-92) - the one place either file breaks from otherwise-consistent German copy, and it breaks silently: the stored location_type value is identical, only its rendered label differs by which file drew it.
  • 2026-09-12 (measured at 62ba4fc): the kit's own shared ui-design-system/ui/appshell-navigation/nav.tsx does not list /admin/calendar under its "Operate" domain's paths (unlike the real app's nav.tsx, which has both the path, line 265, and a "Calendar (admin)" rail item, line 280, at the same commit) - so this recipe's admin-variant state renders with the shell's active-domain highlight falling back to Home rather than Operate. Not a bug in this recipe or in Labs; a staleness in a shared demo file that belongs to a different recipe (ui-design-system/ui/appshell-navigation) and was left unfixed here rather than edited out of turn.
  • 2026-09-12 (kit): the domain directory was created directly as modules/calendar-booking/ui/... (no doubled calendar-booking/ui/calendar-booking/ui/... segment) from the start, having read billing-credits/ui/ subscription-manager's own Gotchas entry about exactly that collision first; npm run check's inDemoFolder test and render-smoke.mjs's id lookup both pass on the first try (Acceptance checks).

Reference implementation

Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root.

Workspace file, frontend/src/pages/CalendarSettings.tsx (1369 lines): imports and types (1-93), constants (95-120), component state (122-171), fetchData (174-212), the OAuth-callback listener (219-235), handle Connect (238-272), handleDisconnect (275-291), the meeting-type form handlers (294-395), copyEmbedCode (398-402), generateSlug (405-411), date formatters (414-450), categorizedBookings (453-483), the workspace guards (498-554), googleConnected (559), header and message card (564-604), connection-status card (607-657), AlternativeCalendarConnect mount (661-666), Standard-Nachrichten card (668-734), meeting-types header (737-755), the meeting-type form (758-1086, the zoom-only conditional at 843-857), the meeting-types list (1089-1163, embed preview 1151-1159), the bookings section (1166-1361).

Admin file, frontend/src/pages/AdminCalendarSettings.tsx (969 lines): types (37-79) - no messages field on MeetingType; component state (116-149) - no defaultMessages/defaultsLoaded/isSavingDefaults; fetchData (151-177, admin-only endpoints, bookings capped at limit: 10 server-side); OAuth listener (180-198); handleConnect (201-231, English error strings at lines 217, 226); handleDisconnect (232-248, also clears bookings); meeting-type handlers (249-339); copyEmbedCode (340-345, no integrationId argument); loading guard (367-375, no workspace guard at all); header and connection card (376-478); meeting-types header (479-496, "Neuer Meeting-Typ"); the meeting-type form (497-777, custom_location and phone_number inputs at 548-561 and 587-600 respectively, no timezone selector, static tz text at 651-659, buffer caps of 60 and max_days_ahead capped at 90 versus the workspace form's 120/365); the meeting-types list (778-911, the expand/collapse chevron at 856-870, the "Inaktiv" badge at 809-813); the flat bookings list (912-967).

Supporting: frontend/src/components/calendar/AlternativeCalendarConnect.tsx (header row reproduced, tab forms not); frontend/src/components/prima/PrimaMachtsButton.tsx (not rendered); frontend/src/components/forms/ TallyFormBuilder/MessageStepsEditor.tsx (not mounted, summarised); frontend/src/utils/calendarEmbed.ts (generateEmbedCode/getEmbedUrl, VITE_API_URL); frontend/src/utils/messageOrigin.ts (isTrustedMessageOrigin); frontend/src/contexts/WorkspaceContext.tsx (the workspace guards' source, not this recipe's territory); frontend/src/components/nav.tsx (real "Bookings" domain 127-136, real "Operate" domain including /admin/calendar 256-281); frontend/src/App.tsx (both lazy imports and both routes: 73, 134, 455-459, 799-806); backend/api/routers/calendar_router.py (every route in API surface); backend/models/ calendar_integration_model.py (is_platform_level); backend/app_fastapi.py 719-767 (the envelope both files misread), backend/schemas/error_schemas.py 112-136 (create_error_response, the error key).

Deviations of the reference from this recipe, all dated 2026-09-12: the detail reads on both files; the timezone patch's total silence on failure; the workspace form's missing custom_location/phone_number inputs; the live new Date() in categorizedBookings; the two dead workspace imports; the German/English LOCATION_TYPES label mismatch; and, specific to this recipe's admin-variant state, the shared shell demo's nav.tsx not yet listing /admin/calendar the way the real app's own nav.tsx does.

Acceptance checks

From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, an owner session for /calendar, an admin session for /admin/calendar), or from a new app built to this recipe:

  1. grep -c "data?.detail" src/pages/CalendarSettings.tsx src/pages/AdminCalendarSettings.tsx prints 5 and 3 respectively at 62ba4fc (workspace: connect/disconnect/save/delete/default-messages; admin: connect/ disconnect/save - admin has no delete- or default-messages-specific catch reading .detail beyond what is already counted) and 0/0 after the fix.
  2. grep -n "location_type === 'in_person'\|location_type === 'phone'" src/pages/CalendarSettings.tsx prints nothing at 62ba4fc (the reproduced gap) and at least one line per branch once fixed.
  3. Demo gate: node tooling/verify/render-smoke.mjs calendar-booking/ui/calendar-settings-panel renders all six states with renderToString, fetch stubbed, 0 failures (run below, result in this task's report).
  4. Browser walk-through, signed in as a workspace member with Google connected and at least one meeting type: (a) open /calendar, observe the shell paint first, then the spinner, then the connection card; (b) create a meeting type with "Vor Ort" selected, observe no address field anywhere in the form (the reproduced gap); (c) toggle Standard-Nachrichten on, observe the placeholder copy change, save, reload, observe it stayed on; (d) as a platform admin, open /admin/calendar, observe no Standard-Nachrichten section exists there at all and the rail does not highlight "Operate" (the shared nav.tsx gap, Gotchas).
  5. Verifier: node tooling/verify/compare.mjs calendar-booking/ui/calendar-settings-panel --state populated --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/ session.json (an owner session with Google connected, three meeting types and bookings seeded around the run's actual "today") writes a report with page_errors: []; mismatch_ratio is read for the header and the connection-status card only, since the app's real booking dates and the demo's fixed sample dates will not agree on which bucket "today" falls into once real time has moved past 2026-09-12.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.