@medalsocial/meda 3.0.0 → 3.3.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 (88) hide show
  1. package/README.md +36 -2
  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 +316 -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 +36 -0
  13. package/dist/booking/index.js +41 -0
  14. package/dist/booking/internal/label-parts.d.ts +28 -0
  15. package/dist/booking/internal/label-parts.js +57 -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 +372 -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 +104 -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 +84 -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 +140 -0
  61. package/dist/booking/service-screen.js +235 -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 +128 -0
  65. package/dist/booking/stylist-screen.js +229 -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 +419 -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/checkbox.d.ts +23 -0
  77. package/dist/primitives/checkbox.js +20 -0
  78. package/dist/primitives/field.d.ts +47 -0
  79. package/dist/primitives/field.js +48 -0
  80. package/dist/primitives/index.d.ts +8 -0
  81. package/dist/primitives/index.js +4 -0
  82. package/dist/primitives/sheet.d.ts +68 -0
  83. package/dist/primitives/sheet.js +303 -0
  84. package/dist/primitives/textarea.d.ts +14 -0
  85. package/dist/primitives/textarea.js +13 -0
  86. package/dist/styles/booking.css +10 -0
  87. package/package.json +15 -8
  88. package/skills/components/SKILL.md +53 -0
package/README.md CHANGED
@@ -28,7 +28,7 @@ them (package managers no longer auto-install them):
28
28
  pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @xyflow/react
29
29
  ```
30
30
 
31
- Lean surfaces such as `@medalsocial/meda/calendar` and
31
+ Lean surfaces such as `@medalsocial/meda/calendar`, `@medalsocial/meda/booking` and
32
32
  `@medalsocial/meda/primitives` need none of them. Fonts are not bundled either:
33
33
  the tokens name `Geist` / `Geist Mono`, so load them yourself (for example
34
34
  `@fontsource-variable/geist` + `@fontsource-variable/geist-mono`, or
@@ -62,6 +62,39 @@ either — if you `@import` them, list them yourself.
62
62
  per-feature stylesheets never reach an optional peer (see
63
63
  [Lean consumer setup](#lean-consumer-setup)).
64
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
+
65
98
  ## Tailwind CSS v4 setup
66
99
 
67
100
  Meda ships a `styles.css` with its design tokens. Import it once in your entry stylesheet or entry script:
@@ -119,8 +152,9 @@ only the utilities its own compiled output uses:
119
152
  | `styles/base.css` | `tokens.css` + `bridge.css` + the `color-scheme` base layer + Geist font mapping. No component scan, no xyflow. |
120
153
  | `styles/bridge.css` | `@custom-variant dark` + the `@theme inline` mappings (`bg-primary`, `text-muted-foreground`, `bg-brand-500`, ...). No token values, no fonts. |
121
154
  | `styles/tokens.css` | Raw CSS custom properties only. |
122
- | `primitives/styles.css` | Utilities for `@medalsocial/meda/primitives` (Button, Input, ToggleGroup, Avatar, Card, Skeleton, StatusPill, EmptyState, FilterRail; not MarkdownView). |
155
+ | `primitives/styles.css` | Utilities for `@medalsocial/meda/primitives` (Button, Input, Textarea, Checkbox, Field, Sheet, ToggleGroup, Avatar, Card, Skeleton, StatusPill, EmptyState, FilterRail; not MarkdownView). |
123
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. |
124
158
  | `workflow-builder/styles.css` | `@xyflow/react`'s stylesheet + utilities for `@medalsocial/meda/workflow-builder`. The only lean entry that ships xyflow CSS. |
125
159
 
126
160
  Feature stylesheets never include the foundation, so importing several of them
@@ -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 { joinLabelParts, renderLabel } from './internal/label-parts.js';
4
+ import { BookingButton } from './internal/ui.js';
5
+ import { fillLabel } from './labels.js';
6
+ import { slotClass } from './slots.js';
7
+ /**
8
+ * The confirmation screen — the one the whole flow exists to reach.
9
+ *
10
+ * Presentational, and computes nothing about the booking: each line's start
11
+ * and price, the total, and the calendar file all come in precomputed from the
12
+ * same machine the details step read, so the number the visitor agreed to and
13
+ * the number on this card cannot drift apart.
14
+ *
15
+ * Everything it was not given, it does not draw: an empty address line or a
16
+ * link to a page that 404s reads as a booking that did not quite happen.
17
+ */
18
+ export const CONFIRMATION_LABEL_KEYS = [
19
+ 'confirmation.heading',
20
+ /** `{service}` booked for `{name}` (used when a name is known). */
21
+ 'confirmation.serviceFor',
22
+ /** `{day}` `{time}` — the single booking's «when». */
23
+ 'confirmation.when',
24
+ /** `{day}` — the family card's heading. */
25
+ 'confirmation.party.heading',
26
+ /** `{time} {name} – {service} {stylist} – {price}`; blank parts collapse. */
27
+ 'confirmation.party.line',
28
+ /** `{name}` — the stylist part of a family line. */
29
+ 'confirmation.party.stylist',
30
+ /** `{total}` — the family card's total line. */
31
+ 'confirmation.party.total',
32
+ 'confirmation.calendar',
33
+ 'confirmation.manage',
34
+ /** `{who}` `{time}` — accessible name of a family line's manage link. */
35
+ 'confirmation.manageFor',
36
+ 'confirmation.portal',
37
+ 'confirmation.startOver',
38
+ ];
39
+ /** One row of the family card: the line and its own manage link. Renders an `<li>`. */
40
+ export function DefaultPartyLine({ line, text, manageLabel, manageAriaLabel, linkClassName, }) {
41
+ 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 }))] }));
42
+ }
43
+ const SEPARATOR = ' · ';
44
+ export function Confirmation({ lines: rawLines, startTs, totalOre, address = null, calendarHref = null, calendarFileName = 'booking.ics', portalHref = null, onStartOver, now = Date.now(), format, labels, classNames, components, }) {
45
+ const { clock } = format;
46
+ // Cleaned here as well as upstream: a restored confirmation carries whatever
47
+ // name was resolved when it was stored.
48
+ const lines = rawLines.map((line) => ({
49
+ ...line,
50
+ stylistName: line.stylistName ? format.stylistName(line.stylistName) || null : null,
51
+ }));
52
+ const linkClass = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
53
+ const first = lines[0];
54
+ const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
55
+ first.item.bookedForName
56
+ ? renderLabel(labels['confirmation.serviceFor'], {
57
+ service: first.item.service.name,
58
+ name: first.item.bookedForName,
59
+ })
60
+ : first.item.service.name,
61
+ first.stylistName,
62
+ renderLabel(labels['confirmation.when'], {
63
+ day: clock.dayLabel(startTs, now),
64
+ time: clock.formatTime(startTs),
65
+ }),
66
+ // This line's own price, which for one person is the whole total.
67
+ format.price(first.priceOre),
68
+ ], SEPARATOR) })) : null;
69
+ 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'] }) }))] }));
70
+ }
71
+ /**
72
+ * The family card: one line per person in the order they sit down, each with
73
+ * its own manage link (each line is its own booking), and a total.
74
+ */
75
+ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine, }) {
76
+ const { clock } = format;
77
+ const firstStart = lines[0]?.startTs ?? 0;
78
+ 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: renderLabel(labels['confirmation.party.heading'], {
79
+ day: clock.dayLabel(firstStart, now),
80
+ }) }), _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: renderLabel(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;