@medalsocial/meda 3.3.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 +2 -2
- package/dist/booking/details-screen.d.ts +2 -1
- package/dist/booking/details-screen.js +9 -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/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 +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 +2 -1
- package/dist/booking/service-screen.js +2 -2
- 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 +6 -6
- 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 +3 -2
- package/package.json +1 -1
- package/skills/components/SKILL.md +4 -0
|
@@ -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
|
/**
|
|
@@ -10,7 +11,7 @@ import type { BookingServiceDto } from './types.js';
|
|
|
10
11
|
* already holds a child.
|
|
11
12
|
*/
|
|
12
13
|
export declare const SERVICE_SCREEN_LABEL_KEYS: readonly ["service.heading", "service.categoriesLegend", "service.sameAsLast", "service.chooseOther", "service.duration", "service.phoneOnly", "service.phoneOnlyLinkLabel", "service.ageDivider.named", "service.ageDivider.unnamed", "service.party.listLabel", "service.party.adultAlone", "service.party.nothingFits", "service.party.remove"];
|
|
13
|
-
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number],
|
|
14
|
+
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
14
15
|
/**
|
|
15
16
|
* - `root` — the `<section>`
|
|
16
17
|
* - `heading` — the `<h2>`
|
|
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
5
|
import { renderLabel } from './internal/label-parts.js';
|
|
6
6
|
import { BookingButton } from './internal/ui.js';
|
|
7
|
-
import { fillLabel } from './labels.js';
|
|
7
|
+
import { fillLabel, labelText } from './labels.js';
|
|
8
8
|
import { slotClass } from './slots.js';
|
|
9
9
|
/**
|
|
10
10
|
* The service step — what should be done?
|
|
@@ -37,7 +37,7 @@ const asIs = (name) => name;
|
|
|
37
37
|
/** The age divider's sentence for `name`. */
|
|
38
38
|
function ageDividerLabel(labels, name, possessive) {
|
|
39
39
|
if (!name)
|
|
40
|
-
return labels['service.ageDivider.unnamed'];
|
|
40
|
+
return labelText(labels['service.ageDivider.unnamed']);
|
|
41
41
|
return fillLabel(labels['service.ageDivider.named'], {
|
|
42
42
|
name,
|
|
43
43
|
nameGenitive: possessive(name),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType, type Ref } 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 { BookingResourceDto, PartyMode } from './types.js';
|
|
5
6
|
/**
|
|
@@ -20,7 +21,7 @@ import type { BookingResourceDto, PartyMode } from './types.js';
|
|
|
20
21
|
* available» included. Arrow keys move and choose, Tab enters and leaves once.
|
|
21
22
|
*/
|
|
22
23
|
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],
|
|
24
|
+
export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
24
25
|
/**
|
|
25
26
|
* - `root` — the `<section>`
|
|
26
27
|
* - `heading` — the `<h2>`
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useId, useRef, } from 'react';
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
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
|
/**
|
|
@@ -83,10 +84,10 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
|
|
|
83
84
|
const options = [
|
|
84
85
|
{
|
|
85
86
|
resourceId: null,
|
|
86
|
-
name: labels['stylist.firstAvailable'],
|
|
87
|
-
shortName: labels['stylist.firstAvailable'],
|
|
88
|
-
subtitle: labels['stylist.firstAvailableSubtitle'],
|
|
89
|
-
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']),
|
|
90
91
|
},
|
|
91
92
|
...(loading
|
|
92
93
|
? held
|
|
@@ -113,7 +114,7 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
|
|
|
113
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,
|
|
114
115
|
// The held option takes one placeholder's place, so the row is
|
|
115
116
|
// 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
|
+
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 })] }));
|
|
117
118
|
}
|
|
118
119
|
/**
|
|
119
120
|
* The options as one radiogroup with a roving tab stop.
|
|
@@ -183,7 +184,7 @@ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPic
|
|
|
183
184
|
* with its next-opening row pulsing like every card still waiting for one. */
|
|
184
185
|
function pendingOption(resourceId, pendingName, labels, format) {
|
|
185
186
|
const known = pendingName?.trim() ? pendingName.trim() : null;
|
|
186
|
-
const name = known ?? labels['stylist.pendingName'];
|
|
187
|
+
const name = known ?? labelText(labels['stylist.pendingName']);
|
|
187
188
|
return {
|
|
188
189
|
resourceId,
|
|
189
190
|
name,
|
|
@@ -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
|
|
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react';
|
|
|
5
5
|
import { cn } from '../lib/utils.js';
|
|
6
6
|
import { renderLabel } from './internal/label-parts.js';
|
|
7
7
|
import { BookingButton, bookingButtonClass } from './internal/ui.js';
|
|
8
|
-
import { fillLabel } from './labels.js';
|
|
8
|
+
import { fillLabel, labelText } from './labels.js';
|
|
9
9
|
import { LiveStatus } from './live-status.js';
|
|
10
10
|
import { slotClass } from './slots.js';
|
|
11
11
|
/**
|
|
@@ -130,7 +130,7 @@ function partyChipLabel(labels, clock, slot, party) {
|
|
|
130
130
|
});
|
|
131
131
|
return fillLabel(labels['time.party.parallel'], {
|
|
132
132
|
time: clock.formatTime(slot.startTs),
|
|
133
|
-
seats: seats.join(labels['time.party.seatSeparator']),
|
|
133
|
+
seats: seats.join(labelText(labels['time.party.seatSeparator'])),
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
/**
|
|
@@ -283,7 +283,7 @@ export function TakenToast({ labels, takenSlotTs, className }) {
|
|
|
283
283
|
}, [takenSlotTs]);
|
|
284
284
|
return (_jsx("div", { role: "status", "aria-live": "polite", className: shown
|
|
285
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)
|
|
286
|
-
: '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" }) })] })) }));
|
|
287
287
|
}
|
|
288
288
|
/**
|
|
289
289
|
* The time step before its openings are in: the same heading, a strip of day
|
|
@@ -301,7 +301,7 @@ export function TimeScreenSkeleton({ labels, days = 7, monthView = false, surcha
|
|
|
301
301
|
size: 'lg',
|
|
302
302
|
className: 'min-w-20 animate-pulse border-border bg-muted text-transparent',
|
|
303
303
|
});
|
|
304
|
-
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))) })] })] })] }));
|
|
305
305
|
}
|
|
306
306
|
// ---------------------------------------------------------------------------
|
|
307
307
|
// Month view
|
|
@@ -323,7 +323,7 @@ function MonthCalendar({ labels, clock, open, onToggle, monthTs, onMonth, strip,
|
|
|
323
323
|
year,
|
|
324
324
|
});
|
|
325
325
|
const selectedKey = clock.dayKey(selectedDay);
|
|
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": 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) => {
|
|
327
327
|
if (cell === null) {
|
|
328
328
|
// The blanks before the first of the month; the position is
|
|
329
329
|
// the only thing that tells them apart.
|
|
@@ -366,7 +366,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
|
|
|
366
366
|
else if (partySlot && party) {
|
|
367
367
|
label = partyChipLabel(labels, clock, partySlot, party);
|
|
368
368
|
}
|
|
369
|
-
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));
|
|
370
370
|
}) })] }, daypart.key));
|
|
371
371
|
});
|
|
372
372
|
}
|
|
@@ -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>`
|
|
@@ -5,6 +5,7 @@ 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
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?
|
|
@@ -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
|
|
@@ -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
|
@@ -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`.
|