@medalsocial/meda 3.2.0 → 3.4.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 +11 -11
- package/dist/booking/details-screen.d.ts +2 -1
- package/dist/booking/details-screen.js +13 -10
- package/dist/booking/index.d.ts +2 -1
- package/dist/booking/index.js +2 -1
- package/dist/booking/internal/label-parts.d.ts +39 -4
- package/dist/booking/internal/label-parts.js +78 -12
- package/dist/booking/label-keys.d.ts +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 +8 -7
- 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 +30 -22
- 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/age-confirm-card.js +3 -3
- package/dist/booking/portal/child-cards.d.ts +2 -1
- package/dist/booking/portal/child-cards.js +5 -5
- package/dist/booking/portal/data-controls.d.ts +2 -1
- package/dist/booking/portal/data-controls.js +5 -4
- 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/portal-unreachable.js +2 -2
- 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 +10 -14
- 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/portal/visit-history.js +2 -2
- package/dist/booking/service-screen.d.ts +7 -3
- package/dist/booking/service-screen.js +9 -7
- package/dist/booking/stylist-screen.d.ts +15 -3
- package/dist/booking/stylist-screen.js +21 -17
- package/dist/booking/summary-bar.d.ts +2 -1
- package/dist/booking/time-screen.d.ts +2 -1
- package/dist/booking/time-screen.js +16 -15
- 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 +7 -6
- package/package.json +8 -8
- package/skills/components/SKILL.md +4 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useId, useRef } from 'react';
|
|
3
|
+
import { useEffect, useId, useRef, } from 'react';
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { labelText } from './labels.js';
|
|
6
7
|
import { LiveStatus } from './live-status.js';
|
|
7
8
|
import { slotClass } from './slots.js';
|
|
8
9
|
/**
|
|
@@ -55,10 +56,13 @@ function firstName(displayName) {
|
|
|
55
56
|
}
|
|
56
57
|
/** Sizes the label packs write out in words; any other size reads `.other`. */
|
|
57
58
|
const SIZE_WORDS = { 2: 'two', 3: 'three' };
|
|
58
|
-
/**
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The size-specific label: `.two`, `.three`, else `.other`, filled with
|
|
61
|
+
* `{count}` (the number) and `{sizeWord}` (`party.sizeWord`, else the number).
|
|
62
|
+
*/
|
|
63
|
+
function bySize(labels, base, size, sizeWord) {
|
|
60
64
|
const suffix = SIZE_WORDS[size] ?? 'other';
|
|
61
|
-
return
|
|
65
|
+
return renderLabel(labels[`${base}.${suffix}`], { count: size, sizeWord: sizeWord ?? size });
|
|
62
66
|
}
|
|
63
67
|
export function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId = null, nextAvailableTs, nextAvailableLoading = false, loading = false, skeletonCount = DEFAULT_SKELETON_COUNT, pendingName = null, notice = null, onPick, party, classNames, components, }) {
|
|
64
68
|
// `every`, not `some`: the step asks one question for the whole visit, so a
|
|
@@ -80,10 +84,10 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
|
|
|
80
84
|
const options = [
|
|
81
85
|
{
|
|
82
86
|
resourceId: null,
|
|
83
|
-
name: labels['stylist.firstAvailable'],
|
|
84
|
-
shortName: labels['stylist.firstAvailable'],
|
|
85
|
-
subtitle: labels['stylist.firstAvailableSubtitle'],
|
|
86
|
-
badge: labels['stylist.firstAvailableBadge'],
|
|
87
|
+
name: labelText(labels['stylist.firstAvailable']),
|
|
88
|
+
shortName: labelText(labels['stylist.firstAvailable']),
|
|
89
|
+
subtitle: labelText(labels['stylist.firstAvailableSubtitle']),
|
|
90
|
+
badge: labelText(labels['stylist.firstAvailableBadge']),
|
|
87
91
|
},
|
|
88
92
|
...(loading
|
|
89
93
|
? held
|
|
@@ -103,14 +107,14 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
|
|
|
103
107
|
};
|
|
104
108
|
})),
|
|
105
109
|
];
|
|
106
|
-
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['stylist.heading'] }), party && (_jsxs("fieldset", { className: "flex flex-col gap-2 sm:flex-row", children: [_jsx("legend", { className: "mb-2 font-sans text-lg font-bold", children: labels['stylist.party.legend'] }), _jsx(ModeCard, { selected: party.mode === 'sequential', onClick: () => party.onMode('sequential'), title: labels['stylist.party.sequential'], subtitle:
|
|
110
|
+
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'], {
|
|
107
111
|
minutes: party.minutes.sequential,
|
|
108
|
-
}), classNames: classNames }), _jsx(ModeCard, { selected: party.mode === 'parallel', onClick: () => party.onMode('parallel'), title: bySize(labels, 'stylist.party.parallel', party.size), subtitle:
|
|
112
|
+
}), 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'], {
|
|
109
113
|
minutes: party.minutes.parallel,
|
|
110
|
-
}), classNames: classNames })] })), party?.mode === 'parallel' ? (_jsx("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: bySize(labels, 'stylist.party.parallelNote', party.size) })) : (_jsx(StylistRadioGroup, { options: options, selectedResourceId: selectedResourceId,
|
|
114
|
+
}), 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,
|
|
111
115
|
// The held option takes one placeholder's place, so the row is
|
|
112
116
|
// exactly as long as it was and nothing after it moves.
|
|
113
|
-
skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labels['stylist.loading'] : notice })] }));
|
|
117
|
+
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 ? labelText(labels['stylist.loading']) : notice })] }));
|
|
114
118
|
}
|
|
115
119
|
/**
|
|
116
120
|
* The options as one radiogroup with a roving tab stop.
|
|
@@ -174,13 +178,13 @@ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPic
|
|
|
174
178
|
// box would otherwise clip. `md`: the column of cards.
|
|
175
179
|
className: slotClass(classNames, 'group', 'relative -mx-1 flex h-36 snap-x snap-mandatory scroll-px-1 gap-1 overflow-x-auto overscroll-x-contain px-1 py-2', 'md:mx-0 md:h-auto md:snap-none md:flex-col md:gap-2 md:overflow-visible md:p-0'), children: [options.map((option, index) => (_jsx(Card, { ref: (node) => {
|
|
176
180
|
optionRefs.current[index] = node;
|
|
177
|
-
}, option: option, selected: option.resourceId === selectedResourceId, tabIndex: index === tabStop ? 0 : -1, onClick: () => onPick(option.resourceId), labels: labels, className: classNames?.card, selectedClassName: classNames?.cardSelected }, option.resourceId ?? 'first-available'))), Array.from({ length: skeletons }, (_, index) => (_jsx(SkeletonCard, {}, index)))] }));
|
|
181
|
+
}, 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)))] }));
|
|
178
182
|
}
|
|
179
183
|
/** The restored stylist's stand-in: a real, checked radio of the final size,
|
|
180
184
|
* with its next-opening row pulsing like every card still waiting for one. */
|
|
181
185
|
function pendingOption(resourceId, pendingName, labels, format) {
|
|
182
186
|
const known = pendingName?.trim() ? pendingName.trim() : null;
|
|
183
|
-
const name = known ?? labels['stylist.pendingName'];
|
|
187
|
+
const name = known ?? labelText(labels['stylist.pendingName']);
|
|
184
188
|
return {
|
|
185
189
|
resourceId,
|
|
186
190
|
name,
|
|
@@ -204,7 +208,7 @@ function ModeCard({ selected, onClick, title, subtitle, classNames, }) {
|
|
|
204
208
|
: 'border-border bg-card hover:border-primary', classNames?.modeCard, selected && classNames?.modeCardSelected), children: [_jsx("span", { className: "font-medium", children: title }), _jsx("span", { className: "text-sm font-normal text-muted-foreground tabular-nums", children: subtitle })] }));
|
|
205
209
|
}
|
|
206
210
|
/** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
|
|
207
|
-
export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }) {
|
|
211
|
+
export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }) {
|
|
208
212
|
const id = useId();
|
|
209
213
|
const nameId = `${id}-name`;
|
|
210
214
|
const nextId = `${id}-next`;
|
|
@@ -218,7 +222,7 @@ export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, l
|
|
|
218
222
|
? 'font-semibold md:border-primary md:bg-primary/10 md:ring-2 md:ring-primary'
|
|
219
223
|
: 'md:border-border md:bg-card md:hover:border-primary', className, selected && selectedClassName), children: [_jsx("span", { "aria-hidden": "true", "data-testid": "stylist-avatar", className: cn('flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary/30 font-semibold md:size-12', selected
|
|
220
224
|
? 'ring-2 ring-primary ring-offset-2 ring-offset-background md:ring-0 md:ring-offset-0'
|
|
221
|
-
: 'ring-1 ring-border md:ring-0'), children: photoUrl ? (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" })) : (badge) }), _jsxs("span", { className: "flex w-full min-w-0 flex-col md:w-auto", children: [_jsx("span", { "aria-hidden": "true", className: "h-5 truncate text-sm font-medium md:hidden", children: option.shortName }), _jsx("span", { id: nameId, className: "hidden truncate font-medium md:block", children: name }), subtitle && (_jsx("span", { id: bioId, className: "hidden truncate text-sm text-muted-foreground md:block", children: subtitle })), availability ? (_jsxs("span", { id: nextId, "data-testid": "next-available-row", className:
|
|
225
|
+
: '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" })) })))] })] }));
|
|
222
226
|
}
|
|
223
227
|
/** A stylist option before there is a stylist: the same box, the same circle, at both widths. */
|
|
224
228
|
function SkeletonCard() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BookingLabel } from './labels.js';
|
|
1
2
|
import { type SlotClassNames } from './slots.js';
|
|
2
3
|
import type { WizardStep } from './types.js';
|
|
3
4
|
/**
|
|
@@ -9,7 +10,7 @@ import type { WizardStep } from './types.js';
|
|
|
9
10
|
* wizard it summarises.
|
|
10
11
|
*/
|
|
11
12
|
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],
|
|
13
|
+
export type SummaryBarLabels = Record<(typeof SUMMARY_BAR_LABEL_KEYS)[number], BookingLabel>;
|
|
13
14
|
/** `root` the bar, `line` the summary sentence, `next` the button. */
|
|
14
15
|
export type SummaryBarSlot = 'root' | 'line' | 'next';
|
|
15
16
|
export interface SummaryBarProps {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingDaypart, BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingDayDto, BookingSlotDto, PartySlot, WizardItem } from './types.js';
|
|
5
6
|
/**
|
|
@@ -26,7 +27,7 @@ export declare const TIME_SCREEN_LABEL_KEYS: readonly ["time.heading", "time.day
|
|
|
26
27
|
* finishes (end them with the dash); `closed` and `over` are whole sentences.
|
|
27
28
|
* - `time.empty.callLink|callLinkAria` `{phone}`
|
|
28
29
|
*/
|
|
29
|
-
export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number],
|
|
30
|
+
export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
30
31
|
/**
|
|
31
32
|
* `classNames` slots:
|
|
32
33
|
* - `root` the section · `heading` the h2
|
|
@@ -3,8 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from 'lucide-react';
|
|
4
4
|
import { useEffect, useState } from 'react';
|
|
5
5
|
import { cn } from '../lib/utils.js';
|
|
6
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
6
7
|
import { BookingButton, bookingButtonClass } from './internal/ui.js';
|
|
7
|
-
import { fillLabel } from './labels.js';
|
|
8
|
+
import { fillLabel, labelText } from './labels.js';
|
|
8
9
|
import { LiveStatus } from './live-status.js';
|
|
9
10
|
import { slotClass } from './slots.js';
|
|
10
11
|
/**
|
|
@@ -129,7 +130,7 @@ function partyChipLabel(labels, clock, slot, party) {
|
|
|
129
130
|
});
|
|
130
131
|
return fillLabel(labels['time.party.parallel'], {
|
|
131
132
|
time: clock.formatTime(slot.startTs),
|
|
132
|
-
seats: seats.join(labels['time.party.seatSeparator']),
|
|
133
|
+
seats: seats.join(labelText(labels['time.party.seatSeparator'])),
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
136
|
/**
|
|
@@ -155,8 +156,8 @@ function offeredSlots(slots, party) {
|
|
|
155
156
|
function weekendSentence(labels, format, note) {
|
|
156
157
|
const price = format.price(note.priceOre);
|
|
157
158
|
return note.pct === null
|
|
158
|
-
?
|
|
159
|
-
:
|
|
159
|
+
? renderLabel(labels['time.weekend.mixed'], { price })
|
|
160
|
+
: renderLabel(labels['time.weekend.pct'], { pct: note.pct, price });
|
|
160
161
|
}
|
|
161
162
|
// Month grid — pure calendar arithmetic over the business-local date, so a
|
|
162
163
|
// viewer in another zone is never shown a grid built around the wrong day.
|
|
@@ -282,7 +283,7 @@ export function TakenToast({ labels, takenSlotTs, className }) {
|
|
|
282
283
|
}, [takenSlotTs]);
|
|
283
284
|
return (_jsx("div", { role: "status", "aria-live": "polite", className: shown
|
|
284
285
|
? cn('fixed inset-x-4 bottom-24 z-50 mx-auto flex max-w-md items-center gap-3 rounded-lg border border-secondary/40 bg-background px-5 py-3 text-sm shadow-lg', className)
|
|
285
|
-
: 'sr-only', children: shown && (_jsxs(_Fragment, { children: [_jsx("p", { className: "flex-1", children: labels['time.taken.message'] }), _jsx("button", { type: "button", "aria-label": labels['time.taken.close'], onClick: () => setShown(false), className: "flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-muted", children: _jsx(X, { "aria-hidden": "true", className: "size-4" }) })] })) }));
|
|
286
|
+
: 'sr-only', children: shown && (_jsxs(_Fragment, { children: [_jsx("p", { className: "flex-1", children: labels['time.taken.message'] }), _jsx("button", { type: "button", "aria-label": labelText(labels['time.taken.close']), onClick: () => setShown(false), className: "flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-muted", children: _jsx(X, { "aria-hidden": "true", className: "size-4" }) })] })) }));
|
|
286
287
|
}
|
|
287
288
|
/**
|
|
288
289
|
* The time step before its openings are in: the same heading, a strip of day
|
|
@@ -300,7 +301,7 @@ export function TimeScreenSkeleton({ labels, days = 7, monthView = false, surcha
|
|
|
300
301
|
size: 'lg',
|
|
301
302
|
className: 'min-w-20 animate-pulse border-border bg-muted text-transparent',
|
|
302
303
|
});
|
|
303
|
-
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: cn('space-y-6', className), children: [_jsx(TimeHeading, { labels: labels }), _jsx(LiveStatus, { text: labels['time.loading'] }), _jsxs("div", { "aria-hidden": "true", className: "space-y-6", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: days }, (_, index) => (_jsx("span", { "data-testid": "day-chip-skeleton", className: dayChip, children: labels['time.skeleton.dayPlaceholder'] }, index))) }), monthView && _jsx("span", { className: "block h-8 w-36 animate-pulse rounded bg-muted" }), surchargeRow && _jsx("span", { "data-testid": "surcharge-row-skeleton", className: "block min-h-5" }), _jsxs("div", { className: "space-y-3", children: [_jsx("span", { className: "block h-7 w-32 animate-pulse rounded bg-muted" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: 8 }, (_, index) => (_jsx("span", { "data-testid": "slot-skeleton", className: slotChip, children: "00:00" }, index))) })] })] })] }));
|
|
304
|
+
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: cn('space-y-6', className), children: [_jsx(TimeHeading, { labels: labels }), _jsx(LiveStatus, { text: labelText(labels['time.loading']) }), _jsxs("div", { "aria-hidden": "true", className: "space-y-6", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: days }, (_, index) => (_jsx("span", { "data-testid": "day-chip-skeleton", className: dayChip, children: labels['time.skeleton.dayPlaceholder'] }, index))) }), monthView && _jsx("span", { className: "block h-8 w-36 animate-pulse rounded bg-muted" }), surchargeRow && _jsx("span", { "data-testid": "surcharge-row-skeleton", className: "block min-h-5" }), _jsxs("div", { className: "space-y-3", children: [_jsx("span", { className: "block h-7 w-32 animate-pulse rounded bg-muted" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: 8 }, (_, index) => (_jsx("span", { "data-testid": "slot-skeleton", className: slotChip, children: "00:00" }, index))) })] })] })] }));
|
|
304
305
|
}
|
|
305
306
|
// ---------------------------------------------------------------------------
|
|
306
307
|
// Month view
|
|
@@ -317,12 +318,12 @@ function MonthCalendar({ labels, clock, open, onToggle, monthTs, onMonth, strip,
|
|
|
317
318
|
const { year, month, day: anchorDay } = ymd(clock, monthTs);
|
|
318
319
|
const firstOfMonth = clock.dayStart(monthTs, 1 - anchorDay);
|
|
319
320
|
const monthName = clock.monthName(month);
|
|
320
|
-
const heading =
|
|
321
|
+
const heading = renderLabel(labels['time.month.heading'], {
|
|
321
322
|
month: `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)}`,
|
|
322
323
|
year,
|
|
323
324
|
});
|
|
324
325
|
const selectedKey = clock.dayKey(selectedDay);
|
|
325
|
-
return (_jsxs("div", { className: slotClass(classNames, 'month', 'space-y-3'), children: [_jsxs(BookingButton, { variant: "link", size: "sm", "aria-expanded": open, onClick: onToggle, className: "px-0", children: [open ? labels['time.month.hide'] : labels['time.month.show'], open ? (_jsx(ChevronUp, { "aria-hidden": "true", className: "size-4" })) : (_jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }))] }), open && (_jsxs("div", { className: "space-y-3 rounded-lg border border-border bg-card px-4 py-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labels['time.month.previous'], onClick: () => onMonth(clock.dayStart(firstOfMonth, -1)), className: "rounded-full", children: _jsx(ChevronLeft, { "aria-hidden": "true", className: "size-4" }) }), _jsx("p", { "aria-live": "polite", className: "font-sans font-bold", children: heading }), _jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labels['time.month.next'], onClick: () => onMonth(clock.dayStart(firstOfMonth, daysInMonth(year, month))), className: "rounded-full", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" }) })] }), _jsx("div", { "aria-hidden": "true", className: "grid grid-cols-7 gap-1 text-center", children: clock.weekdayHeads().map((head, index) => (_jsx("span", { className: "text-xs font-bold text-muted-foreground", children: head }, index))) }), _jsx("div", { className: "grid grid-cols-7 gap-1", children: cells.map((cell, index) => {
|
|
326
|
+
return (_jsxs("div", { className: slotClass(classNames, 'month', 'space-y-3'), children: [_jsxs(BookingButton, { variant: "link", size: "sm", "aria-expanded": open, onClick: onToggle, className: "px-0", children: [open ? labels['time.month.hide'] : labels['time.month.show'], open ? (_jsx(ChevronUp, { "aria-hidden": "true", className: "size-4" })) : (_jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }))] }), open && (_jsxs("div", { className: "space-y-3 rounded-lg border border-border bg-card px-4 py-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labelText(labels['time.month.previous']), onClick: () => onMonth(clock.dayStart(firstOfMonth, -1)), className: "rounded-full", children: _jsx(ChevronLeft, { "aria-hidden": "true", className: "size-4" }) }), _jsx("p", { "aria-live": "polite", className: "font-sans font-bold", children: heading }), _jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labelText(labels['time.month.next']), onClick: () => onMonth(clock.dayStart(firstOfMonth, daysInMonth(year, month))), className: "rounded-full", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" }) })] }), _jsx("div", { "aria-hidden": "true", className: "grid grid-cols-7 gap-1 text-center", children: clock.weekdayHeads().map((head, index) => (_jsx("span", { className: "text-xs font-bold text-muted-foreground", children: head }, index))) }), _jsx("div", { className: "grid grid-cols-7 gap-1", children: cells.map((cell, index) => {
|
|
326
327
|
if (cell === null) {
|
|
327
328
|
// The blanks before the first of the month; the position is
|
|
328
329
|
// the only thing that tells them apart.
|
|
@@ -365,7 +366,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
|
|
|
365
366
|
else if (partySlot && party) {
|
|
366
367
|
label = partyChipLabel(labels, clock, partySlot, party);
|
|
367
368
|
}
|
|
368
|
-
return (_jsx(TimeChip, { startTs: slot.startTs, label: label, nearest: isNearest, nearestLabel: labels['time.nearest'], current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
369
|
+
return (_jsx(TimeChip, { startTs: slot.startTs, label: label, nearest: isNearest, nearestLabel: labelText(labels['time.nearest']), current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
369
370
|
}) })] }, daypart.key));
|
|
370
371
|
});
|
|
371
372
|
}
|
|
@@ -380,7 +381,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
|
|
|
380
381
|
function ParallelInstead({ labels, clock, slot, day, now, size, onAccept, className, }) {
|
|
381
382
|
const time = clock.formatTime(slot.startTs);
|
|
382
383
|
const offer = size === 2 ? labels['time.parallel.offer.two'] : labels['time.parallel.offer.other'];
|
|
383
|
-
return (_jsxs("div", { className: cn('space-y-3 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4', className), children: [_jsx("p", { className: "text-sm", children:
|
|
384
|
+
return (_jsxs("div", { className: cn('space-y-3 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4', className), children: [_jsx("p", { className: "text-sm", children: renderLabel(offer, { day: clock.dayLabel(day, now), time }) }), _jsx(BookingButton, { size: "lg", onClick: onAccept, className: "tabular-nums", children: renderLabel(labels['time.parallel.accept'], { time }) })] }));
|
|
384
385
|
}
|
|
385
386
|
/**
|
|
386
387
|
* A day with nothing left, in four different situations:
|
|
@@ -395,17 +396,17 @@ function NothingFreeCard({ labels, format, day, now, phone, standing, stylistNam
|
|
|
395
396
|
const dayLabel = format.clock.dayLabel(day, now);
|
|
396
397
|
const cardClass = cn('rounded-lg border border-border bg-card px-5 py-4 text-sm', className);
|
|
397
398
|
if (standing === 'closed' || standing === 'over') {
|
|
398
|
-
return (_jsx("p", { className: cardClass, children:
|
|
399
|
+
return (_jsx("p", { className: cardClass, children: renderLabel(labels[standing === 'closed' ? 'time.empty.closed' : 'time.empty.over'], {
|
|
399
400
|
day: dayLabel,
|
|
400
401
|
}) }));
|
|
401
402
|
}
|
|
402
|
-
let lead =
|
|
403
|
+
let lead = renderLabel(labels['time.empty.unknown'], { day: dayLabel });
|
|
403
404
|
if (standing === 'open') {
|
|
404
405
|
lead = stylistName
|
|
405
|
-
?
|
|
406
|
-
:
|
|
406
|
+
? renderLabel(labels['time.empty.stylist'], { stylist: stylistName, day: dayLabel })
|
|
407
|
+
: renderLabel(labels['time.empty.full'], { day: dayLabel });
|
|
407
408
|
}
|
|
408
|
-
return (_jsxs("p", { className: cardClass, children: [lead, ' ', phone ? (_jsxs(_Fragment, { children: [_jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['time.empty.callLinkAria'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children:
|
|
409
|
+
return (_jsxs("p", { className: cardClass, children: [lead, ' ', phone ? (_jsxs(_Fragment, { children: [_jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['time.empty.callLinkAria'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(labels['time.empty.callLink'], { phone }) }), labels['time.empty.callSuffix']] })) : (labels['time.empty.callPlain'])] }));
|
|
409
410
|
}
|
|
410
411
|
/**
|
|
411
412
|
* Today's remaining gaps sit inside the business's lead time, so they are never
|
|
@@ -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_BUTTON_LABEL_KEYS: readonly ["vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
3
|
-
export type VippsButtonLabels = Record<(typeof VIPPS_BUTTON_LABEL_KEYS)[number],
|
|
4
|
+
export type VippsButtonLabels = Record<(typeof VIPPS_BUTTON_LABEL_KEYS)[number], BookingLabel>;
|
|
4
5
|
/** Why a Vipps start did NOT end in a redirect. `null` is silence. */
|
|
5
6
|
export type VippsStartState = {
|
|
6
7
|
ok: false;
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useActionState, useId } from 'react';
|
|
4
4
|
import { useFormStatus } from 'react-dom';
|
|
5
5
|
import { bookingButtonClass } from './internal/ui.js';
|
|
6
|
+
import { labelText } from './labels.js';
|
|
6
7
|
import { slotClass } from './slots.js';
|
|
7
8
|
export const VIPPS_BUTTON_LABEL_KEYS = [
|
|
8
9
|
'vipps.button',
|
|
@@ -26,7 +27,7 @@ export function VippsButton({ onVipps, next = null, label, labels, classNames })
|
|
|
26
27
|
const [state, formAction] = useActionState(onVipps, null);
|
|
27
28
|
const noticeId = `${useId()}-notice`;
|
|
28
29
|
const notice = state === null ? null : labels[`vipps.${state.reason}`];
|
|
29
|
-
return (_jsxs("form", { action: formAction, className: slotClass(classNames, 'root', 'space-y-3'), "aria-describedby": notice ? noticeId : undefined, children: [next && _jsx("input", { type: "hidden", name: "next", value: next }), _jsx(SubmitButton, { label: label ?? labels['vipps.button'], className: bookingButtonClass({
|
|
30
|
+
return (_jsxs("form", { action: formAction, className: slotClass(classNames, 'root', 'space-y-3'), "aria-describedby": notice ? noticeId : undefined, children: [next && _jsx("input", { type: "hidden", name: "next", value: next }), _jsx(SubmitButton, { label: label ?? labelText(labels['vipps.button']), className: bookingButtonClass({
|
|
30
31
|
size: 'lg',
|
|
31
32
|
className: slotClass(classNames, 'button', 'w-full rounded-full bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/40'),
|
|
32
33
|
}) }), _jsx("div", { "aria-live": "polite", children: notice && (_jsx("p", { id: noticeId, className: slotClass(classNames, 'notice', 'text-sm text-muted-foreground'), children: notice })) })] }));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
import { type AddChildSheetLabels } from './add-child-sheet.js';
|
|
3
3
|
import type { BookingFormat } from './format.js';
|
|
4
|
+
import { type BookingLabel } from './labels.js';
|
|
4
5
|
import { type SlotClassNames } from './slots.js';
|
|
5
6
|
import type { NewChild, SaveResult, WizardPerson } from './types.js';
|
|
6
7
|
/**
|
|
@@ -35,7 +36,7 @@ import type { NewChild, SaveResult, WizardPerson } from './types.js';
|
|
|
35
36
|
* from the sheet, grows the list by whole rows and moves nothing sideways.
|
|
36
37
|
*/
|
|
37
38
|
export declare const WHO_SCREEN_LABEL_KEYS: readonly ["who.heading", "who.guest.groupLabel", "who.guest.children.one", "who.guest.children.other", "who.guest.adult", "who.guest.addedList", "who.family.legend", "who.family.self", "who.family.limit", "addChild.trigger", "addChild.title", "addChild.description.saved", "addChild.description.local", "addChild.name", "addChild.birthYear", "addChild.birthYearPlaceholder", "addChild.birthMonth", "addChild.birthMonthUnknown", "addChild.notes", "addChild.missing", "addChild.submit", "addChild.close"];
|
|
38
|
-
export type WhoScreenLabels = Record<(typeof WHO_SCREEN_LABEL_KEYS)[number],
|
|
39
|
+
export type WhoScreenLabels = Record<(typeof WHO_SCREEN_LABEL_KEYS)[number], BookingLabel> & AddChildSheetLabels;
|
|
39
40
|
/**
|
|
40
41
|
* - `root` — the `<section>`
|
|
41
42
|
* - `heading` — the `<h2>`
|
|
@@ -4,7 +4,8 @@ import { Check } from 'lucide-react';
|
|
|
4
4
|
import { useRef } from 'react';
|
|
5
5
|
import { cn } from '../lib/utils.js';
|
|
6
6
|
import { ADD_CHILD_SHEET_LABEL_KEYS, AddChildSheet, } from './add-child-sheet.js';
|
|
7
|
-
import {
|
|
7
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
8
|
+
import { labelText } from './labels.js';
|
|
8
9
|
import { slotClass } from './slots.js';
|
|
9
10
|
/**
|
|
10
11
|
* The first step — who is the visit for?
|
|
@@ -67,7 +68,7 @@ function choiceLabel(choice, labels) {
|
|
|
67
68
|
return labels['who.guest.adult'];
|
|
68
69
|
}
|
|
69
70
|
const count = choice.people.length;
|
|
70
|
-
return
|
|
71
|
+
return renderLabel(labels[count === 1 ? 'who.guest.children.one' : 'who.guest.children.other'], {
|
|
71
72
|
count,
|
|
72
73
|
});
|
|
73
74
|
}
|
|
@@ -85,7 +86,7 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
|
|
|
85
86
|
selectedClassName: classNames?.cardSelected,
|
|
86
87
|
};
|
|
87
88
|
if (family === null) {
|
|
88
|
-
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, loginRow, _jsxs("div", { className: "space-y-4", children: [_jsx(GuestChips, { choices: guestChoices, people: people, labels: labels, classNames: classNames, onChoose: onChoose }), addedChildren.length > 0 && (_jsx("ul", { "aria-label": labels['who.guest.addedList'], className: "space-y-2", children: addedChildren.map((entry, index) => (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: true, disabled: false, onToggle: () => onChoose(people.filter((other) => other.key !== entry.person.key), false), ...cardClasses }) }, entry.person.key))) })), sheet(addedChildren.length >= maxPeople, false)] })] }));
|
|
89
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, loginRow, _jsxs("div", { className: "space-y-4", children: [_jsx(GuestChips, { choices: guestChoices, people: people, labels: labels, classNames: classNames, onChoose: onChoose }), addedChildren.length > 0 && (_jsx("ul", { "aria-label": labelText(labels['who.guest.addedList']), className: "space-y-2", children: addedChildren.map((entry, index) => (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: true, disabled: false, onToggle: () => onChoose(people.filter((other) => other.key !== entry.person.key), false), ...cardClasses }) }, entry.person.key))) })), sheet(addedChildren.length >= maxPeople, false)] })] }));
|
|
89
90
|
}
|
|
90
91
|
// Defensively only what this picker draws: the caller clears guest seats
|
|
91
92
|
// when the family appears, and a seat nobody can see must never count
|
|
@@ -104,10 +105,10 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
|
|
|
104
105
|
return;
|
|
105
106
|
onChoose([...seated, person], false);
|
|
106
107
|
}
|
|
107
|
-
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, _jsxs("fieldset", { className: "space-y-4", children: [_jsx("legend", { className: "mb-2 text-sm text-muted-foreground", children:
|
|
108
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, _jsxs("fieldset", { className: "space-y-4", children: [_jsx("legend", { className: "mb-2 text-sm text-muted-foreground", children: renderLabel(labels['who.family.legend'], { max: maxPeople }) }), _jsxs("ul", { className: "space-y-2", children: [family.map((entry, index) => {
|
|
108
109
|
const selected = chosen.has(entry.person.key);
|
|
109
110
|
return (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: selected, disabled: !selected && full, describedBy: !selected && full ? LIMIT_ID : undefined, onToggle: () => toggle(entry.person), ...cardClasses }) }, entry.person.key));
|
|
110
|
-
}), _jsx("li", { children: sheet(full, true) })] }), _jsxs(CheckRow, { checked: selfChecked, disabled: !selfChecked && full, describedBy: !selfChecked && full ? LIMIT_ID : undefined, onToggle: () => toggle(self), className: cn('h-14 justify-between px-5 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsx("span", { className: "font-medium", children: labels['who.family.self'] }), _jsx(CheckCircle, { checked: selfChecked })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ?
|
|
111
|
+
}), _jsx("li", { children: sheet(full, true) })] }), _jsxs(CheckRow, { checked: selfChecked, disabled: !selfChecked && full, describedBy: !selfChecked && full ? LIMIT_ID : undefined, onToggle: () => toggle(self), className: cn('h-14 justify-between px-5 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsx("span", { className: "font-medium", children: labels['who.family.self'] }), _jsx(CheckCircle, { checked: selfChecked })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? renderLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] })] }));
|
|
111
112
|
}
|
|
112
113
|
/**
|
|
113
114
|
* The guest chips as a radio group. Roving focus: one chip is in the tab
|
|
@@ -134,7 +135,7 @@ function GuestChips({ choices, people, labels, classNames, onChoose, }) {
|
|
|
134
135
|
event.preventDefault();
|
|
135
136
|
refs.current[target]?.focus();
|
|
136
137
|
}
|
|
137
|
-
return (_jsx("div", { role: "radiogroup", "aria-label": labels['who.guest.groupLabel'], className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: choices.map((choice, index) => {
|
|
138
|
+
return (_jsx("div", { role: "radiogroup", "aria-label": labelText(labels['who.guest.groupLabel']), className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: choices.map((choice, index) => {
|
|
138
139
|
const pressed = index === checked;
|
|
139
140
|
return (_jsx("button", { ref: (element) => {
|
|
140
141
|
refs.current[index] = element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medalsocial/meda",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Shared Meda UI shell and runtime package.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -201,13 +201,13 @@
|
|
|
201
201
|
}
|
|
202
202
|
},
|
|
203
203
|
"dependencies": {
|
|
204
|
-
"@base-ui/react": "1.
|
|
205
|
-
"class-variance-authority": "0.7.1",
|
|
206
|
-
"clsx": "2.1.1",
|
|
207
|
-
"cmdk": "1.1.1",
|
|
208
|
-
"react-resizable-panels": "4.13.1",
|
|
209
|
-
"tailwind-merge": "3.
|
|
210
|
-
"vaul": "1.1.2"
|
|
204
|
+
"@base-ui/react": "^1.7.0",
|
|
205
|
+
"class-variance-authority": "^0.7.1",
|
|
206
|
+
"clsx": "^2.1.1",
|
|
207
|
+
"cmdk": "^1.1.1",
|
|
208
|
+
"react-resizable-panels": "^4.13.1",
|
|
209
|
+
"tailwind-merge": "^3.6.0",
|
|
210
|
+
"vaul": "^1.1.2"
|
|
211
211
|
},
|
|
212
212
|
"devDependencies": {
|
|
213
213
|
"@biomejs/biome": "2.5.14",
|
|
@@ -156,6 +156,10 @@ same wizard / manage page / portal. Rules when editing or adding one:
|
|
|
156
156
|
- **No copy, no clock.** Every string is a `labels` key (`'<screen>.<thing>'`, declared in
|
|
157
157
|
the screen's `*_LABEL_KEYS` array and rolled up in `label-keys.ts`); every date / price
|
|
158
158
|
goes through the `format: BookingFormat` prop — never `Date` getters or `Intl`.
|
|
159
|
+
- **Labels render in the pack's own pieces.** A label is `string | readonly string[]`:
|
|
160
|
+
render it as children through `renderLabel` (string → one text node, array → one per
|
|
161
|
+
element) and as text (`aria-label`, messages) through `fillLabel` / `labelText`. Never
|
|
162
|
+
split a string label at its `{placeholders}`; test both forms (`childNodes`).
|
|
159
163
|
- **Colours through the bridge only** (`bg-primary`, `text-muted-foreground`, …).
|
|
160
164
|
`pnpm lint:tokens` rejects palette colours (`bg-white`, `text-green-600`) and arbitrary
|
|
161
165
|
colour values in `src/booking`.
|