@medalsocial/meda 3.0.0 → 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 (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 +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/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 +8 -1
  88. package/skills/components/SKILL.md +53 -0
@@ -0,0 +1,117 @@
1
+ import { type ComponentType, type Ref } from 'react';
2
+ import type { BookingFormat } from './format.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ import type { BookingResourceDto, PartyMode } from './types.js';
5
+ /**
6
+ * The stylist step — who would you like to see?
7
+ *
8
+ * Presentational, and one decision short of the machine's: `null` is a real
9
+ * answer here — «first available» — so the tap raises `onPick(null)` rather
10
+ * than raising nothing.
11
+ *
12
+ * ONE list of options at every width, restyled rather than swapped. Under `md`
13
+ * it is a single fixed-height row of avatars that scrolls sideways — full
14
+ * cards stacked above a time grid push everything a phone could tap below the
15
+ * fold — and from `md` up it is the column of cards. A second, phone-only list
16
+ * would be a second set of radios in the accessibility tree and a layout that
17
+ * could only be picked after hydration, i.e. a jump.
18
+ *
19
+ * A radiogroup, because that is what it is: exactly one answer, «first
20
+ * available» included. Arrow keys move and choose, Tab enters and leaves once.
21
+ */
22
+ export declare const STYLIST_SCREEN_LABEL_KEYS: readonly ["stylist.heading", "stylist.firstAvailable", "stylist.firstAvailableSubtitle", "stylist.firstAvailableBadge", "stylist.pendingName", "stylist.nextAvailable", "stylist.loading", "stylist.party.legend", "stylist.party.sequential", "stylist.party.sequentialMinutes", "stylist.party.parallel.two", "stylist.party.parallel.three", "stylist.party.parallel.other", "stylist.party.parallelMinutes", "stylist.party.parallelNote.two", "stylist.party.parallelNote.three", "stylist.party.parallelNote.other"];
23
+ export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number], string>;
24
+ /**
25
+ * - `root` — the `<section>`
26
+ * - `heading` — the `<h2>`
27
+ * - `group` — the radiogroup (the sideways row / the column)
28
+ * - `card` / `cardSelected` — a stylist option / the checked one (passed to `StylistCard`)
29
+ * - `modeCard` / `modeCardSelected` — a family's mode card / the pressed one
30
+ */
31
+ export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected';
32
+ /** One option of the radiogroup as the card draws it. */
33
+ export interface StylistOption {
34
+ /** `null` is «first available». */
35
+ resourceId: string | null;
36
+ name: string;
37
+ /** What a phone tile has room for: a stylist's first name, else the whole label. */
38
+ shortName: string;
39
+ subtitle?: string | null;
40
+ /** The avatar's fallback: initials, or a symbol for «first available». */
41
+ badge: string;
42
+ photoUrl?: string | null;
43
+ /** The formatted next opening, when there is one. */
44
+ availability?: string | null;
45
+ availabilityLoading?: boolean;
46
+ /** Hold the next-opening row's height even when empty. */
47
+ reserveAvailability?: boolean;
48
+ }
49
+ export interface StylistCardProps {
50
+ ref: Ref<HTMLButtonElement>;
51
+ option: StylistOption;
52
+ selected: boolean;
53
+ tabIndex: number;
54
+ onClick: () => void;
55
+ labels: StylistScreenLabels;
56
+ className?: string;
57
+ selectedClassName?: string;
58
+ }
59
+ export interface StylistScreenComponents {
60
+ StylistCard?: ComponentType<StylistCardProps>;
61
+ }
62
+ export interface StylistScreenProps {
63
+ labels: StylistScreenLabels;
64
+ /** `format.clock.when` prints the next opening; `stylistName` / `initials` the names. */
65
+ format: BookingFormat;
66
+ /** «Now» for «today / tomorrow» in the next-opening line. Default `Date.now()`. */
67
+ now?: number;
68
+ /** Every service in the basket. A named stylist has to cover all of them. */
69
+ serviceIds: string[];
70
+ resources: BookingResourceDto[];
71
+ /** The machine's resource answer; `null` is «first available». */
72
+ selectedResourceId?: string | null;
73
+ /**
74
+ * The first opening per stylist, epoch milliseconds. A stylist with no entry
75
+ * gets no line: inventing a time, or printing an empty prefix, both promise
76
+ * more than is known.
77
+ */
78
+ nextAvailableTs?: Record<string, number | null>;
79
+ /** The next-opening answer is still on its way: the rows it will fill pulse instead of sitting blank. */
80
+ nextAvailableLoading?: boolean;
81
+ /**
82
+ * The stylist list itself has not arrived. Cards of the final height stand
83
+ * in for it, so what is below does not jump when it lands.
84
+ */
85
+ loading?: boolean;
86
+ /** How many placeholders to hold while `loading`. Default 5. */
87
+ skeletonCount?: number;
88
+ /**
89
+ * The name to show for a NAMED stylist restored before the list that can
90
+ * vouch for them has landed (a saved draft, a rebook link). `null` reads
91
+ * `stylist.pendingName`. Ignored once the list is here.
92
+ */
93
+ pendingName?: string | null;
94
+ /** A polite announcement once the list is here (e.g. the restored stylist is gone). */
95
+ notice?: string | null;
96
+ onPick: (resourceId: string | null) => void;
97
+ /**
98
+ * Present only for a family. One child gets no picker: «one after the
99
+ * other» is not a choice when there is nobody to be after.
100
+ */
101
+ party?: {
102
+ mode: PartyMode;
103
+ /** How many people, for the option labels. */
104
+ size: number;
105
+ /** How long the visit is in each mode. */
106
+ minutes: {
107
+ sequential: number;
108
+ parallel: number;
109
+ };
110
+ onMode: (mode: PartyMode) => void;
111
+ };
112
+ classNames?: SlotClassNames<StylistScreenSlot>;
113
+ components?: StylistScreenComponents;
114
+ }
115
+ export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
116
+ /** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
117
+ export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }: StylistCardProps): import("react").JSX.Element;
@@ -0,0 +1,226 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ import { fillLabel } from './labels.js';
6
+ import { LiveStatus } from './live-status.js';
7
+ import { slotClass } from './slots.js';
8
+ /**
9
+ * The stylist step — who would you like to see?
10
+ *
11
+ * Presentational, and one decision short of the machine's: `null` is a real
12
+ * answer here — «first available» — so the tap raises `onPick(null)` rather
13
+ * than raising nothing.
14
+ *
15
+ * ONE list of options at every width, restyled rather than swapped. Under `md`
16
+ * it is a single fixed-height row of avatars that scrolls sideways — full
17
+ * cards stacked above a time grid push everything a phone could tap below the
18
+ * fold — and from `md` up it is the column of cards. A second, phone-only list
19
+ * would be a second set of radios in the accessibility tree and a layout that
20
+ * could only be picked after hydration, i.e. a jump.
21
+ *
22
+ * A radiogroup, because that is what it is: exactly one answer, «first
23
+ * available» included. Arrow keys move and choose, Tab enters and leaves once.
24
+ */
25
+ export const STYLIST_SCREEN_LABEL_KEYS = [
26
+ 'stylist.heading',
27
+ 'stylist.firstAvailable',
28
+ 'stylist.firstAvailableSubtitle',
29
+ 'stylist.firstAvailableBadge',
30
+ 'stylist.pendingName',
31
+ 'stylist.nextAvailable',
32
+ 'stylist.loading',
33
+ 'stylist.party.legend',
34
+ 'stylist.party.sequential',
35
+ 'stylist.party.sequentialMinutes',
36
+ 'stylist.party.parallel.two',
37
+ 'stylist.party.parallel.three',
38
+ 'stylist.party.parallel.other',
39
+ 'stylist.party.parallelMinutes',
40
+ 'stylist.party.parallelNote.two',
41
+ 'stylist.party.parallelNote.three',
42
+ 'stylist.party.parallelNote.other',
43
+ ];
44
+ /**
45
+ * One height per breakpoint for every option, filled or not, so nothing on the
46
+ * step — nor what is under it — moves when a list or a line lands: a 128 px
47
+ * avatar tile on a phone, the 96 px card from `md` up.
48
+ */
49
+ const OPTION_HEIGHT = 'h-32 md:h-24';
50
+ const DEFAULT_SKELETON_COUNT = 5;
51
+ const HEADING_ID = 'booking-stylist-heading';
52
+ /** «Ada» from «Ada Demo»: a phone tile has room for one word. */
53
+ function firstName(displayName) {
54
+ return displayName.split(/\s+/u)[0] ?? displayName;
55
+ }
56
+ /** Sizes the label packs write out in words; any other size reads `.other`. */
57
+ const SIZE_WORDS = { 2: 'two', 3: 'three' };
58
+ /** The size-specific label: `.two`, `.three`, else `.other` with `{count}`. */
59
+ function bySize(labels, base, size) {
60
+ const suffix = SIZE_WORDS[size] ?? 'other';
61
+ return fillLabel(labels[`${base}.${suffix}`], { count: size });
62
+ }
63
+ export function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId = null, nextAvailableTs, nextAvailableLoading = false, loading = false, skeletonCount = DEFAULT_SKELETON_COUNT, pendingName = null, notice = null, onPick, party, classNames, components, }) {
64
+ // `every`, not `some`: the step asks one question for the whole visit, so a
65
+ // stylist who covers one of two services is not an answer — it is a refusal
66
+ // the engine issues at submit, offered here as a choice.
67
+ const qualified = resources
68
+ .filter((resource) => serviceIds.every((id) => resource.serviceIds.includes(id)))
69
+ .sort((a, b) => a.sortOrder - b.sortOrder);
70
+ const at = now ?? Date.now();
71
+ // A named answer restored before the list: held in the first slot after
72
+ // «first available», checked and tabbable, so the group does not claim
73
+ // «nobody chosen» — nor put the tab stop on the one answer the visitor did
74
+ // NOT give — for the second it takes the list to land.
75
+ const held = selectedResourceId === null
76
+ ? []
77
+ : [pendingOption(selectedResourceId, pendingName, labels, format)];
78
+ // First and pre-selected: most people do not mind who does it, and the
79
+ // business fills its gaps best when they do not.
80
+ const options = [
81
+ {
82
+ resourceId: null,
83
+ name: labels['stylist.firstAvailable'],
84
+ shortName: labels['stylist.firstAvailable'],
85
+ subtitle: labels['stylist.firstAvailableSubtitle'],
86
+ badge: labels['stylist.firstAvailableBadge'],
87
+ },
88
+ ...(loading
89
+ ? held
90
+ : qualified.map((resource) => {
91
+ const nextTs = nextAvailableTs?.[resource.id];
92
+ const name = format.stylistName(resource.name);
93
+ return {
94
+ resourceId: resource.id,
95
+ name,
96
+ shortName: firstName(name),
97
+ subtitle: resource.bio,
98
+ photoUrl: resource.photoUrl,
99
+ badge: format.initials(name),
100
+ availability: typeof nextTs === 'number' ? format.clock.when(nextTs, at) : null,
101
+ availabilityLoading: nextAvailableLoading && typeof nextTs !== 'number',
102
+ reserveAvailability: true,
103
+ };
104
+ })),
105
+ ];
106
+ return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['stylist.heading'] }), party && (_jsxs("fieldset", { className: "flex flex-col gap-2 sm:flex-row", children: [_jsx("legend", { className: "mb-2 font-sans text-lg font-bold", children: labels['stylist.party.legend'] }), _jsx(ModeCard, { selected: party.mode === 'sequential', onClick: () => party.onMode('sequential'), title: labels['stylist.party.sequential'], subtitle: fillLabel(labels['stylist.party.sequentialMinutes'], {
107
+ minutes: party.minutes.sequential,
108
+ }), classNames: classNames }), _jsx(ModeCard, { selected: party.mode === 'parallel', onClick: () => party.onMode('parallel'), title: bySize(labels, 'stylist.party.parallel', party.size), subtitle: fillLabel(labels['stylist.party.parallelMinutes'], {
109
+ minutes: party.minutes.parallel,
110
+ }), classNames: classNames })] })), party?.mode === 'parallel' ? (_jsx("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: bySize(labels, 'stylist.party.parallelNote', party.size) })) : (_jsx(StylistRadioGroup, { options: options, selectedResourceId: selectedResourceId,
111
+ // The held option takes one placeholder's place, so the row is
112
+ // exactly as long as it was and nothing after it moves.
113
+ skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labels['stylist.loading'] : notice })] }));
114
+ }
115
+ /**
116
+ * The options as one radiogroup with a roving tab stop.
117
+ *
118
+ * Arrow keys move AND choose, as a native radio group does — picking a
119
+ * stylist is cheap and reversible, and a group that needed Space after every
120
+ * arrow would be a list of buttons in a radiogroup's clothes.
121
+ */
122
+ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPick, labels, classNames, Card, }) {
123
+ const groupRef = useRef(null);
124
+ const optionRefs = useRef([]);
125
+ const selectedIndex = options.findIndex((option) => option.resourceId === selectedResourceId);
126
+ // A preference the list cannot show (the stylist does not cover this basket)
127
+ // still leaves the group one tab stop: the first option.
128
+ const tabStop = selectedIndex === -1 ? 0 : selectedIndex;
129
+ // On a phone the chosen stylist may sit past the right edge of the row —
130
+ // coming back to the step must show the answer already given. Only the row
131
+ // is scrolled, never the page: `scrollIntoView` would also move the
132
+ // viewport. The row is `relative`, so an option's `offsetLeft` is already
133
+ // measured from it.
134
+ useEffect(() => {
135
+ const group = groupRef.current;
136
+ const chosen = optionRefs.current[tabStop];
137
+ if (!group || !chosen || group.scrollWidth <= group.clientWidth)
138
+ return;
139
+ group.scrollLeft = Math.max(0, chosen.offsetLeft - 8);
140
+ }, [tabStop]);
141
+ function onKeyDown(event) {
142
+ const last = options.length - 1;
143
+ const from = optionRefs.current.indexOf(document.activeElement);
144
+ if (from === -1)
145
+ return;
146
+ let to;
147
+ switch (event.key) {
148
+ case 'ArrowRight':
149
+ case 'ArrowDown':
150
+ to = from === last ? 0 : from + 1;
151
+ break;
152
+ case 'ArrowLeft':
153
+ case 'ArrowUp':
154
+ to = from === 0 ? last : from - 1;
155
+ break;
156
+ case 'Home':
157
+ to = 0;
158
+ break;
159
+ case 'End':
160
+ to = last;
161
+ break;
162
+ default:
163
+ return;
164
+ }
165
+ event.preventDefault();
166
+ optionRefs.current[to]?.focus();
167
+ const target = options[to];
168
+ if (target)
169
+ onPick(target.resourceId);
170
+ }
171
+ return (_jsxs("div", { ref: groupRef, role: "radiogroup", "aria-labelledby": HEADING_ID, "aria-busy": busy, onKeyDown: onKeyDown, "data-testid": "stylist-options",
172
+ // Phone: one row of fixed height, scrolled sideways with snap; the
173
+ // padding leaves room for the selected avatar's ring, which the scroll
174
+ // box would otherwise clip. `md`: the column of cards.
175
+ className: slotClass(classNames, 'group', 'relative -mx-1 flex h-36 snap-x snap-mandatory scroll-px-1 gap-1 overflow-x-auto overscroll-x-contain px-1 py-2', 'md:mx-0 md:h-auto md:snap-none md:flex-col md:gap-2 md:overflow-visible md:p-0'), children: [options.map((option, index) => (_jsx(Card, { ref: (node) => {
176
+ optionRefs.current[index] = node;
177
+ }, option: option, selected: option.resourceId === selectedResourceId, tabIndex: index === tabStop ? 0 : -1, onClick: () => onPick(option.resourceId), labels: labels, className: classNames?.card, selectedClassName: classNames?.cardSelected }, option.resourceId ?? 'first-available'))), Array.from({ length: skeletons }, (_, index) => (_jsx(SkeletonCard, {}, index)))] }));
178
+ }
179
+ /** The restored stylist's stand-in: a real, checked radio of the final size,
180
+ * with its next-opening row pulsing like every card still waiting for one. */
181
+ function pendingOption(resourceId, pendingName, labels, format) {
182
+ const known = pendingName?.trim() ? pendingName.trim() : null;
183
+ const name = known ?? labels['stylist.pendingName'];
184
+ return {
185
+ resourceId,
186
+ name,
187
+ shortName: known ? firstName(name) : name,
188
+ badge: known ? format.initials(name) : '…',
189
+ availability: null,
190
+ availabilityLoading: true,
191
+ reserveAvailability: true,
192
+ };
193
+ }
194
+ /**
195
+ * One of the two answers to how a family is seen.
196
+ *
197
+ * Its own card rather than a stylist card: that one carries a photo or a set
198
+ * of initials, which a mode does not have, and a mode with an avatar reads as
199
+ * a third stylist.
200
+ */
201
+ function ModeCard({ selected, onClick, title, subtitle, classNames, }) {
202
+ return (_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onClick, className: cn('flex min-h-16 flex-1 flex-col justify-center rounded-lg border px-5 py-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
203
+ ? 'border-primary bg-primary/10 font-semibold ring-2 ring-primary'
204
+ : 'border-border bg-card hover:border-primary', classNames?.modeCard, selected && classNames?.modeCardSelected), children: [_jsx("span", { className: "font-medium", children: title }), _jsx("span", { className: "text-sm font-normal text-muted-foreground tabular-nums", children: subtitle })] }));
205
+ }
206
+ /** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
207
+ export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }) {
208
+ const id = useId();
209
+ const nameId = `${id}-name`;
210
+ const nextId = `${id}-next`;
211
+ const bioId = `${id}-bio`;
212
+ const { name, subtitle, badge, photoUrl, availability, availabilityLoading, reserveAvailability, } = option;
213
+ return (_jsxs("button", { ref: ref, type: "button", role: "radio", "aria-checked": selected, tabIndex: tabIndex, onClick: onClick, "aria-labelledby": availability ? `${nameId} ${nextId}` : nameId, "aria-describedby": subtitle ? bioId : undefined,
214
+ // Ring and weight alongside the tint: the selected option has to be
215
+ // obvious on a monochrome screen too. On a phone the ring is on the
216
+ // avatar; from `md` it is on the card.
217
+ className: cn('group flex w-24 shrink-0 snap-start flex-col items-center gap-1 rounded-lg px-1 pt-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', 'md:w-full md:shrink md:flex-row md:gap-4 md:border md:px-5 md:py-4 md:text-left', OPTION_HEIGHT, selected
218
+ ? 'font-semibold md:border-primary md:bg-primary/10 md:ring-2 md:ring-primary'
219
+ : 'md:border-border md:bg-card md:hover:border-primary', className, selected && selectedClassName), children: [_jsx("span", { "aria-hidden": "true", "data-testid": "stylist-avatar", className: cn('flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary/30 font-semibold md:size-12', selected
220
+ ? 'ring-2 ring-primary ring-offset-2 ring-offset-background md:ring-0 md:ring-offset-0'
221
+ : 'ring-1 ring-border md:ring-0'), children: photoUrl ? (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" })) : (badge) }), _jsxs("span", { className: "flex w-full min-w-0 flex-col md:w-auto", children: [_jsx("span", { "aria-hidden": "true", className: "h-5 truncate text-sm font-medium md:hidden", children: option.shortName }), _jsx("span", { id: nameId, className: "hidden truncate font-medium md:block", children: name }), subtitle && (_jsx("span", { id: bioId, className: "hidden truncate text-sm text-muted-foreground md:block", children: subtitle })), availability ? (_jsxs("span", { id: nextId, "data-testid": "next-available-row", className: "line-clamp-2 h-8 text-xs leading-4 text-foreground tabular-nums md:line-clamp-1 md:h-5 md:text-sm md:leading-5", children: [_jsx("span", { className: "sr-only md:not-sr-only", children: labels['stylist.nextAvailable'] }), ' ', availability] })) : (reserveAvailability && (_jsx("span", { "data-testid": "next-available-row", "aria-hidden": "true", className: "flex h-8 justify-center pt-1 md:block md:h-5 md:py-1", children: availabilityLoading && (_jsx("span", { className: "block h-3 w-16 animate-pulse rounded bg-muted md:w-36" })) })))] })] }));
222
+ }
223
+ /** A stylist option before there is a stylist: the same box, the same circle, at both widths. */
224
+ function SkeletonCard() {
225
+ return (_jsxs("div", { "aria-hidden": "true", "data-testid": "stylist-skeleton", className: cn('flex w-24 shrink-0 flex-col items-center gap-1 px-1 pt-1', 'md:w-full md:flex-row md:gap-4 md:rounded-lg md:border md:border-border md:bg-card md:px-5 md:py-4', OPTION_HEIGHT), children: [_jsx("span", { className: "size-14 shrink-0 animate-pulse rounded-full bg-muted md:size-12" }), _jsxs("span", { className: "flex w-full min-w-0 flex-col items-center gap-2 md:w-auto md:flex-1 md:items-start", children: [_jsx("span", { className: "h-4 w-14 animate-pulse rounded bg-muted md:w-28" }), _jsx("span", { className: "h-3 w-16 animate-pulse rounded bg-muted md:w-40" })] })] }));
226
+ }
@@ -0,0 +1,26 @@
1
+ import { type SlotClassNames } from './slots.js';
2
+ import type { WizardStep } from './types.js';
3
+ /**
4
+ * The sticky bar that accretes the visitor's choices, and the one button that
5
+ * leaves a step.
6
+ *
7
+ * It computes nothing: the sentence and whether the button is enabled come in
8
+ * precomputed from the caller's machine, so the bar cannot disagree with the
9
+ * wizard it summarises.
10
+ */
11
+ export declare const SUMMARY_BAR_LABEL_KEYS: readonly ["summary.placeholder.who", "summary.placeholder.service", "summary.next"];
12
+ export type SummaryBarLabels = Record<(typeof SUMMARY_BAR_LABEL_KEYS)[number], string>;
13
+ /** `root` the bar, `line` the summary sentence, `next` the button. */
14
+ export type SummaryBarSlot = 'root' | 'line' | 'next';
15
+ export interface SummaryBarProps {
16
+ /** The summary sentence, e.g. «Service · Stylist · today 15:00 · 490 kr». Empty = nothing chosen. */
17
+ line: string;
18
+ /** Whether the current step can be left. */
19
+ canAdvance: boolean;
20
+ /** The current step. The bar renders nothing on `details`, which has its own submit. */
21
+ step: WizardStep;
22
+ onNext: () => void;
23
+ labels: SummaryBarLabels;
24
+ classNames?: SlotClassNames<SummaryBarSlot>;
25
+ }
26
+ export declare function SummaryBar({ line, canAdvance, step, onNext, labels, classNames, }: SummaryBarProps): import("react").JSX.Element | null;
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BookingButton } from './internal/ui.js';
4
+ import { slotClass } from './slots.js';
5
+ /**
6
+ * The sticky bar that accretes the visitor's choices, and the one button that
7
+ * leaves a step.
8
+ *
9
+ * It computes nothing: the sentence and whether the button is enabled come in
10
+ * precomputed from the caller's machine, so the bar cannot disagree with the
11
+ * wizard it summarises.
12
+ */
13
+ export const SUMMARY_BAR_LABEL_KEYS = [
14
+ /** Shown on the first step while nothing is chosen. */
15
+ 'summary.placeholder.who',
16
+ /** Shown on later steps while nothing is chosen. */
17
+ 'summary.placeholder.service',
18
+ 'summary.next',
19
+ ];
20
+ export function SummaryBar({ line, canAdvance, step, onNext, labels, classNames, }) {
21
+ // Gone on the details step, not merely relabelled: that step's own button
22
+ // carries the price and the commitment, and two sticky submits stacked at the
23
+ // bottom of a phone is the outcome to avoid.
24
+ if (step === 'details')
25
+ return null;
26
+ // There from the first step at one fixed height, rather than appearing on
27
+ // the first tap and pushing the page down under the visitor's thumb.
28
+ return (_jsxs("div", { className: slotClass(classNames, 'root', 'sticky bottom-0 z-40 flex h-16 items-center justify-between gap-4 border-t border-border bg-background/95 px-5 backdrop-blur-md'), children: [_jsx("p", { className: slotClass(classNames, 'line', 'min-w-0 truncate text-sm font-medium', !line && 'text-muted-foreground'), children: line ||
29
+ (step === 'who'
30
+ ? labels['summary.placeholder.who']
31
+ : labels['summary.placeholder.service']) }), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'next'), onClick: onNext, disabled: !canAdvance, children: labels['summary.next'] })] }));
32
+ }
@@ -0,0 +1,191 @@
1
+ import { type ComponentType } from 'react';
2
+ import type { BookingDaypart, BookingFormat } from './format.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ import type { BookingDayDto, BookingSlotDto, PartySlot, WizardItem } from './types.js';
5
+ /**
6
+ * The time step — «when suits you?».
7
+ *
8
+ * Presentational, with one piece of state the booking machine deliberately does
9
+ * not hold: which day the visitor is *looking at*. Nothing about it is
10
+ * submitted and it survives no further than this screen, so putting it in the
11
+ * machine would make the wizard remember a scroll position. What gets booked
12
+ * still leaves here as `onPick(slot)`.
13
+ */
14
+ export declare const TIME_SCREEN_LABEL_KEYS: readonly ["time.heading", "time.dayStrip.legend", "time.nearest", "time.current", "time.nextFree", "time.shortNotice", "time.weekend.pct", "time.weekend.mixed", "time.month.show", "time.month.hide", "time.month.previous", "time.month.next", "time.month.heading", "time.month.note", "time.party.sequential", "time.party.parallel", "time.party.seat", "time.party.seatSeparator", "time.parallel.offer.two", "time.parallel.offer.other", "time.parallel.accept", "time.empty.closed", "time.empty.over", "time.empty.full", "time.empty.stylist", "time.empty.unknown", "time.empty.callLink", "time.empty.callLinkAria", "time.empty.callSuffix", "time.empty.callPlain", "time.loading", "time.skeleton.dayPlaceholder", "time.taken.message", "time.taken.close"];
15
+ /**
16
+ * Copy for the time step. Placeholders:
17
+ * - `time.current` `{time}`; `time.weekend.pct` `{pct}` `{price}`; `time.weekend.mixed` `{price}`
18
+ * (keep a non-breaking space inside «+10 %» and the price so neither wraps)
19
+ * - `time.month.heading` `{month}` `{year}`
20
+ * - `time.party.sequential` `{start}` `{end}`; `time.party.parallel` `{time}` `{seats}`;
21
+ * `time.party.seat` `{who}` `{stylist}`
22
+ * - `time.parallel.offer.*` `{day}` `{time}` (`two` for two people, `other` for more);
23
+ * `time.parallel.accept` `{time}`
24
+ * - `time.empty.closed|over|full|unknown` `{day}`; `time.empty.stylist` `{stylist}` `{day}`.
25
+ * `full`, `stylist` and `unknown` are the lead of a sentence the phone line
26
+ * finishes (end them with the dash); `closed` and `over` are whole sentences.
27
+ * - `time.empty.callLink|callLinkAria` `{phone}`
28
+ */
29
+ export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], string>;
30
+ /**
31
+ * `classNames` slots:
32
+ * - `root` the section · `heading` the h2
33
+ * - `dayStrip` the fieldset of day chips · `dayChip` / `dayChipSelected` one day chip
34
+ * - `month` the month-view wrapper · `monthCell` / `monthCellSelected` a tappable date
35
+ * - `surcharge` the weekend-note row
36
+ * - `daypart` one daypart section · `daypartHeading` its h3
37
+ * - `chip` a time chip · `chipNearest` a chip marked nearest · `chipCurrent` the current-hour chip
38
+ * - `parallel` the «both at once» offer · `empty` the nothing-free card
39
+ * - `nextFree` the «next free time» link-button · `shortNotice` the short-notice line
40
+ */
41
+ export type TimeScreenSlot = 'root' | 'heading' | 'dayStrip' | 'dayChip' | 'dayChipSelected' | 'month' | 'monthCell' | 'monthCellSelected' | 'surcharge' | 'daypart' | 'daypartHeading' | 'chip' | 'chipNearest' | 'chipCurrent' | 'parallel' | 'empty' | 'nextFree' | 'shortNotice';
42
+ export interface DayChipProps {
43
+ /** An instant inside the day. */
44
+ dayTs: number;
45
+ /** «Today», «Thu 3». */
46
+ label: string;
47
+ selected: boolean;
48
+ /** The resolved classes (base + selected + slot overrides). */
49
+ className: string;
50
+ onSelect: () => void;
51
+ }
52
+ /** One chip of the day strip: an `aria-pressed` outline button. */
53
+ export declare function DefaultDayChip({ label, selected, className, onSelect }: DayChipProps): import("react").JSX.Element;
54
+ export interface TimeChipProps {
55
+ startTs: number;
56
+ /** «14:30», a party's «15:00 → 16:00», or the current hour's «15:00 · Current time». */
57
+ label: string;
58
+ /** One of the nearest alternatives to a slot that was just taken. */
59
+ nearest: boolean;
60
+ /** The screen-reader text that says so. */
61
+ nearestLabel: string;
62
+ /** The hour the visitor already holds: drawn, disabled, `aria-current`. */
63
+ current: boolean;
64
+ /** The resolved classes (base + state + slot overrides). */
65
+ className: string;
66
+ onPick: () => void;
67
+ }
68
+ /** One time chip. */
69
+ export declare function DefaultTimeChip({ label, nearest, nearestLabel, current, className, onPick, }: TimeChipProps): import("react").JSX.Element;
70
+ export interface TimeScreenComponents {
71
+ DayChip?: ComponentType<DayChipProps>;
72
+ TimeChip?: ComponentType<TimeChipProps>;
73
+ }
74
+ /** What the weekend note says for one day. `pct: null` = the basket carries more than one rate. */
75
+ export interface WeekendNote {
76
+ pct: number | null;
77
+ /** The basket's total priced on the day asked about, in minor units. */
78
+ priceOre: number;
79
+ }
80
+ /** A family, whose slots are whole visits rather than single openings. */
81
+ export interface TimeScreenParty {
82
+ /** The basket, for the chip label — who is being served, and the family size. */
83
+ items: WizardItem[];
84
+ mode: 'sequential' | 'parallel';
85
+ /** Whole visits in the mode the visitor chose. */
86
+ slots: PartySlot[];
87
+ /** When a back-to-back visit starting at `slot.startTs` ends (the sequential chip's «→ 16:00»). */
88
+ chipEndTs: (slot: PartySlot) => number;
89
+ /**
90
+ * The rescue for a `sequential` family whose day on screen has no
91
+ * back-to-back opening: a simultaneous visit that day, or `null`. Asked about
92
+ * the day actually on screen, only in `sequential` mode.
93
+ */
94
+ alternativeFor?: (dayTs: number) => PartySlot | null;
95
+ /** The machine holds stylist ids; the parallel chip names people. */
96
+ resolveStylistName: (resourceId: string) => string | null;
97
+ onPick: (slot: PartySlot) => void;
98
+ }
99
+ export interface TimeScreenProps {
100
+ labels: TimeScreenLabels;
101
+ format: BookingFormat;
102
+ /** Dayparts in display order; `format.clock.daypartOf` returns their keys. */
103
+ dayparts: readonly BookingDaypart[];
104
+ /** Free slots, any number of days' worth. */
105
+ slots: BookingSlotDto[];
106
+ /**
107
+ * The days availability was asked for, as an instant inside each. Without it
108
+ * the strip can only be the days that have slots — and a booked-out day has
109
+ * none, so it could never be shown.
110
+ */
111
+ days?: number[];
112
+ /**
113
+ * The business's open dates over the same window, or `null` when they could
114
+ * not be read (NOT the same as `[]`, which says it keeps no hours at all).
115
+ * A date missing from the list is one it keeps no hours on. This is what
116
+ * separates «closed», «too late today» and «full» — free slots alone cannot.
117
+ */
118
+ openDays?: readonly BookingDayDto[] | null;
119
+ /**
120
+ * The stylist the visitor asked for by name. Holds the empty-day card back
121
+ * from claiming «full»: the hours are the business's, so an open day with no
122
+ * slots may just be a day that stylist does not work.
123
+ */
124
+ stylistName?: string | null;
125
+ /** The instant that was taken while the visitor was typing, if one was. */
126
+ takenSlotTs?: number | null;
127
+ /** `null` renders every phone sentence without a link. */
128
+ phone?: string | null;
129
+ /**
130
+ * Offer «show the whole month» under the day strip. Every cell is coloured
131
+ * from the same days the strip is built from; nothing outside them is tappable.
132
+ */
133
+ monthView?: boolean;
134
+ /**
135
+ * The weekend note for a day: `null` when the basket never pays a surcharge,
136
+ * otherwise its rate and the basket total priced on `dayTs` (asked about
137
+ * weekdays too — the row is kept, empty, so a day tap cannot move the grid).
138
+ */
139
+ weekendNote?: (dayTs: number) => WeekendNote | null;
140
+ /**
141
+ * The hour the visitor already holds, when moving an appointment. Drawn among
142
+ * the chips, marked and not tappable; availability can never return it.
143
+ */
144
+ currentSlotTs?: number | null;
145
+ onPick: (slot: BookingSlotDto) => void;
146
+ /** A family. Present, its slots replace `slots` entirely. */
147
+ party?: TimeScreenParty;
148
+ /** «Now», for today / tomorrow and whether today is over. Defaults to `Date.now()`. */
149
+ now?: number;
150
+ classNames?: SlotClassNames<TimeScreenSlot>;
151
+ components?: TimeScreenComponents;
152
+ }
153
+ /**
154
+ * Where one day stands with the business — the question an empty day cannot
155
+ * answer on its own.
156
+ * - `unknown` — the hours could not be read.
157
+ * - `closed` — no hours on the date, or hours posted and shut anyway (holiday).
158
+ * - `over` — open, but the last start this visit could take has passed.
159
+ * - `open` — open, with a start still ahead.
160
+ */
161
+ export type DayStanding = 'unknown' | 'closed' | 'over' | 'open';
162
+ export declare function TimeScreen({ labels, format, dayparts, slots, days, openDays, stylistName, takenSlotTs, phone, weekendNote, currentSlotTs, monthView, onPick, party, now: nowProp, classNames, components, }: TimeScreenProps): import("react").JSX.Element;
163
+ export interface TakenToastProps {
164
+ labels: Pick<TimeScreenLabels, 'time.taken.message' | 'time.taken.close'>;
165
+ /** The instant that was just lost; each new one is a fresh toast. */
166
+ takenSlotTs: number | null;
167
+ className?: string;
168
+ }
169
+ /**
170
+ * «That one was just taken», as a toast over the page. Fixed, so it moves
171
+ * nothing; the ringed chips carry the message once it has gone. Render it ONCE,
172
+ * outside any skeleton-or-step switch, so a re-read of the openings cannot
173
+ * remount it. The live region is mounted empty and filled after mount.
174
+ */
175
+ export declare function TakenToast({ labels, takenSlotTs, className }: TakenToastProps): import("react").JSX.Element;
176
+ export interface TimeScreenSkeletonProps {
177
+ labels: Pick<TimeScreenLabels, 'time.heading' | 'time.loading' | 'time.skeleton.dayPlaceholder'>;
178
+ /** How many day chips to hold. */
179
+ days?: number;
180
+ /** Hold the «show the whole month» row too, when the real step will draw it. */
181
+ monthView?: boolean;
182
+ /** Hold the weekend-surcharge row, when the basket pays one — the real step keeps it on every day. */
183
+ surchargeRow?: boolean;
184
+ className?: string;
185
+ }
186
+ /**
187
+ * The time step before its openings are in: the same heading, a strip of day
188
+ * chips and a daypart of time chips, each built from the real chip's own button
189
+ * classes so every box is the same size.
190
+ */
191
+ export declare function TimeScreenSkeleton({ labels, days, monthView, surchargeRow, className, }: TimeScreenSkeletonProps): import("react").JSX.Element;