@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,63 @@
1
+ import { type ComponentType } from 'react';
2
+ import type { BookingClock, BookingFormat } from '../format.js';
3
+ import { type SlotClassNames } from '../slots.js';
4
+ import type { PortalBookingDto } from '../types.js';
5
+ export declare const VISIT_HISTORY_LABEL_KEYS: readonly ["history.heading", "history.empty", "history.serviceFallback", "history.count.one", "history.count.other", "history.unknownAmount"];
6
+ /** `history.count.*` take `{count}` and `{year}`. */
7
+ export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number], string>;
8
+ /**
9
+ * - `root`: the section. - `heading`. - `empty`: the no-visits box.
10
+ * - `yearChip` / `yearChipSelected`: the year switcher's buttons.
11
+ * - `list`. - `row`: one visit. - `total`: the year's summary bar.
12
+ */
13
+ export type VisitHistorySlot = 'root' | 'heading' | 'empty' | 'yearChip' | 'yearChipSelected' | 'list' | 'row' | 'total';
14
+ /**
15
+ * One year of completed visits, newest first. `totalOre` is `null` unless
16
+ * EVERY visit that year carried a price — «we do not know», not «nothing».
17
+ *
18
+ * (Declared here: the shared `VisitYear` in `types.ts` carries `bookings` but
19
+ * no total.)
20
+ */
21
+ export interface VisitHistoryYear {
22
+ year: number;
23
+ visits: PortalBookingDto[];
24
+ totalOre: number | null;
25
+ }
26
+ /**
27
+ * The visits behind the history section, grouped by the year the BUSINESS
28
+ * says they happened in (a visit at 00:30 on 1 January belongs to the year the
29
+ * business's calendar says, not the reader's).
30
+ *
31
+ * Completed only: a cancelled appointment is not a visit that happened, and
32
+ * adding it to a year's total would bill the user for a chair nobody sat in.
33
+ */
34
+ export declare function visitHistoryYears(past: readonly PortalBookingDto[], clock: Pick<BookingClock, 'dayKey'>): VisitHistoryYear[];
35
+ export interface VisitRowProps {
36
+ visit: PortalBookingDto;
37
+ labels: VisitHistoryLabels;
38
+ format: BookingFormat;
39
+ classNames?: SlotClassNames<VisitHistorySlot>;
40
+ }
41
+ export interface VisitHistoryComponents {
42
+ VisitRow?: ComponentType<VisitRowProps>;
43
+ }
44
+ export interface VisitHistoryProps {
45
+ /** Past bookings in any order and status; only completed ones are shown. */
46
+ past: PortalBookingDto[];
47
+ labels: VisitHistoryLabels;
48
+ format: BookingFormat;
49
+ /** Precomputed years; default `visitHistoryYears(past, format.clock)`. */
50
+ years?: VisitHistoryYear[];
51
+ /** `id` of the heading. Default `portal-history-heading`. */
52
+ headingId?: string;
53
+ classNames?: SlotClassNames<VisitHistorySlot>;
54
+ components?: VisitHistoryComponents;
55
+ }
56
+ /**
57
+ * «History» — every visit behind this user, by year, with the year's count
58
+ * and total. No receipt link: a button that downloaded nothing would be worse
59
+ * than the honest absence; the row still carries what a receipt would say.
60
+ */
61
+ export declare function VisitHistory({ past, labels, format, years: yearsProp, headingId, classNames, components, }: VisitHistoryProps): import("react").JSX.Element;
62
+ /** One visit (an `<li>`): what, then when · who for · who with, and the price. */
63
+ export declare function DefaultVisitRow({ visit, labels, format, classNames }: VisitRowProps): import("react").JSX.Element;
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { cn } from '../../lib/utils.js';
5
+ import { fillLabel } from '../labels.js';
6
+ import { slotClass } from '../slots.js';
7
+ export const VISIT_HISTORY_LABEL_KEYS = [
8
+ 'history.heading',
9
+ 'history.empty',
10
+ 'history.serviceFallback',
11
+ 'history.count.one',
12
+ 'history.count.other',
13
+ 'history.unknownAmount',
14
+ ];
15
+ /**
16
+ * The visits behind the history section, grouped by the year the BUSINESS
17
+ * says they happened in (a visit at 00:30 on 1 January belongs to the year the
18
+ * business's calendar says, not the reader's).
19
+ *
20
+ * Completed only: a cancelled appointment is not a visit that happened, and
21
+ * adding it to a year's total would bill the user for a chair nobody sat in.
22
+ */
23
+ export function visitHistoryYears(past, clock) {
24
+ const byYear = new Map();
25
+ for (const booking of past) {
26
+ if (booking.status !== 'completed')
27
+ continue;
28
+ const year = Number(clock.dayKey(booking.startTs).slice(0, 4));
29
+ const bucket = byYear.get(year);
30
+ if (bucket === undefined)
31
+ byYear.set(year, [booking]);
32
+ else
33
+ bucket.push(booking);
34
+ }
35
+ return [...byYear.entries()]
36
+ .sort(([a], [b]) => b - a)
37
+ .map(([year, visits]) => {
38
+ // ALL or nothing: summing only the priced rows would show a total smaller
39
+ // than what was actually paid, labelled as the year's spending.
40
+ const unknown = visits.some((visit) => visit.amountOre === null);
41
+ return {
42
+ year,
43
+ visits: visits.sort((a, b) => b.startTs - a.startTs),
44
+ totalOre: unknown ? null : visits.reduce((sum, visit) => sum + (visit.amountOre ?? 0), 0),
45
+ };
46
+ });
47
+ }
48
+ /**
49
+ * «History» — every visit behind this user, by year, with the year's count
50
+ * and total. No receipt link: a button that downloaded nothing would be worse
51
+ * than the honest absence; the row still carries what a receipt would say.
52
+ */
53
+ export function VisitHistory({ past, labels, format, years: yearsProp, headingId = 'portal-history-heading', classNames, components, }) {
54
+ const years = yearsProp ?? visitHistoryYears(past, format.clock);
55
+ const [selected, setSelected] = useState(null);
56
+ const shown = years.find((year) => year.year === selected) ?? years[0];
57
+ const Row = components?.VisitRow ?? DefaultVisitRow;
58
+ return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['history.heading'] }), shown === undefined ? (_jsx("p", { className: slotClass(classNames, 'empty', 'rounded-lg border border-border bg-card px-5 py-4 text-muted-foreground'), children: labels['history.empty'] })) : (_jsxs(_Fragment, { children: [years.length > 1 && (_jsx("div", { className: "flex flex-wrap gap-2", children: years.map((year) => {
59
+ const pressed = year.year === shown.year;
60
+ return (_jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => setSelected(year.year), className: cn(slotClass(classNames, 'yearChip', 'rounded-full border px-4 py-1.5 text-sm font-semibold transition-colors', pressed
61
+ ? 'border-foreground bg-foreground text-background'
62
+ : 'border-border hover:bg-muted'), pressed && classNames?.yearChipSelected), children: year.year }, year.year));
63
+ }) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children: fillLabel(labels[shown.visits.length === 1 ? 'history.count.one' : 'history.count.other'], { count: shown.visits.length, year: shown.year }) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: shown.totalOre === null
64
+ ? labels['history.unknownAmount']
65
+ : format.price(shown.totalOre) })] })] }))] }));
66
+ }
67
+ /** One visit (an `<li>`): what, then when · who for · who with, and the price. */
68
+ export function DefaultVisitRow({ visit, labels, format, classNames }) {
69
+ const detail = [format.clock.date(visit.startTs), visit.bookedForName, visit.resourceName]
70
+ .filter(Boolean)
71
+ .join(' · ');
72
+ return (_jsxs("li", { className: slotClass(classNames, 'row', 'flex flex-wrap items-baseline gap-x-4 gap-y-1 py-4'), children: [_jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block font-semibold", children: visit.serviceName ?? labels['history.serviceFallback'] }), _jsx("span", { className: "block text-sm text-muted-foreground", children: detail })] }), _jsx("span", { className: "font-bold tabular-nums", children: visit.amountOre === null ? labels['history.unknownAmount'] : format.price(visit.amountOre) })] }));
73
+ }
@@ -0,0 +1,137 @@
1
+ import { type ComponentType } from 'react';
2
+ import type { BookingFormat } from './format.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ import type { BookingServiceDto } from './types.js';
5
+ /**
6
+ * The service step — what should be done?
7
+ *
8
+ * Presentational. A tap raises `onPick` and the caller's machine decides what
9
+ * that means; nothing here knows which step comes next or whether the basket
10
+ * already holds a child.
11
+ */
12
+ export declare const SERVICE_SCREEN_LABEL_KEYS: readonly ["service.heading", "service.categoriesLegend", "service.sameAsLast", "service.chooseOther", "service.duration", "service.phoneOnly", "service.phoneOnlyLinkLabel", "service.ageDivider.named", "service.ageDivider.unnamed", "service.party.listLabel", "service.party.adultAlone", "service.party.nothingFits", "service.party.remove"];
13
+ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], string>;
14
+ /**
15
+ * - `root` — the `<section>`
16
+ * - `heading` — the `<h2>`
17
+ * - `pill` / `pillSelected` — a category pill / the marked one
18
+ * - `groupHeading` — a category's (or a party member's) `<h3>`
19
+ * - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
20
+ */
21
+ export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected';
22
+ /** One service group, in display order. */
23
+ export interface ServiceScreenCategory {
24
+ key: string;
25
+ /** The pill's and the section heading's text. */
26
+ label: string;
27
+ }
28
+ /** A person's «same as last time», and the one-line note when it was swapped (e.g. for age). */
29
+ export interface ServiceSuggestion {
30
+ service: BookingServiceDto;
31
+ note?: string;
32
+ }
33
+ /** One chair on the step for a family — who, as the section heading names them. */
34
+ export interface ServicePartyPerson {
35
+ key: string;
36
+ /** The section heading, e.g. «Theo · 7 years», «Child 2», «Myself». */
37
+ label: string;
38
+ adult: boolean;
39
+ suggestion?: ServiceSuggestion | null;
40
+ /** The child's own name, for the age divider; absent for an anonymous seat. */
41
+ name?: string;
42
+ }
43
+ export interface ServiceCardProps {
44
+ service: BookingServiceDto;
45
+ /**
46
+ * `book` — a one-tap card that answers the step;
47
+ * `choice` — one person's pressed/unpressed choice in a family;
48
+ * `phone` — not bookable online: no button, a number to call instead.
49
+ */
50
+ kind: 'book' | 'choice' | 'phone';
51
+ selected: boolean;
52
+ onPick: () => void;
53
+ /** The number for `phone` cards; `null` renders the sentence without a link. */
54
+ phone: string | null;
55
+ format: BookingFormat;
56
+ labels: ServiceScreenLabels;
57
+ className?: string;
58
+ selectedClassName?: string;
59
+ }
60
+ export interface ServiceScreenComponents {
61
+ ServiceCard?: ComponentType<ServiceCardProps>;
62
+ }
63
+ export interface ServiceScreenProps {
64
+ labels: ServiceScreenLabels;
65
+ format: BookingFormat;
66
+ /** The full catalogue, in the order the source returned it. */
67
+ services: BookingServiceDto[];
68
+ /** The groups, in display order. Only groups with services are drawn. */
69
+ categories: ServiceScreenCategory[];
70
+ /**
71
+ * The group a service is filed under. Default: its `category` when that is
72
+ * one of `categories`, else the LAST category — a service from a category
73
+ * nobody planned for is filed at the end rather than dropped off the page.
74
+ */
75
+ categoryOf?: (service: BookingServiceDto) => string;
76
+ /**
77
+ * For a family: the category that is the children's menu. A child is
78
+ * offered only it, an adult everything else. Absent: no split.
79
+ */
80
+ childCategory?: string;
81
+ /**
82
+ * Whether a bookable service suits the person at `personIndex` (0 for a
83
+ * party of one) — typically their age on the day against the service's age
84
+ * band. `false` moves it below a divider: suggested against, never refused.
85
+ * Default: everything fits.
86
+ */
87
+ serviceFits?: (service: BookingServiceDto, personIndex: number) => boolean;
88
+ /**
89
+ * The business's number, for the services that cannot be booked online.
90
+ * `null` renders the same sentence without a link: a `tel:` that dials
91
+ * nothing looks like an offer and fails in the visitor's hand.
92
+ */
93
+ phone?: string | null;
94
+ /** The category pill to open on (e.g. from a link). Unknown or empty categories are ignored. */
95
+ initialCategory?: string | null;
96
+ onPick: (service: BookingServiceDto) => void;
97
+ /**
98
+ * A FAMILY: the people the first step seated, when there is more than one,
99
+ * each answered on their own. Absent or a single person, the step is the
100
+ * one-tap catalogue and `onPick` is the answer.
101
+ */
102
+ party?: {
103
+ people: ReadonlyArray<ServicePartyPerson>;
104
+ /** `choices[i]` is `people[i]`'s service so far, `null` for none yet. */
105
+ choices: ReadonlyArray<{
106
+ id: string;
107
+ } | null>;
108
+ onPickFor: (index: number, service: BookingServiceDto) => void;
109
+ /** Take one person out of the party — the way on when nothing fits them in a party this size. */
110
+ onRemove?: (index: number) => void;
111
+ };
112
+ /** «Same as last time» for a party of one, offered first. The catalogue below stays whole. */
113
+ suggestion?: ServiceSuggestion | null;
114
+ /** The service already chosen for a party of one. */
115
+ chosenId?: string | null;
116
+ /** The child's name for a party of one, for the age divider. */
117
+ childName?: string | null;
118
+ /**
119
+ * The possessive of a name for the age divider's `{nameGenitive}` — the
120
+ * rule is the language's (e.g. «Theos» / «Jonas'»). Default: the name as is.
121
+ */
122
+ possessive?: (name: string) => string;
123
+ classNames?: SlotClassNames<ServiceScreenSlot>;
124
+ components?: ServiceScreenComponents;
125
+ }
126
+ export declare function ServiceScreen(props: ServiceScreenProps): import("react").JSX.Element;
127
+ /**
128
+ * The default service card.
129
+ *
130
+ * - `book`: the whole card is the target — on a phone, anything smaller is a
131
+ * missed tap.
132
+ * - `choice`: pressed rather than advancing.
133
+ * - `phone`: not a button, deliberately. The business does not take this one
134
+ * online, and a tap target that accepts the tap and does nothing is the dead
135
+ * end the phone number replaces. The card stays so the catalogue is whole.
136
+ */
137
+ export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }: ServiceCardProps): import("react").JSX.Element;
@@ -0,0 +1,233 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ import { BookingButton } from './internal/ui.js';
6
+ import { fillLabel } from './labels.js';
7
+ import { slotClass } from './slots.js';
8
+ /**
9
+ * The service step — what should be done?
10
+ *
11
+ * Presentational. A tap raises `onPick` and the caller's machine decides what
12
+ * that means; nothing here knows which step comes next or whether the basket
13
+ * already holds a child.
14
+ */
15
+ export const SERVICE_SCREEN_LABEL_KEYS = [
16
+ 'service.heading',
17
+ 'service.categoriesLegend',
18
+ 'service.sameAsLast',
19
+ 'service.chooseOther',
20
+ 'service.duration',
21
+ 'service.phoneOnly',
22
+ 'service.phoneOnlyLinkLabel',
23
+ 'service.ageDivider.named',
24
+ 'service.ageDivider.unnamed',
25
+ 'service.party.listLabel',
26
+ 'service.party.adultAlone',
27
+ 'service.party.nothingFits',
28
+ 'service.party.remove',
29
+ ];
30
+ const HEADING_ID = 'booking-service-heading';
31
+ function groupHeadingId(category) {
32
+ return `booking-services-${category}`;
33
+ }
34
+ const allFit = () => true;
35
+ const asIs = (name) => name;
36
+ /** The age divider's sentence for `name`. */
37
+ function ageDividerLabel(labels, name, possessive) {
38
+ if (!name)
39
+ return labels['service.ageDivider.unnamed'];
40
+ return fillLabel(labels['service.ageDivider.named'], {
41
+ name,
42
+ nameGenitive: possessive(name),
43
+ });
44
+ }
45
+ /** Split a menu into what fits and what usually does not. Phone-only cards always stay put. */
46
+ function byFit(services, fits) {
47
+ const fitting = [];
48
+ const unlikely = [];
49
+ for (const service of services) {
50
+ (!service.bookableOnline || fits(service) ? fitting : unlikely).push(service);
51
+ }
52
+ return { fitting, unlikely };
53
+ }
54
+ function cardProps(shared) {
55
+ return {
56
+ format: shared.format,
57
+ labels: shared.labels,
58
+ className: shared.classNames?.card,
59
+ selectedClassName: shared.classNames?.cardSelected,
60
+ };
61
+ }
62
+ export function ServiceScreen(props) {
63
+ const shared = {
64
+ labels: props.labels,
65
+ format: props.format,
66
+ classNames: props.classNames,
67
+ Card: props.components?.ServiceCard ?? DefaultServiceCard,
68
+ possessive: props.possessive ?? asIs,
69
+ };
70
+ if (props.party && props.party.people.length > 1) {
71
+ return _jsx(PartyServiceScreen, { ...props, party: props.party, shared: shared });
72
+ }
73
+ return _jsx(SingleServiceScreen, { ...props, shared: shared });
74
+ }
75
+ function Heading({ shared }) {
76
+ return (_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(shared.classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: shared.labels['service.heading'] }));
77
+ }
78
+ /**
79
+ * Below the ordinary list: what usually does not suit this person, still one
80
+ * tap away. A labelled list, so a screen reader hears the divider as the name
81
+ * of what follows rather than as a stray sentence.
82
+ */
83
+ function UnlikelyForAge({ id, name, services, shared, children, }) {
84
+ if (services.length === 0)
85
+ return null;
86
+ return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center gap-3 pt-2", children: [_jsx("span", { "aria-hidden": "true", className: "h-px flex-1 bg-border" }), _jsx("p", { id: id, className: "text-sm text-muted-foreground", children: ageDividerLabel(shared.labels, name, shared.possessive) }), _jsx("span", { "aria-hidden": "true", className: "h-px flex-1 bg-border" })] }), _jsx("ul", { "aria-labelledby": id, className: "space-y-2", children: services.map((service) => (_jsx("li", { children: children(service) }, service.id))) })] }));
87
+ }
88
+ function SingleServiceScreen({ services, categories, categoryOf, serviceFits = allFit, phone = null, initialCategory = null, onPick, suggestion = null, chosenId = null, childName = null, shared, }) {
89
+ const { Card, labels, classNames } = shared;
90
+ const fileUnder = categoryOf ?? defaultCategoryOf(categories);
91
+ // What certainly does not suit this person moves below a divider, still
92
+ // bookable. Telephone-only cards stay where they are: the list stays whole.
93
+ const { fitting: offered, unlikely } = byFit(services, (service) => serviceFits(service, 0));
94
+ const groups = categories
95
+ .map((category) => ({
96
+ category,
97
+ items: offered.filter((service) => fileUnder(service) === category.key),
98
+ }))
99
+ .filter((group) => group.items.length > 0);
100
+ // Null until the visitor taps a pill, then read back through the groups that
101
+ // exist — seeding it with the first category's key instead would leave a
102
+ // catalogue without that group pointing at a section that is not on the
103
+ // page. An `initialCategory` seeds it (in the initialiser, so the server
104
+ // render already marks the right pill) when that group exists.
105
+ const [tapped, setTapped] = useState(() => initialCategory !== null && groups.some((group) => group.category.key === initialCategory)
106
+ ? initialCategory
107
+ : null);
108
+ const current = tapped ?? groups[0]?.category.key ?? null;
109
+ function jumpTo(category) {
110
+ setTapped(category);
111
+ // The pills mark where you are in the list; they never shorten it.
112
+ // Filtering would hide most of the catalogue behind a default nobody
113
+ // chose. `scrollIntoView` is absent in jsdom and older browsers; the tap
114
+ // still marks the pill, which is the half that matters.
115
+ document.getElementById(groupHeadingId(category))?.scrollIntoView?.({
116
+ behavior: 'smooth',
117
+ block: 'start',
118
+ });
119
+ }
120
+ return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared }), suggestion && (_jsxs("div", { className: "space-y-2", children: [_jsx(SameAsLast, { suggestion: suggestion, selected: chosenId === suggestion.service.id, onPick: () => onPick(suggestion.service), shared: shared }), _jsx("p", { className: "text-sm font-semibold text-muted-foreground", children: labels['service.chooseOther'] })] })), groups.length > 1 && (_jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['service.categoriesLegend'] }), groups.map(({ category }) => {
121
+ const marked = category.key === current;
122
+ return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": marked, onClick: () => jumpTo(category.key),
123
+ // Weight and ring carry the selection alongside the colour, so
124
+ // it survives a monochrome screen and a colour-blind visitor.
125
+ className: cn('rounded-full', marked
126
+ ? 'font-bold ring-2 ring-primary bg-primary/10 border-primary text-foreground'
127
+ : 'font-normal', classNames?.pill, marked && classNames?.pillSelected), children: category.label }, category.key));
128
+ })] })), groups.map(({ category, items }) => (_jsxs("section", { "aria-labelledby": groupHeadingId(category.key),
129
+ // Clears a sticky header when a pill jumps here.
130
+ className: "scroll-mt-24 space-y-3", children: [_jsx("h3", { id: groupHeadingId(category.key), className: slotClass(classNames, 'groupHeading', 'font-sans text-lg font-bold text-muted-foreground'), children: category.label }), _jsx("ul", { className: "space-y-2", children: items.map((service) => (_jsx("li", { children: _jsx(Card, { service: service, kind: service.bookableOnline ? 'book' : 'phone', selected: false, onPick: () => onPick(service), phone: phone, ...cardProps(shared) }) }, service.id))) })] }, category.key))), _jsx(UnlikelyForAge, { id: "booking-services-unlikely", name: childName, services: unlikely, shared: shared, children: (service) => (_jsx(Card, { service: service, kind: "book", selected: false, onPick: () => onPick(service), phone: phone, ...cardProps(shared) })) })] }));
131
+ }
132
+ function defaultCategoryOf(categories) {
133
+ const known = new Set(categories.map((category) => category.key));
134
+ const fallback = categories[categories.length - 1]?.key ?? '';
135
+ return (service) => (known.has(service.category) ? service.category : fallback);
136
+ }
137
+ /**
138
+ * The step for a family: one short list per person, each answered on its own.
139
+ *
140
+ * What a person in a party can have is what the business takes online and —
141
+ * like the booking engine — nothing whose `maxPerBooking` is below the party's
142
+ * size: the strictest service in a request sets the limit for all of it. With
143
+ * a `childCategory`, a child gets that menu and an adult everything else,
144
+ * which is often nothing, and is told so.
145
+ */
146
+ function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, shared, }) {
147
+ const { Card, labels, classNames } = shared;
148
+ const fileUnder = categoryOf ?? defaultCategoryOf(categories);
149
+ const size = party.people.length;
150
+ const fits = services.filter((service) => service.bookableOnline && service.maxPerBooking >= size);
151
+ return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared }), party.people.map((person, index) => {
152
+ const headingId = `booking-service-for-${index}`;
153
+ const { fitting: menu, unlikely } = byFit(
154
+ // A grown-up gets the grown-ups' menu and a child the children's —
155
+ // a kids' cut for «myself» is not an answer, it is a way round the
156
+ // note below that books the wrong thing.
157
+ childCategory === undefined
158
+ ? fits
159
+ : fits.filter((service) => (fileUnder(service) === childCategory) === !person.adult), (service) => serviceFits(service, index));
160
+ const chosen = party.choices[index]?.id ?? null;
161
+ const suggestion = person.suggestion ?? null;
162
+ const pick = (service) => party.onPickFor(index, service);
163
+ const choiceCard = (service) => (_jsx(Card, { service: service, kind: "choice", selected: service.id === chosen, onPick: () => pick(service), phone: null, ...cardProps(shared) }));
164
+ const unlikelyList = (_jsx(UnlikelyForAge, { id: `booking-service-unlikely-${index}`, name: person.name, services: unlikely, shared: shared, children: choiceCard }));
165
+ const listLabel = fillLabel(labels['service.party.listLabel'], { label: person.label });
166
+ let body;
167
+ if (suggestion && menu.some((service) => service.id === suggestion.service.id)) {
168
+ body = (_jsx(PersonWithSuggestion, { listLabel: listLabel, suggestion: suggestion, menu: menu, chosen: chosen, onPick: pick, choiceCard: choiceCard, unlikely: unlikelyList, hasUnlikely: unlikely.length > 0, shared: shared }));
169
+ }
170
+ else if (menu.length === 0 && unlikely.length === 0) {
171
+ body = (_jsx(NothingInThisParty, { person: person, labels: labels, onRemove: party.onRemove ? () => party.onRemove?.(index) : undefined }));
172
+ }
173
+ else {
174
+ body = (_jsxs(_Fragment, { children: [menu.length > 0 && (_jsx("ul", { "aria-label": listLabel, className: "space-y-2", children: menu.map((service) => (_jsx("li", { children: choiceCard(service) }, service.id))) })), unlikelyList] }));
175
+ }
176
+ return (_jsxs("section", { "aria-labelledby": headingId, className: "space-y-3", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'groupHeading', 'font-sans text-lg font-bold'), children: person.label }), body] }, person.key));
177
+ })] }));
178
+ }
179
+ /**
180
+ * Nothing can be booked for this person IN A PARTY THIS SIZE.
181
+ *
182
+ * The engine's rule: one request holds at most as many people as the
183
+ * STRICTEST `maxPerBooking` among its services allows, so a one-per-booking
184
+ * adult service cannot ride along with the children's. The step says so in
185
+ * one line (the caller keeps «next» shut) and offers the one way on: take them
186
+ * out, and book them as their own time.
187
+ */
188
+ function NothingInThisParty({ person, labels, onRemove, }) {
189
+ const values = { label: person.label };
190
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: fillLabel(labels[person.adult ? 'service.party.adultAlone' : 'service.party.nothingFits'], values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: fillLabel(labels['service.party.remove'], values) }))] }));
191
+ }
192
+ /**
193
+ * One person with a «same as last time»: the suggestion as the answer, and the
194
+ * rest of the menu one tap away behind «choose something else». The menu opens
195
+ * by itself when the answer is something else already, so a choice is never
196
+ * hidden.
197
+ */
198
+ function PersonWithSuggestion({ listLabel, suggestion, menu, chosen, onPick, choiceCard, unlikely, hasUnlikely, shared, }) {
199
+ const [open, setOpen] = useState(chosen !== null && chosen !== suggestion.service.id);
200
+ const others = menu.filter((service) => service.id !== suggestion.service.id);
201
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(SameAsLast, { suggestion: suggestion, selected: chosen === suggestion.service.id, onPick: () => onPick(suggestion.service), shared: shared }), (others.length > 0 || hasUnlikely) && (_jsx("button", { type: "button", "aria-expanded": open, onClick: () => setOpen((current) => !current), className: "text-sm font-semibold text-primary underline underline-offset-4", children: shared.labels['service.chooseOther'] })), open && others.length > 0 && (_jsx("ul", { "aria-label": listLabel, className: "space-y-2", children: others.map((service) => (_jsx("li", { children: choiceCard(service) }, service.id))) })), open && unlikely] }));
202
+ }
203
+ /** «Same as last time · Kids' cut» — the person's last service, as one tap. */
204
+ function SameAsLast({ suggestion, selected, onPick, shared, }) {
205
+ const { service, note } = suggestion;
206
+ const { labels, format, classNames } = shared;
207
+ return (_jsxs("div", { className: "space-y-1", children: [_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onPick, className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border px-5 py-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
208
+ ? 'border-primary bg-primary/5 ring-2 ring-primary'
209
+ : 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: fillLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: "whitespace-nowrap font-semibold text-foreground tabular-nums", children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
210
+ }
211
+ /**
212
+ * The default service card.
213
+ *
214
+ * - `book`: the whole card is the target — on a phone, anything smaller is a
215
+ * missed tap.
216
+ * - `choice`: pressed rather than advancing.
217
+ * - `phone`: not a button, deliberately. The business does not take this one
218
+ * online, and a tap target that accepts the tap and does nothing is the dead
219
+ * end the phone number replaces. The card stays so the catalogue is whole.
220
+ */
221
+ export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }) {
222
+ const duration = fillLabel(labels['service.duration'], { minutes: service.durationMinutes });
223
+ const price = (_jsx("span", { className: "whitespace-nowrap font-semibold text-foreground tabular-nums", children: format.price(service.priceOre) }));
224
+ if (kind === 'phone') {
225
+ return (_jsxs("div", { className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border border-dashed border-border px-5 py-4', className), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), _jsxs("span", { className: "text-sm", children: [labels['service.phoneOnly'], phone ? (_jsxs(_Fragment, { children: [' – ', _jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['service.phoneOnlyLinkLabel'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: phone })] })) : null] })] })] }));
226
+ }
227
+ if (kind === 'choice') {
228
+ return (_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onPick, className: cn('flex min-h-12 w-full items-baseline justify-between gap-4 rounded-lg border px-4 py-3 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
229
+ ? 'border-primary bg-primary/5 font-semibold ring-2 ring-primary'
230
+ : 'border-border bg-card hover:border-primary', className, selected && selectedClassName), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-3 text-right", children: [_jsx("span", { className: "text-muted-foreground tabular-nums", children: duration }), price] })] }));
231
+ }
232
+ return (_jsxs("button", { type: "button", onClick: onPick, className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border border-border bg-card px-5 py-4 text-left transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', className), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), price] })] }));
233
+ }
@@ -0,0 +1,12 @@
1
+ import type { ComponentType } from 'react';
2
+ import { cn } from '../lib/utils.js';
3
+ /**
4
+ * Per-slot class overrides a screen accepts: `classNames={{ card: '…' }}`.
5
+ * Merged AFTER the screen's own classes with tailwind-merge, so an override
6
+ * wins over a conflicting default utility.
7
+ */
8
+ export type SlotClassNames<Slot extends string> = Partial<Record<Slot, string>>;
9
+ /** `cn(base, classNames?.[slot])` — the one way a screen applies a slot override. */
10
+ export declare function slotClass<Slot extends string>(classNames: SlotClassNames<Slot> | undefined, slot: Slot, ...base: Parameters<typeof cn>): string;
11
+ /** Card-level renderer overrides: `components={{ ServiceCard: MyCard }}`. */
12
+ export type ScreenComponents<Map extends Record<string, ComponentType<never>>> = Partial<Map>;
@@ -0,0 +1,5 @@
1
+ import { cn } from '../lib/utils.js';
2
+ /** `cn(base, classNames?.[slot])` — the one way a screen applies a slot override. */
3
+ export function slotClass(classNames, slot, ...base) {
4
+ return cn(...base, classNames?.[slot]);
5
+ }