@medalsocial/meda 2.8.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +130 -1
  2. package/dist/booking/add-child-sheet.d.ts +38 -0
  3. package/dist/booking/add-child-sheet.js +88 -0
  4. package/dist/booking/booking-skeleton.d.ts +24 -0
  5. package/dist/booking/booking-skeleton.js +17 -0
  6. package/dist/booking/confirmation.d.ts +85 -0
  7. package/dist/booking/confirmation.js +102 -0
  8. package/dist/booking/details-screen.d.ts +118 -0
  9. package/dist/booking/details-screen.js +314 -0
  10. package/dist/booking/format.d.ts +64 -0
  11. package/dist/booking/format.js +1 -0
  12. package/dist/booking/index.d.ts +35 -0
  13. package/dist/booking/index.js +40 -0
  14. package/dist/booking/internal/label-parts.d.ts +8 -0
  15. package/dist/booking/internal/label-parts.js +19 -0
  16. package/dist/booking/internal/ui.d.ts +33 -0
  17. package/dist/booking/internal/ui.js +35 -0
  18. package/dist/booking/label-keys.d.ts +34 -0
  19. package/dist/booking/label-keys.js +56 -0
  20. package/dist/booking/labels.d.ts +15 -0
  21. package/dist/booking/labels.js +17 -0
  22. package/dist/booking/live-status.d.ts +14 -0
  23. package/dist/booking/live-status.js +17 -0
  24. package/dist/booking/login-panel.d.ts +108 -0
  25. package/dist/booking/login-panel.js +239 -0
  26. package/dist/booking/login-sheet.d.ts +41 -0
  27. package/dist/booking/login-sheet.js +49 -0
  28. package/dist/booking/manage-screen.d.ts +113 -0
  29. package/dist/booking/manage-screen.js +369 -0
  30. package/dist/booking/otp-slots.d.ts +42 -0
  31. package/dist/booking/otp-slots.js +109 -0
  32. package/dist/booking/portal/account-card.d.ts +24 -0
  33. package/dist/booking/portal/account-card.js +18 -0
  34. package/dist/booking/portal/action-result.d.ts +30 -0
  35. package/dist/booking/portal/action-result.js +18 -0
  36. package/dist/booking/portal/age-confirm-card.d.ts +39 -0
  37. package/dist/booking/portal/age-confirm-card.js +42 -0
  38. package/dist/booking/portal/child-cards.d.ts +65 -0
  39. package/dist/booking/portal/child-cards.js +59 -0
  40. package/dist/booking/portal/data-controls.d.ts +48 -0
  41. package/dist/booking/portal/data-controls.js +103 -0
  42. package/dist/booking/portal/family-editor.d.ts +141 -0
  43. package/dist/booking/portal/family-editor.js +422 -0
  44. package/dist/booking/portal/logout-button.d.ts +21 -0
  45. package/dist/booking/portal/logout-button.js +27 -0
  46. package/dist/booking/portal/portal-shell.d.ts +71 -0
  47. package/dist/booking/portal/portal-shell.js +38 -0
  48. package/dist/booking/portal/portal-unreachable.d.ts +31 -0
  49. package/dist/booking/portal/portal-unreachable.js +19 -0
  50. package/dist/booking/portal/profile-form.d.ts +75 -0
  51. package/dist/booking/portal/profile-form.js +144 -0
  52. package/dist/booking/portal/rebook-cards.d.ts +44 -0
  53. package/dist/booking/portal/rebook-cards.js +35 -0
  54. package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
  55. package/dist/booking/portal/upcoming-bookings.js +88 -0
  56. package/dist/booking/portal/vipps-link-row.d.ts +54 -0
  57. package/dist/booking/portal/vipps-link-row.js +80 -0
  58. package/dist/booking/portal/visit-history.d.ts +63 -0
  59. package/dist/booking/portal/visit-history.js +73 -0
  60. package/dist/booking/service-screen.d.ts +137 -0
  61. package/dist/booking/service-screen.js +233 -0
  62. package/dist/booking/slots.d.ts +12 -0
  63. package/dist/booking/slots.js +5 -0
  64. package/dist/booking/stylist-screen.d.ts +117 -0
  65. package/dist/booking/stylist-screen.js +226 -0
  66. package/dist/booking/summary-bar.d.ts +26 -0
  67. package/dist/booking/summary-bar.js +32 -0
  68. package/dist/booking/time-screen.d.ts +191 -0
  69. package/dist/booking/time-screen.js +418 -0
  70. package/dist/booking/types.d.ts +284 -0
  71. package/dist/booking/types.js +15 -0
  72. package/dist/booking/vipps-button.d.ts +49 -0
  73. package/dist/booking/vipps-button.js +33 -0
  74. package/dist/booking/who-screen.d.ts +121 -0
  75. package/dist/booking/who-screen.js +164 -0
  76. package/dist/primitives/avatar.d.ts +24 -0
  77. package/dist/primitives/avatar.js +44 -0
  78. package/dist/primitives/button.d.ts +21 -0
  79. package/dist/primitives/button.js +51 -0
  80. package/dist/primitives/checkbox.d.ts +23 -0
  81. package/dist/primitives/checkbox.js +20 -0
  82. package/dist/primitives/field.d.ts +47 -0
  83. package/dist/primitives/field.js +48 -0
  84. package/dist/primitives/index.d.ts +16 -0
  85. package/dist/primitives/index.js +8 -0
  86. package/dist/primitives/input.d.ts +10 -0
  87. package/dist/primitives/input.js +9 -0
  88. package/dist/primitives/sheet.d.ts +68 -0
  89. package/dist/primitives/sheet.js +303 -0
  90. package/dist/primitives/textarea.d.ts +14 -0
  91. package/dist/primitives/textarea.js +13 -0
  92. package/dist/primitives/toggle-group.d.ts +46 -0
  93. package/dist/primitives/toggle-group.js +148 -0
  94. package/dist/styles/base.css +40 -0
  95. package/dist/styles/booking.css +10 -0
  96. package/dist/styles/bridge.css +159 -0
  97. package/dist/styles/calendar.css +8 -0
  98. package/dist/styles/primitives.css +10 -0
  99. package/dist/styles/theme.css +12 -162
  100. package/dist/styles/vendor/xyflow.css +626 -0
  101. package/dist/styles/workflow-builder.css +17 -0
  102. package/package.json +45 -9
  103. package/skills/components/SKILL.md +53 -0
