@medalsocial/meda 3.3.0 → 3.5.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 +35 -1
- package/dist/booking/add-child-sheet.d.ts +2 -1
- package/dist/booking/add-child-sheet.js +2 -1
- package/dist/booking/booking-skeleton.d.ts +2 -1
- package/dist/booking/confirmation.d.ts +2 -1
- package/dist/booking/confirmation.js +7 -6
- package/dist/booking/details-screen.d.ts +5 -1
- package/dist/booking/details-screen.js +13 -8
- package/dist/booking/index.d.ts +2 -2
- package/dist/booking/index.js +2 -2
- package/dist/booking/internal/label-parts.d.ts +36 -21
- package/dist/booking/internal/label-parts.js +71 -43
- package/dist/booking/internal/service-multi.d.ts +55 -0
- package/dist/booking/internal/service-multi.js +83 -0
- package/dist/booking/internal/visit.d.ts +13 -0
- package/dist/booking/internal/visit.js +10 -0
- package/dist/booking/label-keys.d.ts +13 -5
- package/dist/booking/label-keys.js +2 -1
- package/dist/booking/labels.d.ts +37 -8
- package/dist/booking/labels.js +45 -8
- package/dist/booking/login-panel.d.ts +3 -2
- package/dist/booking/login-panel.js +3 -2
- package/dist/booking/login-sheet.d.ts +2 -1
- package/dist/booking/login-sheet.js +2 -1
- package/dist/booking/manage-screen.d.ts +2 -1
- package/dist/booking/manage-screen.js +16 -11
- package/dist/booking/otp-slots.d.ts +2 -1
- package/dist/booking/otp-slots.js +2 -1
- package/dist/booking/portal/age-confirm-card.d.ts +2 -1
- package/dist/booking/portal/child-cards.d.ts +2 -1
- package/dist/booking/portal/data-controls.d.ts +2 -1
- package/dist/booking/portal/data-controls.js +3 -3
- package/dist/booking/portal/family-editor.d.ts +2 -1
- package/dist/booking/portal/family-editor.js +9 -9
- package/dist/booking/portal/logout-button.d.ts +2 -1
- package/dist/booking/portal/logout-button.js +2 -1
- package/dist/booking/portal/portal-shell.d.ts +2 -1
- package/dist/booking/portal/portal-shell.js +3 -2
- package/dist/booking/portal/portal-unreachable.d.ts +2 -1
- package/dist/booking/portal/profile-form.d.ts +2 -1
- package/dist/booking/portal/profile-form.js +3 -2
- package/dist/booking/portal/rebook-cards.d.ts +2 -1
- package/dist/booking/portal/rebook-cards.js +3 -2
- package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
- package/dist/booking/portal/upcoming-bookings.js +3 -3
- package/dist/booking/portal/vipps-link-row.d.ts +2 -1
- package/dist/booking/portal/vipps-link-row.js +7 -6
- package/dist/booking/portal/visit-history.d.ts +2 -1
- package/dist/booking/service-screen.d.ts +78 -3
- package/dist/booking/service-screen.js +131 -46
- package/dist/booking/stylist-screen.d.ts +2 -1
- package/dist/booking/stylist-screen.js +7 -6
- package/dist/booking/summary-bar.d.ts +2 -1
- package/dist/booking/time-screen.d.ts +2 -1
- package/dist/booking/time-screen.js +42 -19
- package/dist/booking/types.d.ts +14 -1
- package/dist/booking/vipps-button.d.ts +2 -1
- package/dist/booking/vipps-button.js +2 -1
- package/dist/booking/who-screen.d.ts +2 -1
- package/dist/booking/who-screen.js +4 -3
- package/package.json +1 -1
- package/skills/components/SKILL.md +4 -0
|
@@ -5,7 +5,7 @@ import { Field } from '../../primitives/field.js';
|
|
|
5
5
|
import { Input } from '../../primitives/input.js';
|
|
6
6
|
import { renderLabel } from '../internal/label-parts.js';
|
|
7
7
|
import { BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
|
|
8
|
-
import { fillLabel } from '../labels.js';
|
|
8
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
9
9
|
import { slotClass } from '../slots.js';
|
|
10
10
|
import { settle } from './action-result.js';
|
|
11
11
|
export const DATA_CONTROLS_LABEL_KEYS = [
|
|
@@ -51,7 +51,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
51
51
|
const [error, setError] = useState(null);
|
|
52
52
|
const [exportError, setExportError] = useState(null);
|
|
53
53
|
const [pending, startTransition] = useTransition();
|
|
54
|
-
const word = labels['dataControls.confirmWord'];
|
|
54
|
+
const word = labelText(labels['dataControls.confirmWord']);
|
|
55
55
|
const confirmId = `${id}-confirm`;
|
|
56
56
|
const errorId = `${id}-error`;
|
|
57
57
|
function failed(failure) {
|
|
@@ -59,7 +59,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
59
59
|
onSessionExpired?.();
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
|
-
return failure.message ?? labels['dataControls.unreachable'];
|
|
62
|
+
return failure.message ?? labelText(labels['dataControls.unreachable']);
|
|
63
63
|
}
|
|
64
64
|
function exportData() {
|
|
65
65
|
setExportError(null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalFamilyMemberDto, PortalPersonNouns } from '../types.js';
|
|
5
6
|
import { type PortalActionFailure } from './action-result.js';
|
|
@@ -10,7 +11,7 @@ export declare const FAMILY_EDITOR_LABEL_KEYS: readonly ["familyEditor.heading",
|
|
|
10
11
|
* `heading` is used when no `nouns` are given. `removeNamed`, `ageConfirmed`
|
|
11
12
|
* and `ageSaved` take `{name}`. The card's `ageConfirm.*` keys are included.
|
|
12
13
|
*/
|
|
13
|
-
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number],
|
|
14
|
+
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number], BookingLabel> & AgeConfirmCardLabels;
|
|
14
15
|
/**
|
|
15
16
|
* `root` (the section), `heading`, `list` (the `<ul>` of rows), `card` (each
|
|
16
17
|
* row's form), `listStatus` (the live region under the list), `add` (the add
|
|
@@ -6,7 +6,7 @@ import { Field } from '../../primitives/field.js';
|
|
|
6
6
|
import { Input } from '../../primitives/input.js';
|
|
7
7
|
import { Textarea } from '../../primitives/textarea.js';
|
|
8
8
|
import { BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton } from '../internal/ui.js';
|
|
9
|
-
import { fillLabel } from '../labels.js';
|
|
9
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
10
10
|
import { slotClass } from '../slots.js';
|
|
11
11
|
import { settle } from './action-result.js';
|
|
12
12
|
import { AGE_CONFIRM_CARD_LABEL_KEYS, AgeConfirmCard, } from './age-confirm-card.js';
|
|
@@ -196,7 +196,7 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
|
|
|
196
196
|
onSessionExpired?.();
|
|
197
197
|
return null;
|
|
198
198
|
}
|
|
199
|
-
return failure.message ?? labels['familyEditor.unreachable'];
|
|
199
|
+
return failure.message ?? labelText(labels['familyEditor.unreachable']);
|
|
200
200
|
}
|
|
201
201
|
/** Never ask about this child again. */
|
|
202
202
|
function rememberAgeAnswer(personId) {
|
|
@@ -352,7 +352,7 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
|
|
|
352
352
|
adopt(result.family, row.key, isNew ? result.personId : null);
|
|
353
353
|
setStatuses({
|
|
354
354
|
[row.key]: result.fallback && gaveDetails
|
|
355
|
-
? { kind: 'saved', note: labels['familyEditor.savedNoDetails'] }
|
|
355
|
+
? { kind: 'saved', note: labelText(labels['familyEditor.savedNoDetails']) }
|
|
356
356
|
: { kind: 'saved' },
|
|
357
357
|
});
|
|
358
358
|
setListStatus(isNew ? { kind: 'saved' } : null);
|
|
@@ -371,13 +371,13 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
|
|
|
371
371
|
showSaving({
|
|
372
372
|
rows: rows.filter((current) => current.key !== row.key),
|
|
373
373
|
statuses,
|
|
374
|
-
listStatus: { kind: 'saved', note: labels['familyEditor.removed'] },
|
|
374
|
+
listStatus: { kind: 'saved', note: labelText(labels['familyEditor.removed']) },
|
|
375
375
|
});
|
|
376
376
|
const result = await settle(() => onRemovePerson(target(row)));
|
|
377
377
|
if (result.ok) {
|
|
378
378
|
adopt(result.family, row.key, null);
|
|
379
379
|
setStatuses({});
|
|
380
|
-
setListStatus({ kind: 'saved', note: labels['familyEditor.removed'] });
|
|
380
|
+
setListStatus({ kind: 'saved', note: labelText(labels['familyEditor.removed']) });
|
|
381
381
|
return;
|
|
382
382
|
}
|
|
383
383
|
fail(result, null);
|
|
@@ -404,18 +404,18 @@ export function DefaultFamilyMemberCard({ id, row, years, months, offerDetails,
|
|
|
404
404
|
// A stylist no longer on the roster keeps their option rather than the
|
|
405
405
|
// select silently showing «none» for a preference the backend still holds.
|
|
406
406
|
const stylistOptions = row.stylist !== '' && !stylists.some((stylist) => stylist.id === row.stylist)
|
|
407
|
-
? [...stylists, { id: row.stylist, name: labels['familyEditor.stylistFormer'] }]
|
|
407
|
+
? [...stylists, { id: row.stylist, name: labelText(labels['familyEditor.stylistFormer']) }]
|
|
408
408
|
: stylists;
|
|
409
409
|
return (_jsxs("form", { onSubmit: (event) => {
|
|
410
410
|
event.preventDefault();
|
|
411
411
|
onSave();
|
|
412
|
-
}, "aria-label": name || labels['familyEditor.newRow'], className: cn('space-y-3 rounded-lg border border-border bg-card px-5 py-4', className), children: [_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['familyEditor.name'] }), _jsx(Input, { id: `${id}-name`, value: row.name, maxLength: 60, "aria-invalid": nameMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ name: event.target.value }), className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['familyEditor.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: row.birthYear, "aria-invalid": yearMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ birthYear: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthYearPlaceholder'] }), years.map((year) => (_jsx("option", { value: String(year), children: year }, year)))] })] })] }), offerDetails && (_jsx(DetailFields, { id: id, row: row, months: months, stylists: stylistOptions, labels: labels, onPatch: onPatch })), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", size: "sm", children: labels['familyEditor.save'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", "aria-label": name
|
|
412
|
+
}, "aria-label": name || labelText(labels['familyEditor.newRow']), className: cn('space-y-3 rounded-lg border border-border bg-card px-5 py-4', className), children: [_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['familyEditor.name'] }), _jsx(Input, { id: `${id}-name`, value: row.name, maxLength: 60, "aria-invalid": nameMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ name: event.target.value }), className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['familyEditor.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: row.birthYear, "aria-invalid": yearMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ birthYear: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthYearPlaceholder'] }), years.map((year) => (_jsx("option", { value: String(year), children: year }, year)))] })] })] }), offerDetails && (_jsx(DetailFields, { id: id, row: row, months: months, stylists: stylistOptions, labels: labels, onPatch: onPatch })), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", size: "sm", children: labels['familyEditor.save'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", "aria-label": name
|
|
413
413
|
? fillLabel(labels['familyEditor.removeNamed'], { name })
|
|
414
|
-
: labels['familyEditor.removeRow'], onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
|
|
414
|
+
: labelText(labels['familyEditor.removeRow']), onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
|
|
415
415
|
}
|
|
416
416
|
/** Month, preferred stylist and a note for the stylist — where the backend keeps them. */
|
|
417
417
|
function DetailFields({ id, row, months, stylists, labels, onPatch, }) {
|
|
418
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['familyEditor.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: row.birthMonth, onChange: (event) => onPatch({ birthMonth: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthMonthUnknown'] }), months.map((month, index) => (_jsx("option", { value: String(index + 1), children: month }, month)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-stylist`, children: labels['familyEditor.stylist'] }), _jsxs("select", { id: `${id}-stylist`, value: row.stylist, onChange: (event) => onPatch({ stylist: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.stylistNone'] }), stylists.map((stylist) => (_jsx("option", { value: stylist.id, children: stylist.name }, stylist.id)))] })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['familyEditor.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: NOTES_MAX, placeholder: labels['familyEditor.notesPlaceholder'], value: row.notes, onChange: (event) => onPatch({ notes: event.target.value }), className: BOOKING_TEXTAREA_CLASS })] })] }));
|
|
418
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['familyEditor.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: row.birthMonth, onChange: (event) => onPatch({ birthMonth: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthMonthUnknown'] }), months.map((month, index) => (_jsx("option", { value: String(index + 1), children: month }, month)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-stylist`, children: labels['familyEditor.stylist'] }), _jsxs("select", { id: `${id}-stylist`, value: row.stylist, onChange: (event) => onPatch({ stylist: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.stylistNone'] }), stylists.map((stylist) => (_jsx("option", { value: stylist.id, children: stylist.name }, stylist.id)))] })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['familyEditor.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: NOTES_MAX, placeholder: labelText(labels['familyEditor.notesPlaceholder']), value: row.notes, onChange: (event) => onPatch({ notes: event.target.value }), className: BOOKING_TEXTAREA_CLASS })] })] }));
|
|
419
419
|
}
|
|
420
420
|
function RowStatusLine({ id, status, labels, }) {
|
|
421
421
|
return (_jsxs("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: [status?.kind === 'saved' && (_jsx("span", { id: id, className: "text-muted-foreground", children: status.note ?? labels['familyEditor.saved'] })), status?.kind === 'partial' && (_jsx(Field.Error, { id: id, children: labels['familyEditor.partial'] })), status?.kind === 'error' && _jsx(Field.Error, { id: id, children: status.message })] }));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
import type { SaveResult } from '../types.js';
|
|
3
4
|
export declare const LOGOUT_BUTTON_LABEL_KEYS: readonly ["logout.button", "logout.unreachable"];
|
|
4
|
-
export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number],
|
|
5
|
+
export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number], BookingLabel>;
|
|
5
6
|
/** `root` (the form), `button`, `error` (the live line under it). */
|
|
6
7
|
export type LogoutButtonSlot = 'root' | 'button' | 'error';
|
|
7
8
|
export interface LogoutButtonProps {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useTransition } from 'react';
|
|
4
4
|
import { BookingButton } from '../internal/ui.js';
|
|
5
|
+
import { labelText } from '../labels.js';
|
|
5
6
|
import { slotClass } from '../slots.js';
|
|
6
7
|
export const LOGOUT_BUTTON_LABEL_KEYS = ['logout.button', 'logout.unreachable'];
|
|
7
8
|
/** «Log out», locked while the callback runs. */
|
|
@@ -20,7 +21,7 @@ export function LogoutButton({ labels, onLogout, classNames }) {
|
|
|
20
21
|
result = { ok: false, message: '' };
|
|
21
22
|
}
|
|
22
23
|
if (!result.ok)
|
|
23
|
-
setError(result.message || labels['logout.unreachable']);
|
|
24
|
+
setError(result.message || labelText(labels['logout.unreachable']));
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
27
|
return (_jsxs("form", { onSubmit: logout, className: slotClass(classNames, 'root'), children: [_jsx(BookingButton, { type: "submit", variant: "outline", disabled: pending, className: slotClass(classNames, 'button'), children: labels['logout.button'] }), _jsx("p", { "aria-live": "polite", className: slotClass(classNames, 'error', 'mt-2 text-sm text-destructive'), children: error })] }));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
import { type BookingLabel } from '../labels.js';
|
|
2
3
|
import { type SlotClassNames } from '../slots.js';
|
|
3
4
|
export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "portalShell.navBar"];
|
|
4
5
|
/**
|
|
@@ -6,7 +7,7 @@ export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "port
|
|
|
6
7
|
* bottom bar. Only one of the two is displayed at a time; a pack may give both
|
|
7
8
|
* the same name.
|
|
8
9
|
*/
|
|
9
|
-
export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number],
|
|
10
|
+
export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number], BookingLabel>;
|
|
10
11
|
/** An icon the shell draws beside a tab label (e.g. a lucide icon). */
|
|
11
12
|
export type PortalShellIcon = ComponentType<{
|
|
12
13
|
className?: string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { cn } from '../../lib/utils.js';
|
|
5
|
+
import { labelText } from '../labels.js';
|
|
5
6
|
import { slotClass } from '../slots.js';
|
|
6
7
|
export const PORTAL_SHELL_LABEL_KEYS = ['portalShell.nav', 'portalShell.navBar'];
|
|
7
8
|
/**
|
|
@@ -26,12 +27,12 @@ export function PortalShell({ labels, tabs, tab: tabProp, defaultTab, onTabChang
|
|
|
26
27
|
setUncontrolled(next);
|
|
27
28
|
onTabChange?.(next);
|
|
28
29
|
}
|
|
29
|
-
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-8'), children: [header, _jsxs("div", { className: "gap-10 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]", children: [_jsx("aside", { className: slotClass(classNames, 'rail', 'hidden lg:block'), children: _jsxs("div", { className: "sticky top-8 space-y-4", children: [account, _jsx("nav", { "aria-label": labels['portalShell.nav'], children: _jsx("ul", { className: "space-y-1", children: tabs.map(({ id, label, icon: Icon }) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'railItem', 'flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition-colors', tab === id
|
|
30
|
+
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-8'), children: [header, _jsxs("div", { className: "gap-10 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]", children: [_jsx("aside", { className: slotClass(classNames, 'rail', 'hidden lg:block'), children: _jsxs("div", { className: "sticky top-8 space-y-4", children: [account, _jsx("nav", { "aria-label": labelText(labels['portalShell.nav']), children: _jsx("ul", { className: "space-y-1", children: tabs.map(({ id, label, icon: Icon }) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'railItem', 'flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition-colors', tab === id
|
|
30
31
|
? 'bg-foreground text-background'
|
|
31
32
|
: 'text-foreground hover:bg-muted'), tab === id && classNames?.railItemActive), children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0" }), label] }) }, id))) }) }), logout] }) }), _jsx("div", { className: "pb-28 lg:pb-0", children: tabs.map(({ id, label, content }) => (_jsxs("section", { hidden: tab !== id, "aria-label": label,
|
|
32
33
|
// `space-y` on the section rather than a wrapper, so a hidden one
|
|
33
34
|
// contributes no margin.
|
|
34
|
-
className: slotClass(classNames, 'section', 'space-y-10'), children: [content, id === logoutIn && logout && _jsx("div", { className: "lg:hidden", children: logout })] }, id))) })] }), _jsx("nav", { "aria-label": labels['portalShell.navBar'], className: slotClass(classNames, 'bar', 'fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md lg:hidden'), children: _jsx("ul", { className: "mx-auto flex max-w-lg", children: tabs.map(({ id, label, short, icon, shortIcon }) => {
|
|
35
|
+
className: slotClass(classNames, 'section', 'space-y-10'), children: [content, id === logoutIn && logout && _jsx("div", { className: "lg:hidden", children: logout })] }, id))) })] }), _jsx("nav", { "aria-label": labelText(labels['portalShell.navBar']), className: slotClass(classNames, 'bar', 'fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md lg:hidden'), children: _jsx("ul", { className: "mx-auto flex max-w-lg", children: tabs.map(({ id, label, short, icon, shortIcon }) => {
|
|
35
36
|
const ShortIcon = shortIcon ?? icon;
|
|
36
37
|
return (_jsx("li", { className: "flex-1", children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'barItem', 'flex w-full flex-col items-center gap-1 px-2 py-3 text-[0.7rem] font-bold transition-colors', tab === id ? 'text-primary' : 'text-muted-foreground'), tab === id && classNames?.barItemActive), children: [ShortIcon && _jsx(ShortIcon, { "aria-hidden": "true", className: "size-5" }), short ?? label] }) }, id));
|
|
37
38
|
}) }) })] }));
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import type { BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
export declare const PORTAL_UNREACHABLE_LABEL_KEYS: readonly ["portalUnreachable.heading", "portalUnreachable.body", "portalUnreachable.retry", "portalUnreachable.call"];
|
|
5
6
|
/** `portalUnreachable.call` takes `{phone}`. */
|
|
6
|
-
export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number],
|
|
7
|
+
export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number], BookingLabel>;
|
|
7
8
|
/** - `root`: the section. - `heading`. - `actions`: the row of links. - `link`: each link. */
|
|
8
9
|
export type PortalUnreachableSlot = 'root' | 'heading' | 'actions' | 'link';
|
|
9
10
|
export interface PortalUnreachableProps {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
import type { PortalProfileDto } from '../types.js';
|
|
3
4
|
import { type PortalActionFailure } from './action-result.js';
|
|
4
5
|
export declare const PROFILE_FORM_LABEL_KEYS: readonly ["profileForm.heading", "profileForm.firstName", "profileForm.lastName", "profileForm.phone", "profileForm.email", "profileForm.emailHelp", "profileForm.save", "profileForm.saved", "profileForm.restored", "profileForm.marketing", "profileForm.unreachable"];
|
|
5
6
|
/** `marketing` is the consent sentence and usually names the business. */
|
|
6
|
-
export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number],
|
|
7
|
+
export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number], BookingLabel>;
|
|
7
8
|
/**
|
|
8
9
|
* `root` (the section), `heading`, `form`, `status` (the save notice's live
|
|
9
10
|
* region), `save` (the submit button), `consent` (the marketing box's field).
|
|
@@ -5,6 +5,7 @@ import { Checkbox } from '../../primitives/checkbox.js';
|
|
|
5
5
|
import { Field } from '../../primitives/field.js';
|
|
6
6
|
import { Input } from '../../primitives/input.js';
|
|
7
7
|
import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
|
|
8
|
+
import { labelText } from '../labels.js';
|
|
8
9
|
import { slotClass } from '../slots.js';
|
|
9
10
|
import { settle } from './action-result.js';
|
|
10
11
|
export const PROFILE_FORM_LABEL_KEYS = [
|
|
@@ -103,7 +104,7 @@ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessio
|
|
|
103
104
|
}
|
|
104
105
|
setStatus({
|
|
105
106
|
kind: 'error',
|
|
106
|
-
message: result.message ?? labels['profileForm.unreachable'],
|
|
107
|
+
message: result.message ?? labelText(labels['profileForm.unreachable']),
|
|
107
108
|
field: 'field' in result && result.field === 'phone' ? 'phone' : null,
|
|
108
109
|
});
|
|
109
110
|
});
|
|
@@ -124,7 +125,7 @@ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessio
|
|
|
124
125
|
onSessionExpired?.();
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
127
|
-
setConsentError(labels['profileForm.unreachable']);
|
|
128
|
+
setConsentError(labelText(labels['profileForm.unreachable']));
|
|
128
129
|
});
|
|
129
130
|
}
|
|
130
131
|
const view = shown.status;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
|
+
import { type BookingLabel } from '../labels.js';
|
|
2
3
|
import { type SlotClassNames } from '../slots.js';
|
|
3
4
|
import type { RebookSuggestion } from '../types.js';
|
|
4
5
|
export declare const REBOOK_CARDS_LABEL_KEYS: readonly ["rebook.heading", "rebook.empty", "rebook.serviceFallback", "rebook.anyStylist", "rebook.cta"];
|
|
5
|
-
export type RebookCardsLabels = Record<(typeof REBOOK_CARDS_LABEL_KEYS)[number],
|
|
6
|
+
export type RebookCardsLabels = Record<(typeof REBOOK_CARDS_LABEL_KEYS)[number], BookingLabel>;
|
|
6
7
|
/** - `root`: the section. - `heading`. - `empty`. - `list`. - `card`. - `link`: the book link. */
|
|
7
8
|
export type RebookCardsSlot = 'root' | 'heading' | 'empty' | 'list' | 'card' | 'link';
|
|
8
9
|
export interface RebookCardProps {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { labelText } from '../labels.js';
|
|
3
4
|
import { slotClass } from '../slots.js';
|
|
4
5
|
export const REBOOK_CARDS_LABEL_KEYS = [
|
|
5
6
|
'rebook.heading',
|
|
@@ -26,9 +27,9 @@ export function RebookCards({ suggestions, hrefFor, onRebook, labels, headingId
|
|
|
26
27
|
/** One suggestion (an `<li>`): what · who for · with whom, and the book link. */
|
|
27
28
|
export function DefaultRebookCard({ suggestion, href, onRebook, labels, classNames, }) {
|
|
28
29
|
return (_jsxs("li", { className: slotClass(classNames, 'card', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: [
|
|
29
|
-
suggestion.serviceName ?? labels['rebook.serviceFallback'],
|
|
30
|
+
suggestion.serviceName ?? labelText(labels['rebook.serviceFallback']),
|
|
30
31
|
suggestion.bookedForName,
|
|
31
|
-
suggestion.resourceName ?? labels['rebook.anyStylist'],
|
|
32
|
+
suggestion.resourceName ?? labelText(labels['rebook.anyStylist']),
|
|
32
33
|
]
|
|
33
34
|
.filter(Boolean)
|
|
34
35
|
.join(SEPARATOR) }), _jsx("a", { href: href, onClick: onRebook ? () => onRebook(suggestion) : undefined, className: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4'), children: labels['rebook.cta'] })] }));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalBookingDto } from '../types.js';
|
|
5
6
|
export declare const UPCOMING_BOOKINGS_LABEL_KEYS: readonly ["upcoming.heading", "upcoming.empty", "upcoming.book", "upcoming.next", "upcoming.today", "upcoming.tomorrow", "upcoming.inDays", "upcoming.when", "upcoming.serviceFallback", "upcoming.anyStylist", "upcoming.manage", "upcoming.noManage", "upcoming.noManageWithPhone", "upcoming.call"];
|
|
@@ -9,7 +10,7 @@ export declare const UPCOMING_BOOKINGS_LABEL_KEYS: readonly ["upcoming.heading",
|
|
|
9
10
|
* `upcoming.noManageWithPhone` `{call}` (where the `upcoming.call` link goes),
|
|
10
11
|
* `upcoming.call` `{phone}`.
|
|
11
12
|
*/
|
|
12
|
-
export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number],
|
|
13
|
+
export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number], BookingLabel>;
|
|
13
14
|
/**
|
|
14
15
|
* - `root`: the section. - `heading`. - `empty`: the no-bookings box.
|
|
15
16
|
* - `list`. - `card`: a plain card; `cardHero`: the first (next) one.
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { cn } from '../../lib/utils.js';
|
|
4
4
|
import { joinLabelParts, labelParts, renderLabel } from '../internal/label-parts.js';
|
|
5
5
|
import { bookingButtonClass } from '../internal/ui.js';
|
|
6
|
-
import { fillLabel } from '../labels.js';
|
|
6
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
7
7
|
import { slotClass } from '../slots.js';
|
|
8
8
|
export const UPCOMING_BOOKINGS_LABEL_KEYS = [
|
|
9
9
|
'upcoming.heading',
|
|
@@ -29,9 +29,9 @@ const SEPARATOR = ' · ';
|
|
|
29
29
|
function howFarOff(startTs, now, labels, format) {
|
|
30
30
|
const days = format.clock.daysBetween(now, startTs);
|
|
31
31
|
if (days <= 0)
|
|
32
|
-
return labels['upcoming.today'];
|
|
32
|
+
return labelText(labels['upcoming.today']);
|
|
33
33
|
if (days === 1)
|
|
34
|
-
return labels['upcoming.tomorrow'];
|
|
34
|
+
return labelText(labels['upcoming.tomorrow']);
|
|
35
35
|
return fillLabel(labels['upcoming.inDays'], { count: days });
|
|
36
36
|
}
|
|
37
37
|
/** What is booked and who for. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
export declare const VIPPS_LINK_ROW_LABEL_KEYS: readonly ["vippsLink.heading", "vippsLink.linked", "vippsLink.pitch", "vippsLink.button", "vippsLink.success", "vippsLink.conflict", "vippsLink.failed", "vippsLink.unavailable", "vippsLink.throttled"];
|
|
3
|
-
export type VippsLinkRowLabels = Record<(typeof VIPPS_LINK_ROW_LABEL_KEYS)[number],
|
|
4
|
+
export type VippsLinkRowLabels = Record<(typeof VIPPS_LINK_ROW_LABEL_KEYS)[number], BookingLabel>;
|
|
4
5
|
/** `root` (the section), `card` (the bordered row), `heading`, `button`, `notice` (the live line). */
|
|
5
6
|
export type VippsLinkRowSlot = 'root' | 'card' | 'heading' | 'button' | 'notice';
|
|
6
7
|
/** The outcome of a link attempt that just returned, as the server recorded it. */
|
|
@@ -4,6 +4,7 @@ import { useActionState, useEffect, useId, useRef } from 'react';
|
|
|
4
4
|
import { useFormStatus } from 'react-dom';
|
|
5
5
|
import { cn } from '../../lib/utils.js';
|
|
6
6
|
import { BookingButton } from '../internal/ui.js';
|
|
7
|
+
import { labelText } from '../labels.js';
|
|
7
8
|
import { slotClass } from '../slots.js';
|
|
8
9
|
export const VIPPS_LINK_ROW_LABEL_KEYS = [
|
|
9
10
|
'vippsLink.heading',
|
|
@@ -23,17 +24,17 @@ function LinkButton({ label, className }) {
|
|
|
23
24
|
/** The sentence under the row: a failed start first, else what the return said. */
|
|
24
25
|
function noticeFor(state, isLinked, flash, labels, announceSuccess) {
|
|
25
26
|
if (state?.reason === 'unavailable')
|
|
26
|
-
return labels['vippsLink.unavailable'];
|
|
27
|
+
return labelText(labels['vippsLink.unavailable']);
|
|
27
28
|
if (state?.reason === 'throttled')
|
|
28
|
-
return labels['vippsLink.throttled'];
|
|
29
|
+
return labelText(labels['vippsLink.throttled']);
|
|
29
30
|
if (flash === 'linked' && announceSuccess)
|
|
30
|
-
return labels['vippsLink.success'];
|
|
31
|
+
return labelText(labels['vippsLink.success']);
|
|
31
32
|
if (isLinked)
|
|
32
33
|
return null;
|
|
33
34
|
if (flash === 'link_conflict')
|
|
34
|
-
return labels['vippsLink.conflict'];
|
|
35
|
+
return labelText(labels['vippsLink.conflict']);
|
|
35
36
|
if (flash === 'link_failed')
|
|
36
|
-
return labels['vippsLink.failed'];
|
|
37
|
+
return labelText(labels['vippsLink.failed']);
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
@@ -76,5 +77,5 @@ export function VippsLinkRow({ labels, linked, flash, onStart, onSessionExpired,
|
|
|
76
77
|
const sentence = noticeFor(state, linked, flash, labels, announceSuccess);
|
|
77
78
|
const headingId = `${id}-heading`;
|
|
78
79
|
const noticeId = `${id}-notice`;
|
|
79
|
-
return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-2'), children: [_jsxs("div", { className: slotClass(classNames, 'card', 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-semibold'), children: labels['vippsLink.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: linked ? labels['vippsLink.linked'] : labels['vippsLink.pitch'] })] }), !linked && (_jsx("form", { action: formAction, "aria-describedby": sentence ? noticeId : undefined, children: _jsx(LinkButton, { label: labels['vippsLink.button'], className: classNames?.button }) }))] }), _jsx("div", { "aria-live": "polite", className: slotClass(classNames, 'notice', 'min-h-5'), children: sentence && (_jsx("p", { id: noticeId, className: "text-sm text-muted-foreground", children: sentence })) })] }));
|
|
80
|
+
return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-2'), children: [_jsxs("div", { className: slotClass(classNames, 'card', 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-semibold'), children: labels['vippsLink.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: linked ? labels['vippsLink.linked'] : labels['vippsLink.pitch'] })] }), !linked && (_jsx("form", { action: formAction, "aria-describedby": sentence ? noticeId : undefined, children: _jsx(LinkButton, { label: labelText(labels['vippsLink.button']), className: classNames?.button }) }))] }), _jsx("div", { "aria-live": "polite", className: slotClass(classNames, 'notice', 'min-h-5'), children: sentence && (_jsx("p", { id: noticeId, className: "text-sm text-muted-foreground", children: sentence })) })] }));
|
|
80
81
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingClock, BookingFormat } from '../format.js';
|
|
3
|
+
import type { BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalBookingDto } from '../types.js';
|
|
5
6
|
export declare const VISIT_HISTORY_LABEL_KEYS: readonly ["history.heading", "history.empty", "history.serviceFallback", "history.count.one", "history.count.other", "history.unknownAmount"];
|
|
6
7
|
/** `history.count.*` take `{count}` and `{year}`. */
|
|
7
|
-
export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number],
|
|
8
|
+
export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number], BookingLabel>;
|
|
8
9
|
/**
|
|
9
10
|
* - `root`: the section. - `heading`. - `empty`: the no-visits box.
|
|
10
11
|
* - `yearChip` / `yearChipSelected`: the year switcher's buttons.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingServiceDto } from './types.js';
|
|
5
6
|
/**
|
|
@@ -8,9 +9,32 @@ import type { BookingServiceDto } from './types.js';
|
|
|
8
9
|
* Presentational. A tap raises `onPick` and the caller's machine decides what
|
|
9
10
|
* that means; nothing here knows which step comes next or whether the basket
|
|
10
11
|
* already holds a child.
|
|
12
|
+
*
|
|
13
|
+
* With a `selection`, the step is multi-select instead: one person may have
|
|
14
|
+
* several services as ONE visit, the cards tick rather than answer, and a
|
|
15
|
+
* sticky bar carries the running total and the way on.
|
|
11
16
|
*/
|
|
12
17
|
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
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The keys only the multi-select step reads (`ServiceScreenProps.selection`).
|
|
20
|
+
* Optional in `ServiceScreenLabels`, so a pack written before they existed
|
|
21
|
+
* still type-checks; a `selection` carries them all, required, in
|
|
22
|
+
* `ServiceSelection.labels` — a multi-select step cannot compile without them.
|
|
23
|
+
*
|
|
24
|
+
* - `service.multiHint` — under the heading: you may tick more than one;
|
|
25
|
+
* - `service.total` — the sticky bar's running total, `{minutes}` and `{price}`;
|
|
26
|
+
* - `service.chooseFirst` — the sticky bar's line while someone has nothing ticked;
|
|
27
|
+
* - `service.continue` — the sticky bar's button;
|
|
28
|
+
* - `service.party.nothingBookable` — a family member with nothing on their
|
|
29
|
+
* menu, `{label}`: the business takes nothing for them online (the party's
|
|
30
|
+
* size is no longer the reason, as `service.party.nothingFits` says);
|
|
31
|
+
* - `service.tabDone` — read after a family member's tab name once they have
|
|
32
|
+
* something ticked (the visible tick, in words): «Theo, ferdig».
|
|
33
|
+
*/
|
|
34
|
+
export declare const SERVICE_SCREEN_MULTI_LABEL_KEYS: readonly ["service.multiHint", "service.total", "service.chooseFirst", "service.continue", "service.party.nothingBookable", "service.tabDone"];
|
|
35
|
+
/** The multi-select step's labels, every one required (`ServiceSelection.labels`). */
|
|
36
|
+
export type ServiceMultiLabels = Record<(typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number], BookingLabel>;
|
|
37
|
+
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<ServiceMultiLabels>;
|
|
14
38
|
/**
|
|
15
39
|
* - `root` — the `<section>`
|
|
16
40
|
* - `heading` — the `<h2>`
|
|
@@ -18,8 +42,10 @@ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[numb
|
|
|
18
42
|
* - `groupHeading` — a category's (or a party member's) `<h3>`
|
|
19
43
|
* - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
|
|
20
44
|
* - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
|
|
45
|
+
* - `tab` / `tabSelected` — a person's tab in a multi-select family / the open one
|
|
46
|
+
* - `bar` — the multi-select step's sticky total bar
|
|
21
47
|
*/
|
|
22
|
-
export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
|
|
48
|
+
export type ServiceScreenSlot = 'tab' | 'tabSelected' | 'bar' | 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
|
|
23
49
|
/** One service group, in display order. */
|
|
24
50
|
export interface ServiceScreenCategory {
|
|
25
51
|
key: string;
|
|
@@ -50,6 +76,13 @@ export interface ServiceCardProps {
|
|
|
50
76
|
*/
|
|
51
77
|
kind: 'book' | 'choice' | 'phone';
|
|
52
78
|
selected: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* `choice` only: the card is one of several the person may tick (a
|
|
81
|
+
* checkbox, `selected` = ticked), not their one answer (a pressed button).
|
|
82
|
+
* Set on the multi-select step (`ServiceScreenProps.selection`).
|
|
83
|
+
*/
|
|
84
|
+
multiple?: boolean;
|
|
85
|
+
/** Raised on a tap. On a `multiple` card it toggles; nothing advances. */
|
|
53
86
|
onPick: () => void;
|
|
54
87
|
/** The number for `phone` cards; `null` renders the sentence without a link. */
|
|
55
88
|
phone: string | null;
|
|
@@ -60,6 +93,40 @@ export interface ServiceCardProps {
|
|
|
60
93
|
/** Merged onto the price (the `price` slot). */
|
|
61
94
|
priceClassName?: string;
|
|
62
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The multi-select step: each person ticks one or more services, done back to
|
|
98
|
+
* back as one visit. The caller's machine owns the lists; the screen only
|
|
99
|
+
* reports taps.
|
|
100
|
+
*/
|
|
101
|
+
export interface ServiceSelection {
|
|
102
|
+
/** Each person's services so far, in order (index = party person; a lone person is index 0). */
|
|
103
|
+
lists: ReadonlyArray<ReadonlyArray<{
|
|
104
|
+
id: string;
|
|
105
|
+
}>>;
|
|
106
|
+
/** A tick or an untick on the person whose tab is open (0 for a lone person). Never advances. */
|
|
107
|
+
onToggle: (personIndex: number, service: BookingServiceDto) => void;
|
|
108
|
+
/** «Next» in the sticky bar. Called whether or not `canContinue`: the caller decides. */
|
|
109
|
+
onContinue: () => void;
|
|
110
|
+
/** The running total for the whole booking, or `null` when nothing is chosen. */
|
|
111
|
+
total: {
|
|
112
|
+
minutes: number;
|
|
113
|
+
priceOre: number;
|
|
114
|
+
} | null;
|
|
115
|
+
/**
|
|
116
|
+
* Whether every person has at least one service. «Next» stays enabled
|
|
117
|
+
* either way (a disabled button explains nothing); while `false` the bar
|
|
118
|
+
* shows `service.chooseFirst` instead of the total.
|
|
119
|
+
*/
|
|
120
|
+
canContinue: boolean;
|
|
121
|
+
/** A refusal to say politely (e.g. too many services), announced as a live region. */
|
|
122
|
+
notice?: string | null;
|
|
123
|
+
/**
|
|
124
|
+
* The multi-select step's labels (`SERVICE_SCREEN_MULTI_LABEL_KEYS`), all
|
|
125
|
+
* required: a pack written before multi-select cannot reach this step and
|
|
126
|
+
* draw an unnamed «Next». A pack that defines them can be passed whole.
|
|
127
|
+
*/
|
|
128
|
+
labels: ServiceMultiLabels;
|
|
129
|
+
}
|
|
63
130
|
export interface ServiceScreenComponents {
|
|
64
131
|
ServiceCard?: ComponentType<ServiceCardProps>;
|
|
65
132
|
}
|
|
@@ -125,6 +192,14 @@ export interface ServiceScreenProps {
|
|
|
125
192
|
possessive?: (name: string) => string;
|
|
126
193
|
classNames?: SlotClassNames<ServiceScreenSlot>;
|
|
127
194
|
components?: ServiceScreenComponents;
|
|
195
|
+
/**
|
|
196
|
+
* Switches the step to multi-select (see `ServiceSelection`): bookable cards
|
|
197
|
+
* become checkboxes, a family gets one tab per person, and a sticky bar
|
|
198
|
+
* shows the total and «Next». `onPick` / `party.onPickFor` then serve only
|
|
199
|
+
* the one-tap «same as last time». Its labels come in `selection.labels`.
|
|
200
|
+
* Absent: the one-tap step, unchanged.
|
|
201
|
+
*/
|
|
202
|
+
selection?: ServiceSelection;
|
|
128
203
|
}
|
|
129
204
|
export declare function ServiceScreen(props: ServiceScreenProps): import("react").JSX.Element;
|
|
130
205
|
/**
|
|
@@ -137,4 +212,4 @@ export declare function ServiceScreen(props: ServiceScreenProps): import("react"
|
|
|
137
212
|
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
138
213
|
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
139
214
|
*/
|
|
140
|
-
export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
|
|
215
|
+
export declare function DefaultServiceCard({ service, kind, selected, multiple, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
|