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