package/README.md CHANGED
@@ -12,6 +12,89 @@ pnpm add @medalsocial/meda lucide-react
12
12
 
13
13
  Peer deps: `react >= 19`, `react-dom >= 19`, and `lucide-react`.
14
14
 
15
+ Feature peers are **optional** — install them only for the entry points that use
16
+ them (package managers no longer auto-install them):
17
+
18
+ | Peer | Needed by |
19
+ | --- | --- |
20
+ | `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` | `@medalsocial/meda` (root barrel), `/shell`, `/kanban`, `/email-builder` |
21
+ | `@xyflow/react` | `@medalsocial/meda` (root barrel), `/workflow-builder`. (Its stylesheet is vendored into `styles.css` / `workflow-builder/styles.css`, so the CSS compiles without the package.) |
22
+ | `three`, `@react-three/fiber` | `/voice` (`VoiceOrb`) |
23
+ | `react-markdown`, `remark-gfm`, `rehype-highlight` | `/markdown-view` |
24
+ | `next-themes` | `NextThemesAdapter` / `themeAdapter="next-themes"` in `/shell` |
25
+
26
+ ```bash
27
+ # Full Medal app (root barrel / shell):
28
+ pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @xyflow/react
29
+ ```
30
+
31
+ Lean surfaces such as `@medalsocial/meda/calendar`, `@medalsocial/meda/booking` and
32
+ `@medalsocial/meda/primitives` need none of them. Fonts are not bundled either:
33
+ the tokens name `Geist` / `Geist Mono`, so load them yourself (for example
34
+ `@fontsource-variable/geist` + `@fontsource-variable/geist-mono`, or
35
+ `next/font`).
36
+
37
+ ## Upgrading to 3.0
38
+
39
+ 3.0 makes the feature peers **optional**, so npm and pnpm no longer install them
40
+ for you. Nothing else in the API changed.
41
+
42
+ **If you import any of** the root `@medalsocial/meda`, `@medalsocial/meda/shell`,
43
+ `/email-builder`, `/kanban`, `/workflow-builder`, or `@medalsocial/meda/styles.css`,
44
+ add the peers to your own `package.json`:
45
+
46
+ ```bash
47
+ pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @xyflow/react
48
+ ```
49
+
50
+ **If you render the 3D `VoiceOrb`** (`@medalsocial/meda/voice`), also add:
51
+
52
+ ```bash
53
+ pnpm add three @react-three/fiber
54
+ ```
55
+
56
+ `three` used to be a regular dependency of meda; it is now an optional peer.
57
+ The `@fontsource-variable/geist*` packages are no longer installed by meda
58
+ either — if you `@import` them, list them yourself.
59
+
60
+ **Lean consumers need nothing:** `@medalsocial/meda/calendar`,
61
+ `@medalsocial/meda/primitives`, `styles/bridge.css`, `styles/base.css` and the
62
+ per-feature stylesheets never reach an optional peer (see
63
+ [Lean consumer setup](#lean-consumer-setup)).
64
+
65
+ ## Booking screens (`@medalsocial/meda/booking`)
66
+
67
+ Unbranded, presentational screens for a customer booking flow: the wizard steps
68
+ (`WhoScreen`, `ServiceScreen`, `StylistScreen`, `TimeScreen`, `DetailsScreen`,
69
+ `SummaryBar`, `Confirmation`, `BookingSkeleton`, `AddChildSheet`), `ManageScreen`,
70
+ login (`LoginSheet`, `LoginPanel`, `OtpSlots`, `VippsButton`) and the customer
71
+ portal (`PortalShell`, `UpcomingBookings`, `VisitHistory`, `ChildCards`,
72
+ `RebookCards`, `FamilyEditor`, `ProfileForm`, `DataControls`, `AccountCard`,
73
+ `PortalUnreachable`, …).
74
+
75
+ They are **props in, callbacks out**: nothing fetches, routes or touches storage.
76
+ The data is plain structural shapes (`BookingServiceDto`, `WizardState`, …), every
77
+ date and price goes through the `format` prop (`BookingFormat`: the business's
78
+ clock, money, phone and name formatting), and every word through `labels` — meda
79
+ ships no default language and no brand. Override, cheapest first:
80
+
81
+ 1. **CSS variables** — the shadcn tokens the bridge maps (`--primary`, `--card`,
82
+ `--muted`, `--border`, `--ring`, `--radius`, …). Colours come from nowhere else.
83
+ 2. **`labels`** — a flat record (`'who.heading'`, …); `BookingLabels` is the full set
84
+ and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen.
85
+ 3. **`classNames`** — per documented slot (`root`, `card`, `cardSelected`, `chip`, …),
86
+ merged after the defaults so an override wins.
87
+ 4. **`components`** — card renderers (`ServiceCard`, `StylistCard`, `TimeChip`,
88
+ `DayChip`, `PersonCard`, …). The defaults are exported (`DefaultServiceCard`, …)
89
+ so an override can wrap them.
90
+
91
+ ```css
92
+ @import 'tailwindcss';
93
+ @import '@medalsocial/meda/styles/bridge.css'; /* your own shadcn tokens */
94
+ @import '@medalsocial/meda/primitives/styles.css';
95
+ @import '@medalsocial/meda/booking/styles.css';
96
+ ```
97
+
15
98
  ## Tailwind CSS v4 setup
16
99
 
17
100
  Meda ships a `styles.css` with its design tokens. Import it once in your entry stylesheet or entry script:
@@ -42,6 +125,50 @@ Avoid placing token overrides before the Meda import; `tokens.css` defines the p
42
125
  @import '@medalsocial/meda/styles.css';
43
126
  ```
44
127
 
128
+ ### Lean consumer setup
129
+
130
+ `styles.css` is batteries-included: it scans **every** meda component so
131
+ Tailwind generates all of their utilities (~22 KB gzip of CSS), and it pulls in
132
+ the `@xyflow/react` stylesheet. That is right for full Medal apps; it is
133
+ overkill for a marketing or customer site that renders a calendar and a few
134
+ primitives. Use the lean entries instead — each feature stylesheet generates
135
+ only the utilities its own compiled output uses:
136
+
137
+ ```css
138
+ @import 'tailwindcss';
139
+
140
+ /* 1. Foundation — pick ONE */
141
+ @import '@medalsocial/meda/styles/base.css'; /* meda tokens + theme bridge + base layer */
142
+ /* @import '@medalsocial/meda/styles/bridge.css'; */ /* theme bridge only: bring your own tokens */
143
+
144
+ /* 2. Only the surfaces you render */
145
+ @import '@medalsocial/meda/primitives/styles.css';
146
+ @import '@medalsocial/meda/calendar/styles.css';
147
+ ```
148
+
149
+ | Entry | Contains |
150
+ | --- | --- |
151
+ | `styles.css` | `styles/base.css` + every feature (full component scan + xyflow CSS). Unchanged for existing consumers. |
152
+ | `styles/base.css` | `tokens.css` + `bridge.css` + the `color-scheme` base layer + Geist font mapping. No component scan, no xyflow. |
153
+ | `styles/bridge.css` | `@custom-variant dark` + the `@theme inline` mappings (`bg-primary`, `text-muted-foreground`, `bg-brand-500`, ...). No token values, no fonts. |
154
+ | `styles/tokens.css` | Raw CSS custom properties only. |
155
+ | `primitives/styles.css` | Utilities for `@medalsocial/meda/primitives` (Button, Input, Textarea, Checkbox, Field, Sheet, ToggleGroup, Avatar, Card, Skeleton, StatusPill, EmptyState, FilterRail; not MarkdownView). |
156
+ | `calendar/styles.css` | Utilities for `@medalsocial/meda/calendar`. |
157
+ | `booking/styles.css` | Utilities for `@medalsocial/meda/booking`. Import `primitives/styles.css` too — the screens are built on the primitives. |
158
+ | `workflow-builder/styles.css` | `@xyflow/react`'s stylesheet + utilities for `@medalsocial/meda/workflow-builder`. The only lean entry that ships xyflow CSS. |
159
+
160
+ Feature stylesheets never include the foundation, so importing several of them
161
+ does not duplicate tokens — import exactly one foundation first. Never combine
162
+ the lean entries with `styles.css` (it already contains all of them).
163
+
164
+ **Bring your own tokens.** meda components use shadcn-style semantic tokens
165
+ (`--background`, `--foreground`, `--primary`, `--primary-foreground`, `--card`,
166
+ `--muted`, `--muted-foreground`, `--accent`, `--border`, `--input`, `--ring`,
167
+ `--destructive`, ...). A site that already defines those names can import
168
+ `styles/bridge.css` instead of `styles/base.css`, and meda parts re-theme to the
169
+ site's palette automatically. `bridge.css` does not map `--font-sans`, so the
170
+ site's own font stack is kept.
171
+
45
172
  ## Usage
46
173
 
47
174
  Use `AppShell` for the styled shell surface:
@@ -219,7 +346,9 @@ See the [demo app](./demo) for a live playground.
219
346
  - `@medalsocial/meda/recipes/next` — copyable Next.js adoption recipe metadata
220
347
  - `@medalsocial/meda/theme` — app-scoped token bridge helpers
221
348
  - `@medalsocial/meda/marketing` — marketing sections and campaign blocks
222
- - `@medalsocial/meda/styles.css` — design tokens + base styles
349
+ - `@medalsocial/meda/primitives` — foundation primitives (`Button`, `Input`, `ToggleGroup`, `Avatar` + `getInitials`, `Card`, `Skeleton`, `StatusPill`, `EmptyState`, `FilterRail`); also re-exported from the root
350
+ - `@medalsocial/meda/styles.css` — design tokens + base styles + every component's utilities
351
+ - `@medalsocial/meda/styles/base.css`, `@medalsocial/meda/styles/bridge.css`, `@medalsocial/meda/<feature>/styles.css` — lean stylesheet entries (see [Lean consumer setup](#lean-consumer-setup))
223
352
 
224
353
  ## Alternative: shadcn registry
225
354
 
@@ -0,0 +1,38 @@
1
+ import type { BookingFormat } from './format.js';
2
+ import { type SlotClassNames } from './slots.js';
3
+ import type { NewChild, SaveResult } from './types.js';
4
+ /**
5
+ * «+ Add child» — a dashed card that opens a small sheet: name, birth year,
6
+ * birth month (optional) and, where there is somewhere to keep it, a note for
7
+ * the stylist. Never a full birth date; the month is all a salon needs.
8
+ *
9
+ * The sheet only collects. What saving MEANS is the caller's: a logged-in
10
+ * parent's child may be created on their profile (`saveNotes`), a guest's
11
+ * lives in this booking only. The sheet stays open, fields locked, until
12
+ * `onSave` resolves — and shows the sentence it answers with if it could not
13
+ * save — so a child is never shown as added before it exists.
14
+ */
15
+ export declare const ADD_CHILD_SHEET_LABEL_KEYS: readonly ["addChild.trigger", "addChild.title", "addChild.description.saved", "addChild.description.local", "addChild.name", "addChild.birthYear", "addChild.birthYearPlaceholder", "addChild.birthMonth", "addChild.birthMonthUnknown", "addChild.notes", "addChild.missing", "addChild.submit", "addChild.close"];
16
+ export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number], string>;
17
+ /**
18
+ * - `root` — the dashed trigger card (the only element in the page flow)
19
+ * - `content` — the sheet (`<dialog>`)
20
+ * - `title` — the sheet's heading
21
+ * - `submit` — the save button
22
+ */
23
+ export type AddChildSheetSlot = 'root' | 'content' | 'title' | 'submit';
24
+ export interface AddChildSheetProps {
25
+ labels: AddChildSheetLabels;
26
+ /** Month names come from `format.clock.monthName`. */
27
+ format: BookingFormat;
28
+ /** Offer the note for the stylist — only where it has somewhere to be kept. */
29
+ saveNotes: boolean;
30
+ disabled: boolean;
31
+ onSave: (child: NewChild) => Promise<SaveResult>;
32
+ /** The newest birth year offered. Defaults to the business-local year now. */
33
+ currentYear?: number;
34
+ /** How many years back the birth-year list goes. Default 18. */
35
+ oldestYearsBack?: number;
36
+ classNames?: SlotClassNames<AddChildSheetSlot>;
37
+ }
38
+ export declare function AddChildSheet({ labels, format, saveNotes, disabled, onSave, currentYear, oldestYearsBack, classNames, }: AddChildSheetProps): import("react").JSX.Element;
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Plus, XIcon } from 'lucide-react';
4
+ import { useId, useState, useTransition } from 'react';
5
+ import { Field } from '../primitives/field.js';
6
+ import { Input } from '../primitives/input.js';
7
+ import { Sheet } from '../primitives/sheet.js';
8
+ import { Textarea } from '../primitives/textarea.js';
9
+ import { BOOKING_INPUT_CLASS, BOOKING_SELECT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
10
+ import { slotClass } from './slots.js';
11
+ /**
12
+ * «+ Add child» — a dashed card that opens a small sheet: name, birth year,
13
+ * birth month (optional) and, where there is somewhere to keep it, a note for
14
+ * the stylist. Never a full birth date; the month is all a salon needs.
15
+ *
16
+ * The sheet only collects. What saving MEANS is the caller's: a logged-in
17
+ * parent's child may be created on their profile (`saveNotes`), a guest's
18
+ * lives in this booking only. The sheet stays open, fields locked, until
19
+ * `onSave` resolves — and shows the sentence it answers with if it could not
20
+ * save — so a child is never shown as added before it exists.
21
+ */
22
+ export const ADD_CHILD_SHEET_LABEL_KEYS = [
23
+ 'addChild.trigger',
24
+ 'addChild.title',
25
+ 'addChild.description.saved',
26
+ 'addChild.description.local',
27
+ 'addChild.name',
28
+ 'addChild.birthYear',
29
+ 'addChild.birthYearPlaceholder',
30
+ 'addChild.birthMonth',
31
+ 'addChild.birthMonthUnknown',
32
+ 'addChild.notes',
33
+ 'addChild.missing',
34
+ 'addChild.submit',
35
+ 'addChild.close',
36
+ ];
37
+ const DEFAULT_OLDEST_YEARS_BACK = 18;
38
+ export function AddChildSheet({ labels, format, saveNotes, disabled, onSave, currentYear, oldestYearsBack = DEFAULT_OLDEST_YEARS_BACK, classNames, }) {
39
+ const id = useId();
40
+ const [open, setOpen] = useState(false);
41
+ const [name, setName] = useState('');
42
+ const [year, setYear] = useState('');
43
+ const [month, setMonth] = useState('');
44
+ const [notes, setNotes] = useState('');
45
+ const [error, setError] = useState(null);
46
+ const [pending, startTransition] = useTransition();
47
+ const newestYear = currentYear ?? Number(format.clock.dayKey(Date.now()).slice(0, 4));
48
+ const years = Array.from({ length: oldestYearsBack + 1 }, (_, index) => newestYear - index);
49
+ const months = Array.from({ length: 12 }, (_, index) => index + 1);
50
+ function reset() {
51
+ setName('');
52
+ setYear('');
53
+ setMonth('');
54
+ setNotes('');
55
+ setError(null);
56
+ }
57
+ function submit(event) {
58
+ event.preventDefault();
59
+ const trimmed = name.trim();
60
+ if (trimmed === '' || year === '') {
61
+ setError(labels['addChild.missing']);
62
+ return;
63
+ }
64
+ startTransition(async () => {
65
+ const result = await onSave({
66
+ name: trimmed,
67
+ birthYear: Number(year),
68
+ ...(month === '' ? {} : { birthMonth: Number(month) }),
69
+ ...(saveNotes && notes.trim() !== '' ? { notes: notes.trim() } : {}),
70
+ });
71
+ if (result.ok) {
72
+ reset();
73
+ setOpen(false);
74
+ return;
75
+ }
76
+ setError(result.message);
77
+ });
78
+ }
79
+ return (_jsxs(Sheet, { open: open, dismissible: !pending, onOpenChange: (next) => {
80
+ if (pending)
81
+ return;
82
+ setOpen(next);
83
+ if (!next)
84
+ setError(null);
85
+ }, children: [_jsxs(Sheet.Trigger, { disabled: disabled, className: slotClass(classNames, 'root', 'flex h-[4.5rem] w-full items-center gap-3 rounded-lg border border-dashed border-border px-4 text-left font-medium text-primary transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-50'), children: [_jsx("span", { "aria-hidden": "true", className: "flex size-11 shrink-0 items-center justify-center rounded-full border border-dashed border-primary", children: _jsx(Plus, { className: "size-5" }) }), labels['addChild.trigger']] }), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content'), children: [_jsxs("form", { onSubmit: submit, className: "space-y-5", children: [_jsx(Sheet.Title, { className: slotClass(classNames, 'title', 'pe-10 font-sans text-2xl font-bold'), children: labels['addChild.title'] }), _jsx(Sheet.Description, { className: "text-muted-foreground", children: saveNotes
86
+ ? labels['addChild.description.saved']
87
+ : labels['addChild.description.local'] }), _jsxs("fieldset", { disabled: pending, className: "space-y-4", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['addChild.name'] }), _jsx(Input, { id: `${id}-name`, value: name, maxLength: 60, autoComplete: "off", onChange: (event) => setName(event.target.value), className: BOOKING_INPUT_CLASS })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['addChild.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: year, onChange: (event) => setYear(event.target.value), className: BOOKING_SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['addChild.birthYearPlaceholder'] }), years.map((option) => (_jsx("option", { value: String(option), children: option }, option)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['addChild.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: month, onChange: (event) => setMonth(event.target.value), className: BOOKING_SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['addChild.birthMonthUnknown'] }), months.map((option) => (_jsx("option", { value: String(option), children: format.clock.monthName(option) }, option)))] })] })] }), saveNotes && (_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['addChild.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: 500, value: notes, onChange: (event) => setNotes(event.target.value), className: BOOKING_TEXTAREA_CLASS })] }))] }), _jsx("div", { "aria-live": "polite", className: "min-h-5", children: error && _jsx(Field.Error, { children: error }) }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['addChild.submit'] })] }), _jsx(Sheet.Close, { render: _jsxs(BookingButton, { variant: "ghost", className: "absolute top-4 right-4", size: "icon-sm", children: [_jsx(XIcon, { "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: labels['addChild.close'] })] }) })] })] }));
88
+ }
@@ -0,0 +1,24 @@
1
+ import { type SlotClassNames } from './slots.js';
2
+ export declare const BOOKING_SKELETON_LABEL_KEYS: readonly ["bookingSkeleton.opening"];
3
+ /** Copy for the pending skeleton: the sentence a screen reader hears while it is up. */
4
+ export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number], string>;
5
+ /**
6
+ * `classNames` slots: `root` the fixed overlay · `frame` the pulsing column ·
7
+ * `bar` the active step-bar segment · `chip` one of the four person chips.
8
+ */
9
+ export type BookingSkeletonSlot = 'root' | 'frame' | 'bar' | 'chip';
10
+ export interface BookingSkeletonProps {
11
+ labels: BookingSkeletonLabels;
12
+ classNames?: SlotClassNames<BookingSkeletonSlot>;
13
+ }
14
+ /**
15
+ * The first screen of the booking flow, drawn while a navigation into it is
16
+ * still pending.
17
+ *
18
+ * Fixed and over the page rather than in its flow, so it moves nothing; under a
19
+ * `z-50` site header, which stays put across the navigation, and over a `z-40`
20
+ * sticky booking bar, which the flow hides. Its shapes follow the first step —
21
+ * title, lead, step bar, four person chips — so the page that replaces it lands
22
+ * where the skeleton was. Pure markup: when to show it is the caller's call.
23
+ */
24
+ export declare function BookingSkeleton({ labels, classNames }: BookingSkeletonProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Skeleton } from '../primitives/skeleton.js';
3
+ import { slotClass } from './slots.js';
4
+ export const BOOKING_SKELETON_LABEL_KEYS = ['bookingSkeleton.opening'];
5
+ /**
6
+ * The first screen of the booking flow, drawn while a navigation into it is
7
+ * still pending.
8
+ *
9
+ * Fixed and over the page rather than in its flow, so it moves nothing; under a
10
+ * `z-50` site header, which stays put across the navigation, and over a `z-40`
11
+ * sticky booking bar, which the flow hides. Its shapes follow the first step —
12
+ * title, lead, step bar, four person chips — so the page that replaces it lands
13
+ * where the skeleton was. Pure markup: when to show it is the caller's call.
14
+ */
15
+ export function BookingSkeleton({ labels, classNames }) {
16
+ return (_jsxs("div", { role: "status", "aria-live": "polite", "data-testid": "booking-pending", className: slotClass(classNames, 'root', 'fixed inset-0 z-[45] bg-background'), children: [_jsx("span", { className: "sr-only", children: labels['bookingSkeleton.opening'] }), _jsxs("div", { "aria-hidden": "true", className: slotClass(classNames, 'frame', 'mx-auto w-full max-w-[720px] px-8 pt-28 motion-safe:animate-pulse'), children: [_jsx(Skeleton, { className: "h-11 w-56 animate-none rounded-lg" }), _jsx(Skeleton, { className: "mt-5 h-4 w-full max-w-md animate-none rounded" }), _jsx(Skeleton, { className: "mt-2 h-4 w-3/4 max-w-sm animate-none rounded" }), _jsxs("div", { className: "mt-12 flex gap-2", children: [_jsx("div", { className: slotClass(classNames, 'bar', 'h-1.5 flex-1 rounded-full bg-primary/60') }), _jsx(Skeleton, { className: "h-1.5 flex-1 animate-none rounded-full" }), _jsx(Skeleton, { className: "h-1.5 flex-1 animate-none rounded-full" }), _jsx(Skeleton, { className: "h-1.5 flex-1 animate-none rounded-full" })] }), _jsx(Skeleton, { className: "mt-10 h-7 w-60 animate-none rounded-lg" }), _jsx("div", { className: "mt-8 grid grid-cols-2 gap-3", children: [0, 1, 2, 3].map((index) => (_jsx("div", { className: slotClass(classNames, 'chip', 'h-12 rounded-full border border-border bg-card') }, index))) })] })] }));
17
+ }
@@ -0,0 +1,85 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { BookingFormat } from './format.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ import type { WizardItem } from './types.js';
5
+ /**
6
+ * The confirmation screen — the one the whole flow exists to reach.
7
+ *
8
+ * Presentational, and computes nothing about the booking: each line's start
9
+ * and price, the total, and the calendar file all come in precomputed from the
10
+ * same machine the details step read, so the number the visitor agreed to and
11
+ * the number on this card cannot drift apart.
12
+ *
13
+ * Everything it was not given, it does not draw: an empty address line or a
14
+ * link to a page that 404s reads as a booking that did not quite happen.
15
+ */
16
+ export declare const CONFIRMATION_LABEL_KEYS: readonly ["confirmation.heading", "confirmation.serviceFor", "confirmation.when", "confirmation.party.heading", "confirmation.party.line", "confirmation.party.stylist", "confirmation.party.total", "confirmation.calendar", "confirmation.manage", "confirmation.manageFor", "confirmation.portal", "confirmation.startOver"];
17
+ export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], string>;
18
+ /**
19
+ * One booked line (one person's appointment). One array rather than parallel
20
+ * lists, because every part answers about the SAME line.
21
+ */
22
+ export interface ConfirmationLine {
23
+ /** With the booked-for name filled in. */
24
+ item: WizardItem;
25
+ /** THIS line's own booking id. */
26
+ bookingId: string;
27
+ /** Who takes this line; `null` = «first available» not worth naming. Cleaned with `format.stylistName`. */
28
+ stylistName: string | null;
29
+ /** Where this line's own booking is managed; `null` draws no link. */
30
+ manageHref: string | null;
31
+ /** When this line starts (a sequential party runs back to back). */
32
+ startTs: number;
33
+ /** What this line costs on the day it is booked for, in minor units. */
34
+ priceOre: number;
35
+ }
36
+ /**
37
+ * - `root` the `<section>`
38
+ * - `heading` the `<h2>`
39
+ * - `card` the single booking's card
40
+ * - `partyCard` the family card
41
+ * - `address` the address line
42
+ * - `link` every text link (calendar, manage, portal)
43
+ * - `startOver` the «book again» button
44
+ */
45
+ export type ConfirmationSlot = 'root' | 'heading' | 'card' | 'partyCard' | 'address' | 'link' | 'startOver';
46
+ export interface PartyLineProps {
47
+ /** The line, with its stylist name already cleaned. */
48
+ line: ConfirmationLine;
49
+ /** The whole line as a sentence, e.g. «15:00 Name – Service with Stylist – 490 kr». */
50
+ text: string;
51
+ /** Visible manage-link text. */
52
+ manageLabel: string;
53
+ /** Accessible manage-link name, naming whose appointment it is. */
54
+ manageAriaLabel: string;
55
+ linkClassName?: string;
56
+ }
57
+ /** One row of the family card: the line and its own manage link. Renders an `<li>`. */
58
+ export declare function DefaultPartyLine({ line, text, manageLabel, manageAriaLabel, linkClassName, }: PartyLineProps): import("react").JSX.Element;
59
+ export interface ConfirmationComponents {
60
+ PartyLine?: ComponentType<PartyLineProps>;
61
+ }
62
+ export interface ConfirmationProps {
63
+ /** One per booked person, in the order they sit down. */
64
+ lines: readonly ConfirmationLine[];
65
+ /** When the visit starts. */
66
+ startTs: number;
67
+ /** The visit's total in minor units. */
68
+ totalOre: number;
69
+ address?: string | null;
70
+ /** The calendar file (e.g. a `data:text/calendar` URL). `null` draws no link. */
71
+ calendarHref?: string | null;
72
+ /** `download` filename for the calendar link. Required for a `data:` URL to work. */
73
+ calendarFileName?: string;
74
+ /** Where all of the visitor's bookings are listed. `null` draws no link. */
75
+ portalHref?: string | null;
76
+ /** A way back to an empty wizard. Absent, the button is not drawn. */
77
+ onStartOver?: () => void;
78
+ /** «today» is relative to this. Read once, so it cannot change mid-card. */
79
+ now?: number;
80
+ format: BookingFormat;
81
+ labels: ConfirmationLabels;
82
+ classNames?: SlotClassNames<ConfirmationSlot>;
83
+ components?: ConfirmationComponents;
84
+ }
85
+ export declare function Confirmation({ lines: rawLines, startTs, totalOre, address, calendarHref, calendarFileName, portalHref, onStartOver, now, format, labels, classNames, components, }: ConfirmationProps): import("react").JSX.Element;
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BookingButton } from './internal/ui.js';
4
+ import { fillLabel } from './labels.js';
5
+ import { slotClass } from './slots.js';
6
+ /**
7
+ * The confirmation screen — the one the whole flow exists to reach.
8
+ *
9
+ * Presentational, and computes nothing about the booking: each line's start
10
+ * and price, the total, and the calendar file all come in precomputed from the
11
+ * same machine the details step read, so the number the visitor agreed to and
12
+ * the number on this card cannot drift apart.
13
+ *
14
+ * Everything it was not given, it does not draw: an empty address line or a
15
+ * link to a page that 404s reads as a booking that did not quite happen.
16
+ */
17
+ export const CONFIRMATION_LABEL_KEYS = [
18
+ 'confirmation.heading',
19
+ /** `{service}` booked for `{name}` (used when a name is known). */
20
+ 'confirmation.serviceFor',
21
+ /** `{day}` `{time}` — the single booking's «when». */
22
+ 'confirmation.when',
23
+ /** `{day}` — the family card's heading. */
24
+ 'confirmation.party.heading',
25
+ /** `{time} {name} – {service} {stylist} – {price}`; blank parts collapse. */
26
+ 'confirmation.party.line',
27
+ /** `{name}` — the stylist part of a family line. */
28
+ 'confirmation.party.stylist',
29
+ /** `{total}` — the family card's total line. */
30
+ 'confirmation.party.total',
31
+ 'confirmation.calendar',
32
+ 'confirmation.manage',
33
+ /** `{who}` `{time}` — accessible name of a family line's manage link. */
34
+ 'confirmation.manageFor',
35
+ 'confirmation.portal',
36
+ 'confirmation.startOver',
37
+ ];
38
+ /** One row of the family card: the line and its own manage link. Renders an `<li>`. */
39
+ export function DefaultPartyLine({ line, text, manageLabel, manageAriaLabel, linkClassName, }) {
40
+ return (_jsxs("li", { className: "flex flex-wrap items-baseline gap-x-3", children: [_jsx("span", { className: "tabular-nums", children: text }), line.manageHref && (_jsx("a", { href: line.manageHref, "aria-label": manageAriaLabel, className: linkClassName ?? 'text-sm font-semibold text-primary underline underline-offset-4', children: manageLabel }))] }));
41
+ }
42
+ const SEPARATOR = ' · ';
43
+ export function Confirmation({ lines: rawLines, startTs, totalOre, address = null, calendarHref = null, calendarFileName = 'booking.ics', portalHref = null, onStartOver, now = Date.now(), format, labels, classNames, components, }) {
44
+ const { clock } = format;
45
+ // Cleaned here as well as upstream: a restored confirmation carries whatever
46
+ // name was resolved when it was stored.
47
+ const lines = rawLines.map((line) => ({
48
+ ...line,
49
+ stylistName: line.stylistName ? format.stylistName(line.stylistName) || null : null,
50
+ }));
51
+ const linkClass = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
52
+ const first = lines[0];
53
+ const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: [
54
+ first.item.bookedForName
55
+ ? fillLabel(labels['confirmation.serviceFor'], {
56
+ service: first.item.service.name,
57
+ name: first.item.bookedForName,
58
+ })
59
+ : first.item.service.name,
60
+ first.stylistName,
61
+ fillLabel(labels['confirmation.when'], {
62
+ day: clock.dayLabel(startTs, now),
63
+ time: clock.formatTime(startTs),
64
+ }),
65
+ // This line's own price, which for one person is the whole total.
66
+ format.price(first.priceOre),
67
+ ]
68
+ // Filtered, not conditionally joined: an absent stylist closes the gap.
69
+ .filter((part) => Boolean(part))
70
+ .join(SEPARATOR) })) : null;
71
+ return (_jsxs("section", { "aria-labelledby": "booking-confirmed-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-confirmed-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['confirmation.heading'] }), lines.length > 1 ? (_jsx(PartyCard, { lines: lines, totalOre: totalOre, now: now, format: format, labels: labels, classNames: classNames, PartyLine: components?.PartyLine ?? DefaultPartyLine })) : (singleCard), address && (_jsx("p", { className: slotClass(classNames, 'address', 'text-sm text-muted-foreground'), children: address })), _jsxs("div", { className: "flex flex-wrap gap-4", children: [calendarHref && (_jsx("a", { href: calendarHref, download: calendarFileName, className: linkClass, children: labels['confirmation.calendar'] })), lines.length === 1 && first?.manageHref && (_jsx("a", { href: first.manageHref, className: linkClass, children: labels['confirmation.manage'] }))] }), portalHref && (_jsx("p", { className: "text-sm", children: _jsx("a", { href: portalHref, className: linkClass, children: labels['confirmation.portal'] }) })), onStartOver && (_jsx("div", { children: _jsx(BookingButton, { variant: "outline", className: slotClass(classNames, 'startOver'), onClick: onStartOver, children: labels['confirmation.startOver'] }) }))] }));
72
+ }
73
+ /**
74
+ * The family card: one line per person in the order they sit down, each with
75
+ * its own manage link (each line is its own booking), and a total.
76
+ */
77
+ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine, }) {
78
+ const { clock } = format;
79
+ const firstStart = lines[0]?.startTs ?? 0;
80
+ return (_jsxs("div", { className: slotClass(classNames, 'partyCard', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: fillLabel(labels['confirmation.party.heading'], { day: clock.dayLabel(firstStart, now) }) }), _jsx("ul", { className: "space-y-1", children: lines.map((line, index) => {
81
+ const time = clock.formatTime(line.startTs);
82
+ const text = fillLabel(labels['confirmation.party.line'], {
83
+ time,
84
+ name: line.item.bookedForName ?? '',
85
+ service: line.item.service.name,
86
+ stylist: line.stylistName
87
+ ? fillLabel(labels['confirmation.party.stylist'], { name: line.stylistName })
88
+ : '',
89
+ price: format.price(line.priceOre),
90
+ })
91
+ // Blank parts collapse. Plain spaces only: a price joined with a
92
+ // non-breaking space must keep it.
93
+ .replace(/ {2,}/g, ' ')
94
+ .trim();
95
+ return (_jsx(PartyLine
96
+ // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
97
+ , { line: line, text: text, manageLabel: labels['confirmation.manage'], manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
98
+ who: line.item.bookedForName ?? line.item.service.name,
99
+ time,
100
+ }), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
101
+ }) }), _jsx("p", { className: "font-semibold", children: fillLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
102
+ }
@@ -0,0 +1,118 @@
1
+ import { type ComponentType } from 'react';
2
+ import type { BookingFormat } from './format.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from './types.js';
5
+ /**
6
+ * The last step of the booking wizard: contact details, who each seat is for,
7
+ * the consents, and the button that books.
8
+ *
9
+ * Presentational, with the same bargain the other steps make: every answer
10
+ * leaves as an action (`onChange`) and the caller's machine decides what it
11
+ * means. What stays here is local UI state that is not an answer about the
12
+ * booking — the keystrokes behind the per-child fields, which field has focus,
13
+ * whether submit has been pressed, and a fallback submission nonce.
14
+ */
15
+ export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["details.heading", "details.phone.label", "details.phone.prefix", "details.phone.error", "details.name.label", "details.known.label", "details.known.for", "details.known.self", "details.known.adult", "details.known.child", "details.child.name", "details.child.year", "details.child.yearPlaceholder", "details.email.label", "details.email.help", "details.email.error", "details.notes.label", "details.notes.placeholder", "details.terms.text", "details.terms.link", "details.marketing.text", "details.error.maxParty", "details.error.slotTaken", "details.error.conflict", "details.error.invalidInput", "details.error.unconfigured", "details.error.upstreamError", "details.error.inProgress", "details.call.link", "details.call.none", "details.submit"];
16
+ export type DetailsScreenLabelKey = (typeof DETAILS_SCREEN_LABEL_KEYS)[number];
17
+ export type DetailsScreenLabels = Record<DetailsScreenLabelKey, string>;
18
+ /**
19
+ * The label key for each failure the wizard can carry. Exported because some
20
+ * of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
21
+ * the same sentence — one map, so one refusal is never explained two ways.
22
+ */
23
+ export declare const DETAILS_ERROR_LABEL_KEYS: Record<WizardError, DetailsScreenLabelKey>;
24
+ /** One line of the submission. Optional fields are ABSENT, never blank. */
25
+ export interface BookingSubmissionItem {
26
+ serviceId: string;
27
+ resourceId?: string;
28
+ startTs: number;
29
+ bookedForName?: string;
30
+ bookedForBirthYear?: number;
31
+ bookedForPersonId?: string;
32
+ }
33
+ /** The booking as the details step submits it. Optional fields are ABSENT, never blank. */
34
+ export interface BookingSubmission {
35
+ items: BookingSubmissionItem[];
36
+ contact: {
37
+ phone: string;
38
+ name?: string;
39
+ email?: string;
40
+ };
41
+ notes?: string;
42
+ consentTerms: boolean;
43
+ consentMarketing: boolean;
44
+ /** Identifies the submission attempt, so a retry can be made idempotent. */
45
+ submissionNonce?: string;
46
+ }
47
+ /** Where and with whom one basket line sits, precomputed by the caller's machine. */
48
+ export interface DetailsLine {
49
+ startTs: number;
50
+ /** The resource the slot RESOLVED to for this line; `null` sends none. */
51
+ resourceId: string | null;
52
+ }
53
+ /**
54
+ * - `root` the `<section>`
55
+ * - `heading` the `<h2>`
56
+ * - `field` every `Field` wrapper
57
+ * - `input` every text input and the notes textarea
58
+ * - `childGroup` the per-line `<fieldset>` (only drawn for more than one line)
59
+ * - `consent` the two consent rows
60
+ * - `error` the submit-failure alert
61
+ * - `submit` the submit button
62
+ */
63
+ export type DetailsScreenSlot = 'root' | 'heading' | 'field' | 'input' | 'childGroup' | 'consent' | 'error' | 'submit';
64
+ export interface FamilyChipProps {
65
+ member: BookingFamilyMember;
66
+ /** Both the name and the year in the fields match this member. */
67
+ pressed: boolean;
68
+ onPick: () => void;
69
+ }
70
+ /** One saved child offered as a single tap above the child fields. */
71
+ export declare function DefaultFamilyChip({ member, pressed, onPick }: FamilyChipProps): import("react").JSX.Element;
72
+ export interface DetailsScreenComponents {
73
+ FamilyChip?: ComponentType<FamilyChipProps>;
74
+ }
75
+ export interface DetailsScreenProps {
76
+ state: WizardState;
77
+ /** Raises `setContact`, `setItemField`, `setNotes` and `setConsent`. */
78
+ onChange: (action: WizardAction) => void;
79
+ onSubmit: (submission: BookingSubmission) => void;
80
+ /**
81
+ * One per `state.items`, in the same order: the start the machine seated
82
+ * each line at and the resource it resolved to.
83
+ */
84
+ lines: readonly DetailsLine[];
85
+ /** The whole basket's price in minor units, as the business will charge it. */
86
+ totalOre: number;
87
+ /** Whether the phone field holds a number the business can use. */
88
+ phoneLooksValid: (value: string) => boolean;
89
+ /** The phone as it is submitted and compared (e.g. national digits only). */
90
+ normalisePhone: (value: string) => string;
91
+ /** True while a submission is in flight: the button goes inert and `aria-busy`. */
92
+ submitting?: boolean;
93
+ /** The business's phone for the failure message; `null` renders the sentence unlinked. */
94
+ phone?: string | null;
95
+ /**
96
+ * The submission nonce, held by the caller so it survives a remount. Absent,
97
+ * one is minted for the life of this mount.
98
+ */
99
+ submissionNonce?: string;
100
+ /** Saved children on a logged-in profile, offered as one tap each. */
101
+ family?: readonly BookingFamilyMember[];
102
+ /**
103
+ * The logged-in guardian's phone. A line's saved-person id is only submitted
104
+ * while the phone in the form normalises to this one.
105
+ */
106
+ guardianPhone?: string | null;
107
+ /** When set, a link to the full terms is drawn after the terms sentence. */
108
+ termsHref?: string | null;
109
+ /** Draw the (optional, unticked) marketing opt-in. */
110
+ marketingConsent?: boolean;
111
+ format: BookingFormat;
112
+ labels: DetailsScreenLabels;
113
+ classNames?: SlotClassNames<DetailsScreenSlot>;
114
+ components?: DetailsScreenComponents;
115
+ }
116
+ /** Blank is fine (the field is optional). Anything else has to be reachable. */
117
+ export declare function looksLikeEmail(value: string): boolean;
118
+ export declare function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting, phone, submissionNonce: nonceProp, family, guardianPhone, termsHref, marketingConsent, format, labels, classNames, components, }: DetailsScreenProps): import("react").JSX.Element;