@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.
- package/README.md +36 -2
- package/dist/booking/add-child-sheet.d.ts +38 -0
- package/dist/booking/add-child-sheet.js +88 -0
- package/dist/booking/booking-skeleton.d.ts +24 -0
- package/dist/booking/booking-skeleton.js +17 -0
- package/dist/booking/confirmation.d.ts +85 -0
- package/dist/booking/confirmation.js +102 -0
- package/dist/booking/details-screen.d.ts +118 -0
- package/dist/booking/details-screen.js +316 -0
- package/dist/booking/format.d.ts +64 -0
- package/dist/booking/format.js +1 -0
- package/dist/booking/index.d.ts +36 -0
- package/dist/booking/index.js +41 -0
- package/dist/booking/internal/label-parts.d.ts +28 -0
- package/dist/booking/internal/label-parts.js +57 -0
- package/dist/booking/internal/ui.d.ts +33 -0
- package/dist/booking/internal/ui.js +35 -0
- package/dist/booking/label-keys.d.ts +34 -0
- package/dist/booking/label-keys.js +56 -0
- package/dist/booking/labels.d.ts +15 -0
- package/dist/booking/labels.js +17 -0
- package/dist/booking/live-status.d.ts +14 -0
- package/dist/booking/live-status.js +17 -0
- package/dist/booking/login-panel.d.ts +108 -0
- package/dist/booking/login-panel.js +239 -0
- package/dist/booking/login-sheet.d.ts +41 -0
- package/dist/booking/login-sheet.js +49 -0
- package/dist/booking/manage-screen.d.ts +113 -0
- package/dist/booking/manage-screen.js +372 -0
- package/dist/booking/otp-slots.d.ts +42 -0
- package/dist/booking/otp-slots.js +109 -0
- package/dist/booking/portal/account-card.d.ts +24 -0
- package/dist/booking/portal/account-card.js +18 -0
- package/dist/booking/portal/action-result.d.ts +30 -0
- package/dist/booking/portal/action-result.js +18 -0
- package/dist/booking/portal/age-confirm-card.d.ts +39 -0
- package/dist/booking/portal/age-confirm-card.js +42 -0
- package/dist/booking/portal/child-cards.d.ts +65 -0
- package/dist/booking/portal/child-cards.js +59 -0
- package/dist/booking/portal/data-controls.d.ts +48 -0
- package/dist/booking/portal/data-controls.js +104 -0
- package/dist/booking/portal/family-editor.d.ts +141 -0
- package/dist/booking/portal/family-editor.js +422 -0
- package/dist/booking/portal/logout-button.d.ts +21 -0
- package/dist/booking/portal/logout-button.js +27 -0
- package/dist/booking/portal/portal-shell.d.ts +71 -0
- package/dist/booking/portal/portal-shell.js +38 -0
- package/dist/booking/portal/portal-unreachable.d.ts +31 -0
- package/dist/booking/portal/portal-unreachable.js +19 -0
- package/dist/booking/portal/profile-form.d.ts +75 -0
- package/dist/booking/portal/profile-form.js +144 -0
- package/dist/booking/portal/rebook-cards.d.ts +44 -0
- package/dist/booking/portal/rebook-cards.js +35 -0
- package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
- package/dist/booking/portal/upcoming-bookings.js +84 -0
- package/dist/booking/portal/vipps-link-row.d.ts +54 -0
- package/dist/booking/portal/vipps-link-row.js +80 -0
- package/dist/booking/portal/visit-history.d.ts +63 -0
- package/dist/booking/portal/visit-history.js +73 -0
- package/dist/booking/service-screen.d.ts +140 -0
- package/dist/booking/service-screen.js +235 -0
- package/dist/booking/slots.d.ts +12 -0
- package/dist/booking/slots.js +5 -0
- package/dist/booking/stylist-screen.d.ts +128 -0
- package/dist/booking/stylist-screen.js +229 -0
- package/dist/booking/summary-bar.d.ts +26 -0
- package/dist/booking/summary-bar.js +32 -0
- package/dist/booking/time-screen.d.ts +191 -0
- package/dist/booking/time-screen.js +419 -0
- package/dist/booking/types.d.ts +284 -0
- package/dist/booking/types.js +15 -0
- package/dist/booking/vipps-button.d.ts +49 -0
- package/dist/booking/vipps-button.js +33 -0
- package/dist/booking/who-screen.d.ts +121 -0
- package/dist/booking/who-screen.js +164 -0
- package/dist/primitives/checkbox.d.ts +23 -0
- package/dist/primitives/checkbox.js +20 -0
- package/dist/primitives/field.d.ts +47 -0
- package/dist/primitives/field.js +48 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/sheet.d.ts +68 -0
- package/dist/primitives/sheet.js +303 -0
- package/dist/primitives/textarea.d.ts +14 -0
- package/dist/primitives/textarea.js +13 -0
- package/dist/styles/booking.css +10 -0
- package/package.json +15 -8
- 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 { renderLabel } from '../internal/label-parts.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: renderLabel(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,140 @@
|
|
|
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
|
+
* - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
|
|
21
|
+
*/
|
|
22
|
+
export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
|
|
23
|
+
/** One service group, in display order. */
|
|
24
|
+
export interface ServiceScreenCategory {
|
|
25
|
+
key: string;
|
|
26
|
+
/** The pill's and the section heading's text. */
|
|
27
|
+
label: string;
|
|
28
|
+
}
|
|
29
|
+
/** A person's «same as last time», and the one-line note when it was swapped (e.g. for age). */
|
|
30
|
+
export interface ServiceSuggestion {
|
|
31
|
+
service: BookingServiceDto;
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
/** One chair on the step for a family — who, as the section heading names them. */
|
|
35
|
+
export interface ServicePartyPerson {
|
|
36
|
+
key: string;
|
|
37
|
+
/** The section heading, e.g. «Theo · 7 years», «Child 2», «Myself». */
|
|
38
|
+
label: string;
|
|
39
|
+
adult: boolean;
|
|
40
|
+
suggestion?: ServiceSuggestion | null;
|
|
41
|
+
/** The child's own name, for the age divider; absent for an anonymous seat. */
|
|
42
|
+
name?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ServiceCardProps {
|
|
45
|
+
service: BookingServiceDto;
|
|
46
|
+
/**
|
|
47
|
+
* `book` — a one-tap card that answers the step;
|
|
48
|
+
* `choice` — one person's pressed/unpressed choice in a family;
|
|
49
|
+
* `phone` — not bookable online: no button, a number to call instead.
|
|
50
|
+
*/
|
|
51
|
+
kind: 'book' | 'choice' | 'phone';
|
|
52
|
+
selected: boolean;
|
|
53
|
+
onPick: () => void;
|
|
54
|
+
/** The number for `phone` cards; `null` renders the sentence without a link. */
|
|
55
|
+
phone: string | null;
|
|
56
|
+
format: BookingFormat;
|
|
57
|
+
labels: ServiceScreenLabels;
|
|
58
|
+
className?: string;
|
|
59
|
+
selectedClassName?: string;
|
|
60
|
+
/** Merged onto the price (the `price` slot). */
|
|
61
|
+
priceClassName?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface ServiceScreenComponents {
|
|
64
|
+
ServiceCard?: ComponentType<ServiceCardProps>;
|
|
65
|
+
}
|
|
66
|
+
export interface ServiceScreenProps {
|
|
67
|
+
labels: ServiceScreenLabels;
|
|
68
|
+
format: BookingFormat;
|
|
69
|
+
/** The full catalogue, in the order the source returned it. */
|
|
70
|
+
services: BookingServiceDto[];
|
|
71
|
+
/** The groups, in display order. Only groups with services are drawn. */
|
|
72
|
+
categories: ServiceScreenCategory[];
|
|
73
|
+
/**
|
|
74
|
+
* The group a service is filed under. Default: its `category` when that is
|
|
75
|
+
* one of `categories`, else the LAST category — a service from a category
|
|
76
|
+
* nobody planned for is filed at the end rather than dropped off the page.
|
|
77
|
+
*/
|
|
78
|
+
categoryOf?: (service: BookingServiceDto) => string;
|
|
79
|
+
/**
|
|
80
|
+
* For a family: the category that is the children's menu. A child is
|
|
81
|
+
* offered only it, an adult everything else. Absent: no split.
|
|
82
|
+
*/
|
|
83
|
+
childCategory?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Whether a bookable service suits the person at `personIndex` (0 for a
|
|
86
|
+
* party of one) — typically their age on the day against the service's age
|
|
87
|
+
* band. `false` moves it below a divider: suggested against, never refused.
|
|
88
|
+
* Default: everything fits.
|
|
89
|
+
*/
|
|
90
|
+
serviceFits?: (service: BookingServiceDto, personIndex: number) => boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The business's number, for the services that cannot be booked online.
|
|
93
|
+
* `null` renders the same sentence without a link: a `tel:` that dials
|
|
94
|
+
* nothing looks like an offer and fails in the visitor's hand.
|
|
95
|
+
*/
|
|
96
|
+
phone?: string | null;
|
|
97
|
+
/** The category pill to open on (e.g. from a link). Unknown or empty categories are ignored. */
|
|
98
|
+
initialCategory?: string | null;
|
|
99
|
+
onPick: (service: BookingServiceDto) => void;
|
|
100
|
+
/**
|
|
101
|
+
* A FAMILY: the people the first step seated, when there is more than one,
|
|
102
|
+
* each answered on their own. Absent or a single person, the step is the
|
|
103
|
+
* one-tap catalogue and `onPick` is the answer.
|
|
104
|
+
*/
|
|
105
|
+
party?: {
|
|
106
|
+
people: ReadonlyArray<ServicePartyPerson>;
|
|
107
|
+
/** `choices[i]` is `people[i]`'s service so far, `null` for none yet. */
|
|
108
|
+
choices: ReadonlyArray<{
|
|
109
|
+
id: string;
|
|
110
|
+
} | null>;
|
|
111
|
+
onPickFor: (index: number, service: BookingServiceDto) => void;
|
|
112
|
+
/** Take one person out of the party — the way on when nothing fits them in a party this size. */
|
|
113
|
+
onRemove?: (index: number) => void;
|
|
114
|
+
};
|
|
115
|
+
/** «Same as last time» for a party of one, offered first. The catalogue below stays whole. */
|
|
116
|
+
suggestion?: ServiceSuggestion | null;
|
|
117
|
+
/** The service already chosen for a party of one. */
|
|
118
|
+
chosenId?: string | null;
|
|
119
|
+
/** The child's name for a party of one, for the age divider. */
|
|
120
|
+
childName?: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* The possessive of a name for the age divider's `{nameGenitive}` — the
|
|
123
|
+
* rule is the language's (e.g. «Theos» / «Jonas'»). Default: the name as is.
|
|
124
|
+
*/
|
|
125
|
+
possessive?: (name: string) => string;
|
|
126
|
+
classNames?: SlotClassNames<ServiceScreenSlot>;
|
|
127
|
+
components?: ServiceScreenComponents;
|
|
128
|
+
}
|
|
129
|
+
export declare function ServiceScreen(props: ServiceScreenProps): import("react").JSX.Element;
|
|
130
|
+
/**
|
|
131
|
+
* The default service card.
|
|
132
|
+
*
|
|
133
|
+
* - `book`: the whole card is the target — on a phone, anything smaller is a
|
|
134
|
+
* missed tap.
|
|
135
|
+
* - `choice`: pressed rather than advancing.
|
|
136
|
+
* - `phone`: not a button, deliberately. The business does not take this one
|
|
137
|
+
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
138
|
+
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
139
|
+
*/
|
|
140
|
+
export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,235 @@
|
|
|
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 { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { BookingButton } from './internal/ui.js';
|
|
7
|
+
import { fillLabel } from './labels.js';
|
|
8
|
+
import { slotClass } from './slots.js';
|
|
9
|
+
/**
|
|
10
|
+
* The service step — what should be done?
|
|
11
|
+
*
|
|
12
|
+
* Presentational. A tap raises `onPick` and the caller's machine decides what
|
|
13
|
+
* that means; nothing here knows which step comes next or whether the basket
|
|
14
|
+
* already holds a child.
|
|
15
|
+
*/
|
|
16
|
+
export const SERVICE_SCREEN_LABEL_KEYS = [
|
|
17
|
+
'service.heading',
|
|
18
|
+
'service.categoriesLegend',
|
|
19
|
+
'service.sameAsLast',
|
|
20
|
+
'service.chooseOther',
|
|
21
|
+
'service.duration',
|
|
22
|
+
'service.phoneOnly',
|
|
23
|
+
'service.phoneOnlyLinkLabel',
|
|
24
|
+
'service.ageDivider.named',
|
|
25
|
+
'service.ageDivider.unnamed',
|
|
26
|
+
'service.party.listLabel',
|
|
27
|
+
'service.party.adultAlone',
|
|
28
|
+
'service.party.nothingFits',
|
|
29
|
+
'service.party.remove',
|
|
30
|
+
];
|
|
31
|
+
const HEADING_ID = 'booking-service-heading';
|
|
32
|
+
function groupHeadingId(category) {
|
|
33
|
+
return `booking-services-${category}`;
|
|
34
|
+
}
|
|
35
|
+
const allFit = () => true;
|
|
36
|
+
const asIs = (name) => name;
|
|
37
|
+
/** The age divider's sentence for `name`. */
|
|
38
|
+
function ageDividerLabel(labels, name, possessive) {
|
|
39
|
+
if (!name)
|
|
40
|
+
return labels['service.ageDivider.unnamed'];
|
|
41
|
+
return fillLabel(labels['service.ageDivider.named'], {
|
|
42
|
+
name,
|
|
43
|
+
nameGenitive: possessive(name),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Split a menu into what fits and what usually does not. Phone-only cards always stay put. */
|
|
47
|
+
function byFit(services, fits) {
|
|
48
|
+
const fitting = [];
|
|
49
|
+
const unlikely = [];
|
|
50
|
+
for (const service of services) {
|
|
51
|
+
(!service.bookableOnline || fits(service) ? fitting : unlikely).push(service);
|
|
52
|
+
}
|
|
53
|
+
return { fitting, unlikely };
|
|
54
|
+
}
|
|
55
|
+
function cardProps(shared) {
|
|
56
|
+
return {
|
|
57
|
+
format: shared.format,
|
|
58
|
+
labels: shared.labels,
|
|
59
|
+
className: shared.classNames?.card,
|
|
60
|
+
selectedClassName: shared.classNames?.cardSelected,
|
|
61
|
+
priceClassName: shared.classNames?.price,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function ServiceScreen(props) {
|
|
65
|
+
const shared = {
|
|
66
|
+
labels: props.labels,
|
|
67
|
+
format: props.format,
|
|
68
|
+
classNames: props.classNames,
|
|
69
|
+
Card: props.components?.ServiceCard ?? DefaultServiceCard,
|
|
70
|
+
possessive: props.possessive ?? asIs,
|
|
71
|
+
};
|
|
72
|
+
if (props.party && props.party.people.length > 1) {
|
|
73
|
+
return _jsx(PartyServiceScreen, { ...props, party: props.party, shared: shared });
|
|
74
|
+
}
|
|
75
|
+
return _jsx(SingleServiceScreen, { ...props, shared: shared });
|
|
76
|
+
}
|
|
77
|
+
function Heading({ shared }) {
|
|
78
|
+
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'] }));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Below the ordinary list: what usually does not suit this person, still one
|
|
82
|
+
* tap away. A labelled list, so a screen reader hears the divider as the name
|
|
83
|
+
* of what follows rather than as a stray sentence.
|
|
84
|
+
*/
|
|
85
|
+
function UnlikelyForAge({ id, name, services, shared, children, }) {
|
|
86
|
+
if (services.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
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))) })] }));
|
|
89
|
+
}
|
|
90
|
+
function SingleServiceScreen({ services, categories, categoryOf, serviceFits = allFit, phone = null, initialCategory = null, onPick, suggestion = null, chosenId = null, childName = null, shared, }) {
|
|
91
|
+
const { Card, labels, classNames } = shared;
|
|
92
|
+
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
93
|
+
// What certainly does not suit this person moves below a divider, still
|
|
94
|
+
// bookable. Telephone-only cards stay where they are: the list stays whole.
|
|
95
|
+
const { fitting: offered, unlikely } = byFit(services, (service) => serviceFits(service, 0));
|
|
96
|
+
const groups = categories
|
|
97
|
+
.map((category) => ({
|
|
98
|
+
category,
|
|
99
|
+
items: offered.filter((service) => fileUnder(service) === category.key),
|
|
100
|
+
}))
|
|
101
|
+
.filter((group) => group.items.length > 0);
|
|
102
|
+
// Null until the visitor taps a pill, then read back through the groups that
|
|
103
|
+
// exist — seeding it with the first category's key instead would leave a
|
|
104
|
+
// catalogue without that group pointing at a section that is not on the
|
|
105
|
+
// page. An `initialCategory` seeds it (in the initialiser, so the server
|
|
106
|
+
// render already marks the right pill) when that group exists.
|
|
107
|
+
const [tapped, setTapped] = useState(() => initialCategory !== null && groups.some((group) => group.category.key === initialCategory)
|
|
108
|
+
? initialCategory
|
|
109
|
+
: null);
|
|
110
|
+
const current = tapped ?? groups[0]?.category.key ?? null;
|
|
111
|
+
function jumpTo(category) {
|
|
112
|
+
setTapped(category);
|
|
113
|
+
// The pills mark where you are in the list; they never shorten it.
|
|
114
|
+
// Filtering would hide most of the catalogue behind a default nobody
|
|
115
|
+
// chose. `scrollIntoView` is absent in jsdom and older browsers; the tap
|
|
116
|
+
// still marks the pill, which is the half that matters.
|
|
117
|
+
document.getElementById(groupHeadingId(category))?.scrollIntoView?.({
|
|
118
|
+
behavior: 'smooth',
|
|
119
|
+
block: 'start',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
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 }) => {
|
|
123
|
+
const marked = category.key === current;
|
|
124
|
+
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": marked, onClick: () => jumpTo(category.key),
|
|
125
|
+
// Weight and ring carry the selection alongside the colour, so
|
|
126
|
+
// it survives a monochrome screen and a colour-blind visitor.
|
|
127
|
+
className: cn('rounded-full', marked
|
|
128
|
+
? 'font-bold ring-2 ring-primary bg-primary/10 border-primary text-foreground'
|
|
129
|
+
: 'font-normal', classNames?.pill, marked && classNames?.pillSelected), children: category.label }, category.key));
|
|
130
|
+
})] })), groups.map(({ category, items }) => (_jsxs("section", { "aria-labelledby": groupHeadingId(category.key),
|
|
131
|
+
// Clears a sticky header when a pill jumps here.
|
|
132
|
+
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) })) })] }));
|
|
133
|
+
}
|
|
134
|
+
function defaultCategoryOf(categories) {
|
|
135
|
+
const known = new Set(categories.map((category) => category.key));
|
|
136
|
+
const fallback = categories[categories.length - 1]?.key ?? '';
|
|
137
|
+
return (service) => (known.has(service.category) ? service.category : fallback);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The step for a family: one short list per person, each answered on its own.
|
|
141
|
+
*
|
|
142
|
+
* What a person in a party can have is what the business takes online and —
|
|
143
|
+
* like the booking engine — nothing whose `maxPerBooking` is below the party's
|
|
144
|
+
* size: the strictest service in a request sets the limit for all of it. With
|
|
145
|
+
* a `childCategory`, a child gets that menu and an adult everything else,
|
|
146
|
+
* which is often nothing, and is told so.
|
|
147
|
+
*/
|
|
148
|
+
function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, shared, }) {
|
|
149
|
+
const { Card, labels, classNames } = shared;
|
|
150
|
+
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
151
|
+
const size = party.people.length;
|
|
152
|
+
const fits = services.filter((service) => service.bookableOnline && service.maxPerBooking >= size);
|
|
153
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared }), party.people.map((person, index) => {
|
|
154
|
+
const headingId = `booking-service-for-${index}`;
|
|
155
|
+
const { fitting: menu, unlikely } = byFit(
|
|
156
|
+
// A grown-up gets the grown-ups' menu and a child the children's —
|
|
157
|
+
// a kids' cut for «myself» is not an answer, it is a way round the
|
|
158
|
+
// note below that books the wrong thing.
|
|
159
|
+
childCategory === undefined
|
|
160
|
+
? fits
|
|
161
|
+
: fits.filter((service) => (fileUnder(service) === childCategory) === !person.adult), (service) => serviceFits(service, index));
|
|
162
|
+
const chosen = party.choices[index]?.id ?? null;
|
|
163
|
+
const suggestion = person.suggestion ?? null;
|
|
164
|
+
const pick = (service) => party.onPickFor(index, service);
|
|
165
|
+
const choiceCard = (service) => (_jsx(Card, { service: service, kind: "choice", selected: service.id === chosen, onPick: () => pick(service), phone: null, ...cardProps(shared) }));
|
|
166
|
+
const unlikelyList = (_jsx(UnlikelyForAge, { id: `booking-service-unlikely-${index}`, name: person.name, services: unlikely, shared: shared, children: choiceCard }));
|
|
167
|
+
const listLabel = fillLabel(labels['service.party.listLabel'], { label: person.label });
|
|
168
|
+
let body;
|
|
169
|
+
if (suggestion && menu.some((service) => service.id === suggestion.service.id)) {
|
|
170
|
+
body = (_jsx(PersonWithSuggestion, { listLabel: listLabel, suggestion: suggestion, menu: menu, chosen: chosen, onPick: pick, choiceCard: choiceCard, unlikely: unlikelyList, hasUnlikely: unlikely.length > 0, shared: shared }));
|
|
171
|
+
}
|
|
172
|
+
else if (menu.length === 0 && unlikely.length === 0) {
|
|
173
|
+
body = (_jsx(NothingInThisParty, { person: person, labels: labels, onRemove: party.onRemove ? () => party.onRemove?.(index) : undefined }));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
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] }));
|
|
177
|
+
}
|
|
178
|
+
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));
|
|
179
|
+
})] }));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Nothing can be booked for this person IN A PARTY THIS SIZE.
|
|
183
|
+
*
|
|
184
|
+
* The engine's rule: one request holds at most as many people as the
|
|
185
|
+
* STRICTEST `maxPerBooking` among its services allows, so a one-per-booking
|
|
186
|
+
* adult service cannot ride along with the children's. The step says so in
|
|
187
|
+
* one line (the caller keeps «next» shut) and offers the one way on: take them
|
|
188
|
+
* out, and book them as their own time.
|
|
189
|
+
*/
|
|
190
|
+
function NothingInThisParty({ person, labels, onRemove, }) {
|
|
191
|
+
const values = { label: person.label };
|
|
192
|
+
return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: renderLabel(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: renderLabel(labels['service.party.remove'], values) }))] }));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* One person with a «same as last time»: the suggestion as the answer, and the
|
|
196
|
+
* rest of the menu one tap away behind «choose something else». The menu opens
|
|
197
|
+
* by itself when the answer is something else already, so a choice is never
|
|
198
|
+
* hidden.
|
|
199
|
+
*/
|
|
200
|
+
function PersonWithSuggestion({ listLabel, suggestion, menu, chosen, onPick, choiceCard, unlikely, hasUnlikely, shared, }) {
|
|
201
|
+
const [open, setOpen] = useState(chosen !== null && chosen !== suggestion.service.id);
|
|
202
|
+
const others = menu.filter((service) => service.id !== suggestion.service.id);
|
|
203
|
+
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] }));
|
|
204
|
+
}
|
|
205
|
+
/** «Same as last time · Kids' cut» — the person's last service, as one tap. */
|
|
206
|
+
function SameAsLast({ suggestion, selected, onPick, shared, }) {
|
|
207
|
+
const { service, note } = suggestion;
|
|
208
|
+
const { labels, format, classNames } = shared;
|
|
209
|
+
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
|
|
210
|
+
? 'border-primary bg-primary/5 ring-2 ring-primary'
|
|
211
|
+
: '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: renderLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: slotClass(classNames, 'price', 'whitespace-nowrap font-semibold text-foreground tabular-nums'), children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The default service card.
|
|
215
|
+
*
|
|
216
|
+
* - `book`: the whole card is the target — on a phone, anything smaller is a
|
|
217
|
+
* missed tap.
|
|
218
|
+
* - `choice`: pressed rather than advancing.
|
|
219
|
+
* - `phone`: not a button, deliberately. The business does not take this one
|
|
220
|
+
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
221
|
+
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
222
|
+
*/
|
|
223
|
+
export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }) {
|
|
224
|
+
const duration = renderLabel(labels['service.duration'], { minutes: service.durationMinutes });
|
|
225
|
+
const price = (_jsx("span", { className: cn('whitespace-nowrap font-semibold text-foreground tabular-nums', priceClassName), children: format.price(service.priceOre) }));
|
|
226
|
+
if (kind === 'phone') {
|
|
227
|
+
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] })] })] }));
|
|
228
|
+
}
|
|
229
|
+
if (kind === 'choice') {
|
|
230
|
+
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
|
|
231
|
+
? 'border-primary bg-primary/5 font-semibold ring-2 ring-primary'
|
|
232
|
+
: '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] })] }));
|
|
233
|
+
}
|
|
234
|
+
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] })] }));
|
|
235
|
+
}
|
|
@@ -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>;
|