@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
package/dist/booking/labels.js
CHANGED
|
@@ -3,15 +3,52 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every word a booking screen shows arrives through its `labels` prop — meda
|
|
5
5
|
* ships no default brand and no default language. A screen's labels are a flat
|
|
6
|
-
* record
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* record keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one merged pack
|
|
7
|
+
* (`BookingLabels`) can be handed to every screen, and a customer can override
|
|
8
|
+
* a single sentence without touching the rest.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* A label is a `BookingLabel`: a string, or an array of strings. Both are
|
|
11
|
+
* serialisable, so a pack can travel from a server component to the client as
|
|
12
|
+
* a prop. Variable parts are `{name}` placeholders.
|
|
13
|
+
*
|
|
14
|
+
* The two forms differ only in the DOM they render as element children:
|
|
15
|
+
*
|
|
16
|
+
* - a **string** renders as ONE text node once filled, like a template
|
|
17
|
+
* literal: `'Total {total} · paid at the salon'` → «Total 840 kr · paid at
|
|
18
|
+
* the salon», one node;
|
|
19
|
+
* - an **array** renders one text node per element, each element filled with
|
|
20
|
+
* the same values: `['Total ', '{total}', ' · ', 'paid at the salon']` →
|
|
21
|
+
* four nodes, the same DOM as `Total {total}{' · '}paid at the salon` in JSX.
|
|
22
|
+
* An element that fills to `''` renders nothing.
|
|
23
|
+
*
|
|
24
|
+
* A browser lays text out per text node, so the same sentence split
|
|
25
|
+
* differently can land its later glyphs a sub-pixel apart. The pack decides
|
|
26
|
+
* where a sentence breaks; a screen never splits a string on its own.
|
|
27
|
+
*
|
|
28
|
+
* Text-only targets (`aria-label`, `title`, a file name, an error string) read
|
|
29
|
+
* a label through `fillLabel` / `labelText`, which join an array's elements.
|
|
13
30
|
*/
|
|
14
|
-
/**
|
|
15
|
-
export function
|
|
31
|
+
/** A label as plain text: an array's elements joined, a string as it is. */
|
|
32
|
+
export function labelText(label) {
|
|
33
|
+
return typeof label === 'string' ? label : label.join('');
|
|
34
|
+
}
|
|
35
|
+
function fillOne(template, values) {
|
|
16
36
|
return template.replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(values, key) ? String(values[key]) : match);
|
|
17
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Replaces `{key}` placeholders in `template`, as plain text. An array is
|
|
40
|
+
* filled element by element and joined. Unknown keys are left as written.
|
|
41
|
+
*/
|
|
42
|
+
export function fillLabel(template, values) {
|
|
43
|
+
return typeof template === 'string'
|
|
44
|
+
? fillOne(template, values)
|
|
45
|
+
: template.map((piece) => fillOne(piece, values)).join('');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The text nodes a label fills to: one for a string, one per element for an
|
|
49
|
+
* array, with empty ones dropped. `[]` when the label fills to nothing.
|
|
50
|
+
*/
|
|
51
|
+
export function fillLabelPieces(template, values) {
|
|
52
|
+
const pieces = typeof template === 'string' ? [template] : template;
|
|
53
|
+
return pieces.map((piece) => fillOne(piece, values)).filter((piece) => piece !== '');
|
|
54
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
|
2
|
+
import type { BookingLabel } from './labels.js';
|
|
2
3
|
import { type OtpSlotsLabels, type OtpSlotsSlot } from './otp-slots.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingGuardian } from './types.js';
|
|
@@ -9,7 +10,7 @@ export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.c
|
|
|
9
10
|
* Placeholders: `login.codeHelp` `{email}`, `login.vippsSentTo` `{to}`,
|
|
10
11
|
* `login.cooldownWait` `{seconds}`, `login.resendIn` `{time}` («0:30»).
|
|
11
12
|
*/
|
|
12
|
-
export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number],
|
|
13
|
+
export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number], BookingLabel> & OtpSlotsLabels & VippsButtonLabels;
|
|
13
14
|
/**
|
|
14
15
|
* - `root`: the panel's column.
|
|
15
16
|
* - `notice`: a non-error sentence in the live region (errors use `Field.Error`).
|
|
@@ -82,7 +83,7 @@ export interface LoginPanelProps {
|
|
|
82
83
|
memory?: LoginPanelMemory;
|
|
83
84
|
onMemoryChange?: Dispatch<SetStateAction<LoginPanelMemory>>;
|
|
84
85
|
/** Draws the heading («Log in» / «Check your e-mail»). Default: none — the page has its own. */
|
|
85
|
-
heading?: (text:
|
|
86
|
+
heading?: (text: ReactNode) => ReactNode;
|
|
86
87
|
/** Drawn above the Vipps button on the first screen. */
|
|
87
88
|
intro?: ReactNode;
|
|
88
89
|
/** Wait between codes. Default 30 s. */
|
|
@@ -3,8 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { useEffect, useId, useRef, useState, useTransition, } from 'react';
|
|
4
4
|
import { Field } from '../primitives/field.js';
|
|
5
5
|
import { Input } from '../primitives/input.js';
|
|
6
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
6
7
|
import { BOOKING_INPUT_CLASS, BookingButton } from './internal/ui.js';
|
|
7
|
-
import { fillLabel } from './labels.js';
|
|
8
8
|
import { OTP_SLOTS_LABEL_KEYS, OtpSlots, } from './otp-slots.js';
|
|
9
9
|
import { slotClass } from './slots.js';
|
|
10
10
|
import { VIPPS_BUTTON_LABEL_KEYS, VippsButton, } from './vipps-button.js';
|
|
@@ -211,7 +211,8 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
211
211
|
setNotice(null);
|
|
212
212
|
}
|
|
213
213
|
const isFieldError = notice !== null && FIELD_ERRORS.has(notice);
|
|
214
|
-
|
|
214
|
+
// `null` for a blank notice, so no input is described by an empty one.
|
|
215
|
+
const noticeText = notice === null ? null : renderLabel(labels[`login.notice.${notice}`]);
|
|
215
216
|
// One live region for the whole flow, ABOVE the switch between the two
|
|
216
217
|
// screens so it stays mounted across it: a region that arrives already
|
|
217
218
|
// holding its sentence is often read as nothing at all. It doubles as the
|
|
@@ -221,19 +222,19 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
221
222
|
// The countdown on the button changes every second; this region says only
|
|
222
223
|
// the two things worth hearing — how long, and that it is over.
|
|
223
224
|
const cooldownSentence = waitSeconds > 0
|
|
224
|
-
?
|
|
225
|
+
? renderLabel(labels['login.cooldownWait'], { seconds: Math.ceil(resendCooldownMs / 1000) })
|
|
225
226
|
: labels['login.cooldownReady'];
|
|
226
227
|
const cooldownRegion = (_jsx("p", { "aria-live": "polite", className: "sr-only", children: mode === 'code' ? cooldownSentence : null }));
|
|
227
228
|
const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
|
|
228
|
-
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(mode === 'start' ?
|
|
229
|
+
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(renderLabel(labels[mode === 'start' ? 'login.heading' : 'login.codeHeading'])), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
|
|
229
230
|
? labels['login.vippsSentToUnknown']
|
|
230
|
-
:
|
|
231
|
+
: renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })] })), _jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": isFieldError || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
|
|
231
232
|
// Read-only rather than disabled while a request is out, so
|
|
232
233
|
// the caret stays in the field instead of falling out of a
|
|
233
234
|
// dialog; a wrong code then only has to empty it.
|
|
234
235
|
readOnly: checking, invalid: isFieldError, describedBy: `${codeId}-help${noticeText ? ` ${noticeId}` : ''}`, labels: labels, classNames: otpClassNames, onChange: setCode, onComplete: verify }) }), _jsx(Field.Description, { id: `${codeId}-help`, children: mode === 'vipps'
|
|
235
236
|
? labels['login.codeHelpVipps']
|
|
236
|
-
:
|
|
237
|
-
?
|
|
237
|
+
: renderLabel(labels['login.codeHelp'], { email: sentTo }) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.submitCode'] })] }), _jsxs("div", { className: slotClass(classNames, 'links', 'flex flex-wrap items-center gap-4 text-sm'), children: [mode !== 'vipps' && (_jsx("button", { type: "button", disabled: pending || waitSeconds > 0, onClick: () => requestCode(sentTo, true), className: link, children: waitSeconds > 0
|
|
238
|
+
? renderLabel(labels['login.resendIn'], { time: timer(waitSeconds) })
|
|
238
239
|
: labels['login.resend'] })), _jsx("button", { type: "button", disabled: pending, onClick: switchEmail, className: link, children: mode === 'vipps' ? labels['login.useEmailInstead'] : labels['login.switchEmail'] })] })] }))] }));
|
|
239
240
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { type BookingLabel } from './labels.js';
|
|
1
2
|
import { type LoginPanelLabels, type LoginPanelProps } from './login-panel.js';
|
|
2
3
|
import { type SlotClassNames } from './slots.js';
|
|
3
4
|
import type { BookingGuardian } from './types.js';
|
|
4
5
|
export declare const LOGIN_SHEET_OWN_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close"];
|
|
5
6
|
export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
6
7
|
/** `loginSheet.prompt` holds a `{trigger}` placeholder where the trigger button goes. */
|
|
7
|
-
export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number],
|
|
8
|
+
export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], BookingLabel> & LoginPanelLabels;
|
|
8
9
|
/**
|
|
9
10
|
* - `prompt`: the «have an account? Log in» row.
|
|
10
11
|
* - `trigger`: the button inside it.
|
|
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
|
|
5
5
|
import { Sheet } from '../primitives/sheet.js';
|
|
6
6
|
import { labelParts } from './internal/label-parts.js';
|
|
7
7
|
import { BookingButton } from './internal/ui.js';
|
|
8
|
+
import { labelText } from './labels.js';
|
|
8
9
|
import { LOGIN_PANEL_LABEL_KEYS, LoginPanel, loginMemoryFor, } from './login-panel.js';
|
|
9
10
|
import { slotClass } from './slots.js';
|
|
10
11
|
export const LOGIN_SHEET_OWN_LABEL_KEYS = [
|
|
@@ -41,7 +42,7 @@ export function LoginSheet({ labels, onStartLogin, onVerify, onVipps, onSignedIn
|
|
|
41
42
|
const [signedIn, setSignedIn] = useState(false);
|
|
42
43
|
return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [trigger && !signedIn && (_jsx("p", { className: slotClass(classNames, 'prompt', 'text-sm text-muted-foreground'), children: labelParts(labels['loginSheet.prompt'], {
|
|
43
44
|
trigger: (_jsx(Sheet.Trigger, { className: slotClass(classNames, 'trigger', 'font-semibold text-primary underline underline-offset-4'), children: labels['loginSheet.trigger'] })),
|
|
44
|
-
}) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labels['loginSheet.vipps'], resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
|
|
45
|
+
}) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labelText(labels['loginSheet.vipps']), resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
|
|
45
46
|
setSignedIn(true);
|
|
46
47
|
setOpen(false);
|
|
47
48
|
onSignedIn(guardian);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BookingDaypart, BookingFormat } from './format.js';
|
|
2
|
+
import { type BookingLabel } from './labels.js';
|
|
2
3
|
import { type SlotClassNames } from './slots.js';
|
|
3
4
|
import { type TimeScreenComponents, type TimeScreenLabels, type TimeScreenSlot } from './time-screen.js';
|
|
4
5
|
import type { BookingDayDto, BookingManageDto, BookingSlotDto } from './types.js';
|
|
@@ -33,7 +34,7 @@ export declare const MANAGE_SCREEN_LABEL_KEYS: readonly ["manage.heading", "mana
|
|
|
33
34
|
* The three `cancel.reason.*` strings are both the chips' text and the value
|
|
34
35
|
* `onCancel` receives.
|
|
35
36
|
*/
|
|
36
|
-
export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number],
|
|
37
|
+
export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number], BookingLabel> & TimeScreenLabels;
|
|
37
38
|
/** Why a mutation failed. Map anything the route did not name to `unavailable`. */
|
|
38
39
|
export type ManageErrorCode = 'windowPassed' | 'slotTaken' | 'notFound' | 'conflict' | 'invalidInput' | 'unavailable';
|
|
39
40
|
/**
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
5
6
|
import { BookingButton } from './internal/ui.js';
|
|
6
|
-
import { fillLabel } from './labels.js';
|
|
7
|
+
import { fillLabel, labelText } from './labels.js';
|
|
7
8
|
import { slotClass } from './slots.js';
|
|
8
9
|
import { TimeScreen, } from './time-screen.js';
|
|
9
10
|
/**
|
|
@@ -120,7 +121,7 @@ const LINK_CLASS = 'font-semibold text-primary underline underline-offset-4';
|
|
|
120
121
|
/** «Name's appointment» when a name was given, the bare noun when not. */
|
|
121
122
|
function theirAppointment(labels, name) {
|
|
122
123
|
if (!name)
|
|
123
|
-
return labels['manage.appointment.unnamed'];
|
|
124
|
+
return labelText(labels['manage.appointment.unnamed']);
|
|
124
125
|
return fillLabel(labels[/[sxz]$/i.test(name) ? 'manage.appointment.namedSibilant' : 'manage.appointment.named'], { name });
|
|
125
126
|
}
|
|
126
127
|
/**
|
|
@@ -141,16 +142,20 @@ function remainingLabel(labels, ms) {
|
|
|
141
142
|
return fillLabel(labels['manage.remaining.in'], { amount: hourPart });
|
|
142
143
|
const minutes = Math.floor((ms % HOUR_MS) / 60_000);
|
|
143
144
|
if (minutes < 1)
|
|
144
|
-
return labels['manage.remaining.underMinute'];
|
|
145
|
+
return labelText(labels['manage.remaining.underMinute']);
|
|
145
146
|
return fillLabel(labels['manage.remaining.in'], {
|
|
146
147
|
amount: fillLabel(labels[minutes === 1 ? 'manage.remaining.minute.one' : 'manage.remaining.minute.other'], { count: minutes }),
|
|
147
148
|
});
|
|
148
149
|
}
|
|
149
150
|
function ringUs(labels, phone) {
|
|
150
|
-
return phone
|
|
151
|
+
return phone
|
|
152
|
+
? fillLabel(labels['manage.ringUsOn'], { phone })
|
|
153
|
+
: labelText(labels['manage.ringUs']);
|
|
151
154
|
}
|
|
152
155
|
function callUs(labels, phone) {
|
|
153
|
-
return phone
|
|
156
|
+
return phone
|
|
157
|
+
? fillLabel(labels['manage.callUsOn'], { phone })
|
|
158
|
+
: labelText(labels['manage.callUs']);
|
|
154
159
|
}
|
|
155
160
|
/** What the visitor is told went wrong — never the upstream's own words. */
|
|
156
161
|
function errorSentence(labels, code, phone) {
|
|
@@ -158,13 +163,13 @@ function errorSentence(labels, code, phone) {
|
|
|
158
163
|
case 'windowPassed':
|
|
159
164
|
return fillLabel(labels['manage.error.windowPassed'], { ringUs: ringUs(labels, phone) });
|
|
160
165
|
case 'slotTaken':
|
|
161
|
-
return labels['manage.error.slotTaken'];
|
|
166
|
+
return labelText(labels['manage.error.slotTaken']);
|
|
162
167
|
case 'notFound':
|
|
163
168
|
return fillLabel(labels['manage.error.notFound'], { callUs: callUs(labels, phone) });
|
|
164
169
|
case 'conflict':
|
|
165
170
|
return fillLabel(labels['manage.error.conflict'], { ringUs: ringUs(labels, phone) });
|
|
166
171
|
case 'invalidInput':
|
|
167
|
-
return labels['manage.error.invalidInput'];
|
|
172
|
+
return labelText(labels['manage.error.invalidInput']);
|
|
168
173
|
default:
|
|
169
174
|
return fillLabel(labels['manage.error.unavailable'], { ringUs: ringUs(labels, phone) });
|
|
170
175
|
}
|
|
@@ -199,7 +204,7 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
|
|
|
199
204
|
? null
|
|
200
205
|
: format.stylistName(booking.resourceName) || null;
|
|
201
206
|
const serviceLabel = booking.serviceName === UNKNOWN_SERVICE
|
|
202
|
-
? labels['manage.serviceFallback']
|
|
207
|
+
? labelText(labels['manage.serviceFallback'])
|
|
203
208
|
: booking.serviceName;
|
|
204
209
|
// The LARGER window wins: the sentence promises both actions, and the earlier
|
|
205
210
|
// of the two deadlines is the one true of both.
|
|
@@ -271,14 +276,14 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
|
|
|
271
276
|
const panelClass = (base) => slotClass(classNames, 'panel', base);
|
|
272
277
|
return (_jsxs("section", { "aria-labelledby": "booking-manage-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: "booking-manage-heading", className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['manage.heading'] }), error !== null && (_jsx("p", { role: "alert", className: slotClass(classNames, 'alert', 'rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-3 text-sm'), children: errorSentence(labels, error, phone) })), view === 'cancelled' && (_jsxs("div", { className: panelClass('space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-medium", children: labels['manage.cancelled'] }), _jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.cancelled.findNew'] })] })), view === 'moved' && (_jsxs("div", { className: panelClass('space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-medium", children: movedTo === null
|
|
273
278
|
? labels['manage.moved']
|
|
274
|
-
:
|
|
279
|
+
: renderLabel(labels['manage.movedTo'], {
|
|
275
280
|
dateTime: format.clock.dateTime(movedTo),
|
|
276
281
|
}) }), newManageHref ? (_jsx("a", { href: newManageHref, className: LINK_CLASS, children: labels['manage.moved.view'] })) : (_jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.moved.noLink'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: callUs(labels, phone) }), ' ', labels['manage.moved.noLinkSuffix']] }))] })), view !== 'cancelled' && view !== 'moved' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: slotClass(classNames, 'card', 'space-y-1 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: booking.bookedForName
|
|
277
|
-
?
|
|
282
|
+
? renderLabel(labels['manage.card.named'], {
|
|
278
283
|
name: booking.bookedForName,
|
|
279
284
|
service: serviceLabel,
|
|
280
285
|
})
|
|
281
|
-
: serviceLabel }), _jsx("p", { className: "tabular-nums", children:
|
|
286
|
+
: serviceLabel }), _jsx("p", { className: "tabular-nums", children: renderLabel(labels[stylist ? 'manage.card.whenWithStylist' : 'manage.card.when'], {
|
|
282
287
|
dateTime: format.clock.dateTime(booking.startTs),
|
|
283
288
|
stylist: stylist ?? '',
|
|
284
289
|
price: format.price(booking.amountOre),
|
|
@@ -287,11 +292,11 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
|
|
|
287
292
|
setError(null);
|
|
288
293
|
setView('cancel');
|
|
289
294
|
} })) : (_jsx(ClosedBooking, { labels: labels, format: format, status: booking.status, bookingHref: bookingHref, phone: phone, className: panelClass('space-y-3 rounded-lg border border-border bg-card px-5 py-4') }))), view === 'party' && (_jsx(PartyChoice, { labels: labels, format: format, bookedForName: booking.bookedForName, phone: phone, onJustThisOne: goToTime, onBack: () => setView('overview'), className: panelClass('space-y-4 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4') })), view === 'time' && (_jsxs("div", { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: stylist
|
|
290
|
-
?
|
|
295
|
+
? renderLabel(labels['manage.reschedule.leadWithStylist'], {
|
|
291
296
|
service: serviceLabel,
|
|
292
297
|
stylist,
|
|
293
298
|
})
|
|
294
|
-
:
|
|
299
|
+
: renderLabel(labels['manage.reschedule.lead'], { service: serviceLabel }) }), slotsFailed && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [labels['manage.reschedule.failed'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.reschedule.failedSuffix']] })), !slotsFailed && slots === null && (_jsx("p", { className: "text-sm text-muted-foreground", children: labels['manage.reschedule.loading'] })), !slotsFailed && slots !== null && (_jsx(TimeScreen, { labels: labels, format: format, dayparts: dayparts, slots: slots, days: reschedule.days, openDays: reschedule.openDays ?? null, currentSlotTs: booking.startTs, weekendNote: weekendNoteFor(pricing), phone: phone, now: nowProp, classNames: timeClassNames, components: components, onPick: (slot) => {
|
|
295
300
|
setPending(slot);
|
|
296
301
|
setError(null);
|
|
297
302
|
setView('move');
|
|
@@ -311,13 +316,15 @@ function Overview({ labels, format, booking, freeUntilTs, remaining, phone, icsH
|
|
|
311
316
|
// The largest window that has already closed — the number the sentence below
|
|
312
317
|
// quotes. Zero when both are open, and then it is not rendered.
|
|
313
318
|
const blockedHours = Math.max(booking.canCancel ? 0 : booking.cancelWindowHours, booking.canReschedule ? 0 : booking.rescheduleWindowHours);
|
|
314
|
-
return (_jsxs(_Fragment, { children: [booking.canCancel && booking.canReschedule && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [
|
|
319
|
+
return (_jsxs(_Fragment, { children: [booking.canCancel && booking.canReschedule && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [renderLabel(labels['manage.freeUntil'], {
|
|
320
|
+
dateTime: format.clock.dateTime(freeUntilTs),
|
|
321
|
+
}), remaining !== null && _jsxs("span", { children: [" (", remaining, ")"] })] })), _jsxs("div", { className: cn('flex flex-wrap items-center gap-4', actionsClassName), children: [booking.canReschedule ? (_jsx(BookingButton, { size: "lg", onClick: onChange, children: labels['manage.change'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.callToChange'], { hours: booking.rescheduleWindowHours }) })), booking.canCancel ? (_jsx(BookingButton, { size: "lg", variant: "outline", onClick: onCancel, children: labels['manage.cancel'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.callToCancel'], { hours: booking.cancelWindowHours }) })), icsHref !== null && (_jsx("a", { href: icsHref, download: icsFileName ?? true, className: LINK_CLASS, children: labels['manage.calendar'] }))] }), blockedHours > 0 && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [renderLabel(labels['manage.blocked'], { hours: blockedHours }), ' ', phone ? (_jsxs(_Fragment, { children: [_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.ringUsOn'], { phone }) }), labels['manage.blocked.suffix']] })) : (labels['manage.blocked.plain'])] }))] }));
|
|
315
322
|
}
|
|
316
323
|
/** A booking no longer open to either action, saying which kind it is. */
|
|
317
324
|
function ClosedBooking({ labels, format, status, bookingHref, phone, className, }) {
|
|
318
325
|
const sentence = labels[CLOSED_SENTENCE[status] ?? 'manage.closed.pending'];
|
|
319
326
|
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "text-sm", children: sentence }), status === 'pending' ? (_jsx("p", { className: "text-sm", children: _jsx(PhoneSentence, { format: format, phone: phone, children: phone
|
|
320
|
-
?
|
|
327
|
+
? renderLabel(labels['manage.closed.pendingCall'], { phone })
|
|
321
328
|
: labels['manage.closed.pendingCallPlain'] }) })) : (_jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.bookNew'] }))] }));
|
|
322
329
|
}
|
|
323
330
|
/**
|
|
@@ -327,8 +334,8 @@ function ClosedBooking({ labels, format, status, bookingHref, phone, className,
|
|
|
327
334
|
* contiguous, which the sub-line says.
|
|
328
335
|
*/
|
|
329
336
|
function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
|
|
330
|
-
const who = bookedForName ?? labels['manage.party.unnamed'];
|
|
331
|
-
return (_jsxs("div", { className: className, children: [_jsx("h2", { className: "font-sans text-lg font-bold", children:
|
|
337
|
+
const who = bookedForName ?? labelText(labels['manage.party.unnamed']);
|
|
338
|
+
return (_jsxs("div", { className: className, children: [_jsx("h2", { className: "font-sans text-lg font-bold", children: renderLabel(labels['manage.party.heading'], { who }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", onClick: onJustThisOne, children: renderLabel(labels['manage.party.justThisOne'], { who }) }), _jsx(BookingButton, { size: "lg", variant: "outline", onClick: onBack, children: labels['manage.back'] })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.party.note'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.party.noteSuffix']] })] }));
|
|
332
339
|
}
|
|
333
340
|
/**
|
|
334
341
|
* The single confirm for a move. The weekend note appears only when the new
|
|
@@ -340,13 +347,13 @@ function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy,
|
|
|
340
347
|
const nextOre = pricing ? pricing.priceAt(pending.startTs) : 0;
|
|
341
348
|
const nowOre = pricing ? pricing.priceAt(booking.startTs) : 0;
|
|
342
349
|
const dearer = pricing !== null && nextOre > nowOre;
|
|
343
|
-
const question =
|
|
350
|
+
const question = renderLabel(labels[stylist ? 'manage.move.questionWithStylist' : 'manage.move.question'], {
|
|
344
351
|
appointment: theirAppointment(labels, booking.bookedForName),
|
|
345
352
|
from: `${clock.date(booking.startTs)} ${clock.formatTime(booking.startTs)}`,
|
|
346
353
|
to: `${clock.date(pending.startTs)} ${clock.formatTime(pending.startTs)}`,
|
|
347
354
|
stylist: stylist ?? '',
|
|
348
355
|
});
|
|
349
|
-
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: question }), dearer && pricing && (_jsx("p", { className: "text-sm", children:
|
|
356
|
+
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: question }), dearer && pricing && (_jsx("p", { className: "text-sm", children: renderLabel(labels['manage.move.surcharge'], {
|
|
350
357
|
weekday: clock.weekday(pending.startTs),
|
|
351
358
|
price: format.price(nextOre),
|
|
352
359
|
pct: pricing.weekendSurchargePct,
|
|
@@ -354,16 +361,17 @@ function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy,
|
|
|
354
361
|
}
|
|
355
362
|
/** The single confirm for a cancellation, with the reason asked and never required. */
|
|
356
363
|
function ConfirmCancel({ labels, format, booking, reason, busy, onPickReason, onConfirm, onBack, className, }) {
|
|
357
|
-
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children:
|
|
364
|
+
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: renderLabel(labels['manage.cancel.question'], {
|
|
358
365
|
appointment: theirAppointment(labels, booking.bookedForName),
|
|
359
366
|
dateTime: format.clock.dateTime(booking.startTs),
|
|
360
367
|
hours: booking.cancelWindowHours,
|
|
361
368
|
}) }), _jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['manage.cancel.reasonLegend'] }), CANCEL_REASON_KEYS.map((key) => {
|
|
362
|
-
|
|
369
|
+
// The reason travels as text; the chip shows the label in its own pieces.
|
|
370
|
+
const option = labelText(labels[key]);
|
|
363
371
|
const picked = reason === option;
|
|
364
372
|
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": picked,
|
|
365
373
|
// Tapping the chosen chip again clears it: the answer is
|
|
366
374
|
// optional, so there has to be a way back to having given none.
|
|
367
|
-
onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children:
|
|
375
|
+
onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children: labels[key] }, key));
|
|
368
376
|
})] }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", disabled: busy, onClick: onConfirm, children: labels['manage.cancel.confirm'] }), _jsx(BookingButton, { size: "lg", variant: "outline", disabled: busy, onClick: onBack, children: labels['manage.cancel.keep'] })] })] }));
|
|
369
377
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type BookingLabel } from './labels.js';
|
|
1
2
|
import { type SlotClassNames } from './slots.js';
|
|
2
3
|
export declare const OTP_SLOTS_LABEL_KEYS: readonly ["otp.label"];
|
|
3
|
-
export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number],
|
|
4
|
+
export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number], BookingLabel>;
|
|
4
5
|
/**
|
|
5
6
|
* - `root`: the positioned wrapper.
|
|
6
7
|
* - `row`: the six-box grid.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
|
+
import { labelText } from './labels.js';
|
|
5
6
|
import { slotClass } from './slots.js';
|
|
6
7
|
const LENGTH = 6;
|
|
7
8
|
function digitsOnly(value) {
|
|
@@ -98,7 +99,7 @@ export function OtpSlots({ id, name = 'code', value, disabled = false, readOnly
|
|
|
98
99
|
return (_jsxs("div", { className: slotClass(classNames, 'root', 'relative'), children: [_jsx("input", { ref: hiddenRef, id: fieldId, name: name, inputMode: "numeric", autoComplete: "one-time-code", autoCorrect: "off", spellCheck: false,
|
|
99
100
|
// `\d*` rather than `[0-9]{6}`: it is the spelling iOS reads as «show the
|
|
100
101
|
// number pad», and the length is `minLength`/`maxLength`'s job.
|
|
101
|
-
pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labels['otp.label'], onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
|
|
102
|
+
pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labelText(labels['otp.label']), onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
|
|
102
103
|
event.preventDefault();
|
|
103
104
|
focusHidden();
|
|
104
105
|
}, children: Array.from({ length: LENGTH }, (_, index) => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
export declare const AGE_CONFIRM_CARD_LABEL_KEYS: readonly ["ageConfirm.heading", "ageConfirm.lead", "ageConfirm.legend", "ageConfirm.birthYear", "ageConfirm.birthMonth", "ageConfirm.birthMonthUnknown", "ageConfirm.confirm", "ageConfirm.dismiss"];
|
|
3
4
|
/** `heading` and `legend` take `{name}`. */
|
|
4
|
-
export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number],
|
|
5
|
+
export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number], BookingLabel>;
|
|
5
6
|
/** `root` (the section), `heading`, `form`, `actions` (the button row). */
|
|
6
7
|
export type AgeConfirmCardSlot = 'root' | 'heading' | 'form' | 'actions';
|
|
7
8
|
export interface AgeConfirmCardProps {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useState } from 'react';
|
|
4
4
|
import { Field } from '../../primitives/field.js';
|
|
5
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
5
6
|
import { BookingButton } from '../internal/ui.js';
|
|
6
|
-
import { fillLabel } from '../labels.js';
|
|
7
7
|
import { slotClass } from '../slots.js';
|
|
8
8
|
export const AGE_CONFIRM_CARD_LABEL_KEYS = [
|
|
9
9
|
'ageConfirm.heading',
|
|
@@ -35,8 +35,8 @@ export function AgeConfirmCard({ labels, name, birthYear, years, months, busy, e
|
|
|
35
35
|
const [month, setMonth] = useState('');
|
|
36
36
|
const headingId = `${id}-heading`;
|
|
37
37
|
const errorId = `${id}-error`;
|
|
38
|
-
return (_jsxs("section", { "aria-labelledby": headingId, "aria-busy": busy || undefined, className: slotClass(classNames, 'root', 'space-y-3 rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-4'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-lg font-bold'), children:
|
|
38
|
+
return (_jsxs("section", { "aria-labelledby": headingId, "aria-busy": busy || undefined, className: slotClass(classNames, 'root', 'space-y-3 rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-4'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-lg font-bold'), children: renderLabel(labels['ageConfirm.heading'], { name }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: labels['ageConfirm.lead'] })] }), _jsxs("form", { onSubmit: (event) => {
|
|
39
39
|
event.preventDefault();
|
|
40
40
|
onConfirm({ birthYear: year, birthMonth: month });
|
|
41
|
-
}, "aria-describedby": error ? errorId : undefined, className: slotClass(classNames, 'form', 'space-y-3'), children: [_jsxs("fieldset", { disabled: busy, className: "grid min-w-0 grid-cols-2 gap-3", children: [_jsx("legend", { className: "sr-only", children:
|
|
41
|
+
}, "aria-describedby": error ? errorId : undefined, className: slotClass(classNames, 'form', 'space-y-3'), children: [_jsxs("fieldset", { disabled: busy, className: "grid min-w-0 grid-cols-2 gap-3", children: [_jsx("legend", { className: "sr-only", children: renderLabel(labels['ageConfirm.legend'], { name }) }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['ageConfirm.birthYear'] }), _jsx("select", { id: `${id}-year`, value: year, onChange: (event) => setYear(event.target.value), className: SELECT_CLASS, children: years.map((option) => (_jsx("option", { value: String(option), children: option }, option))) })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['ageConfirm.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: month, onChange: (event) => setMonth(event.target.value), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['ageConfirm.birthMonthUnknown'] }), months.map((label, index) => (_jsx("option", { value: String(index + 1), children: label }, label)))] })] })] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { type: "submit", size: "sm", disabled: busy, children: labels['ageConfirm.confirm'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: onDismiss, children: labels['ageConfirm.dismiss'] }), _jsx("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: error && _jsx(Field.Error, { id: errorId, children: error }) })] })] })] }));
|
|
42
42
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import type { BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { ChildSummary } from '../types.js';
|
|
5
6
|
export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "childCards.neverVisited", "childCards.lastVisit", "childCards.lastVisitNoService", "childCards.book", "childCards.stylist", "childCards.next", "childCards.none"];
|
|
@@ -8,7 +9,7 @@ export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "chil
|
|
|
8
9
|
* `{age}` `{service}` `{date}`; `childCards.lastVisitNoService` `{age}`
|
|
9
10
|
* `{date}`; `childCards.book` `{name}`.
|
|
10
11
|
*/
|
|
11
|
-
export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number],
|
|
12
|
+
export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number], BookingLabel>;
|
|
12
13
|
/**
|
|
13
14
|
* - `list`: the grid. - `card`: one child. - `avatar`: the initial.
|
|
14
15
|
* - `name`, `detail`: the two lines. - `details`: the full card's facts.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { ArrowRight } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../lib/utils.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
6
6
|
import { slotClass } from '../slots.js';
|
|
7
7
|
export const CHILD_CARDS_LABEL_KEYS = [
|
|
8
8
|
'childCards.empty',
|
|
@@ -20,11 +20,11 @@ const AVATAR_TINTS = ['bg-secondary/30', 'bg-primary/10', 'bg-accent'];
|
|
|
20
20
|
function lastVisitLine(child, labels, format) {
|
|
21
21
|
const age = format.ageLabel(child.ageRange);
|
|
22
22
|
if (child.lastVisitTs === null)
|
|
23
|
-
return
|
|
23
|
+
return renderLabel(labels['childCards.neverVisited'], { age });
|
|
24
24
|
const date = format.clock.date(child.lastVisitTs);
|
|
25
25
|
return child.serviceName
|
|
26
|
-
?
|
|
27
|
-
:
|
|
26
|
+
? renderLabel(labels['childCards.lastVisit'], { age, service: child.serviceName, date })
|
|
27
|
+
: renderLabel(labels['childCards.lastVisitNoService'], { age, date });
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* «My children» — one card per child, and one tap to book them again. A plain
|
|
@@ -44,7 +44,7 @@ export function ChildCards({ kids, variant = 'full', hrefFor, onBook, empty, sty
|
|
|
44
44
|
export function DefaultChildCard({ child, index, variant, href, onBook, stylistNames, labels, format, classNames, }) {
|
|
45
45
|
return (_jsxs("li", { className: slotClass(classNames, 'card', 'flex gap-4 rounded-lg border border-border bg-card px-5 py-4', variant === 'full' ? 'flex-col' : 'items-center'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("span", { "aria-hidden": "true", className: slotClass(classNames, 'avatar', 'flex size-11 shrink-0 items-center justify-center rounded-full font-sans text-lg font-bold', AVATAR_TINTS[index % AVATAR_TINTS.length]), children: child.name.trim().charAt(0).toUpperCase() }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: slotClass(classNames, 'name', 'block truncate font-semibold'), children: child.name }), _jsx("span", { className: slotClass(classNames, 'detail', 'block truncate text-sm text-muted-foreground'), children: lastVisitLine(child, labels, format) })] })] }), variant === 'full' && (_jsx(ChildDetails, { child: child, stylistNames: stylistNames, labels: labels, format: format, className: classNames?.details })), _jsxs("a", { href: href, onClick: onBook ? () => onBook(child) : undefined, className: slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4', variant === 'full'
|
|
46
46
|
? 'inline-flex items-center gap-2 no-underline'
|
|
47
|
-
: 'ms-auto shrink-0 text-sm'), children: [
|
|
47
|
+
: 'ms-auto shrink-0 text-sm'), children: [renderLabel(labels['childCards.book'], { name: child.name }), variant === 'full' && _jsx(ArrowRight, { "aria-hidden": "true", className: "size-4" })] })] }));
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
50
|
* The full card's two facts beyond the last visit: the usual stylist and the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
import { type PortalActionFailure, type PortalActionResult } from './action-result.js';
|
|
3
4
|
export declare const DATA_CONTROLS_LABEL_KEYS: readonly ["dataControls.heading", "dataControls.export", "dataControls.delete", "dataControls.confirmHeading", "dataControls.consequences", "dataControls.confirmLabel", "dataControls.confirmHelp", "dataControls.wrongWord", "dataControls.deleteForever", "dataControls.cancel", "dataControls.unreachable", "dataControls.confirmWord"];
|
|
@@ -5,7 +6,7 @@ export declare const DATA_CONTROLS_LABEL_KEYS: readonly ["dataControls.heading",
|
|
|
5
6
|
* `confirmWord` is the literal the visitor must type (e.g. «DELETE»);
|
|
6
7
|
* `confirmLabel` and `wrongWord` take `{word}`.
|
|
7
8
|
*/
|
|
8
|
-
export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number],
|
|
9
|
+
export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number], BookingLabel>;
|
|
9
10
|
/**
|
|
10
11
|
* `root` (the section), `heading`, `actions` (the export/delete row),
|
|
11
12
|
* `confirm` (the confirm panel form).
|
|
@@ -3,8 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useId, useState, useTransition } from 'react';
|
|
4
4
|
import { Field } from '../../primitives/field.js';
|
|
5
5
|
import { Input } from '../../primitives/input.js';
|
|
6
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
6
7
|
import { BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
|
|
7
|
-
import { fillLabel } from '../labels.js';
|
|
8
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
8
9
|
import { slotClass } from '../slots.js';
|
|
9
10
|
import { settle } from './action-result.js';
|
|
10
11
|
export const DATA_CONTROLS_LABEL_KEYS = [
|
|
@@ -50,7 +51,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
50
51
|
const [error, setError] = useState(null);
|
|
51
52
|
const [exportError, setExportError] = useState(null);
|
|
52
53
|
const [pending, startTransition] = useTransition();
|
|
53
|
-
const word = labels['dataControls.confirmWord'];
|
|
54
|
+
const word = labelText(labels['dataControls.confirmWord']);
|
|
54
55
|
const confirmId = `${id}-confirm`;
|
|
55
56
|
const errorId = `${id}-error`;
|
|
56
57
|
function failed(failure) {
|
|
@@ -58,7 +59,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
58
59
|
onSessionExpired?.();
|
|
59
60
|
return null;
|
|
60
61
|
}
|
|
61
|
-
return failure.message ?? labels['dataControls.unreachable'];
|
|
62
|
+
return failure.message ?? labelText(labels['dataControls.unreachable']);
|
|
62
63
|
}
|
|
63
64
|
function exportData() {
|
|
64
65
|
setExportError(null);
|
|
@@ -92,7 +93,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
92
93
|
setError(message);
|
|
93
94
|
});
|
|
94
95
|
}
|
|
95
|
-
return (_jsxs("section", { "aria-labelledby": "portal-data-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: "portal-data-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['dataControls.heading'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { variant: "outline", onClick: exportData, disabled: pending, children: labels['dataControls.export'] }), !confirming && (_jsx(BookingButton, { variant: "destructive", onClick: () => setConfirming(true), disabled: pending, children: labels['dataControls.delete'] }))] }), _jsx("div", { "aria-live": "polite", children: exportError && _jsx(Field.Error, { children: exportError }) }), confirming && (_jsxs("form", { onSubmit: deleteMe, "aria-labelledby": `${id}-delete-heading`, className: slotClass(classNames, 'confirm', 'space-y-4 rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-4'), children: [_jsx("p", { id: `${id}-delete-heading`, className: "font-semibold", children: labels['dataControls.confirmHeading'] }), _jsx("p", { className: "text-sm", children: labels['dataControls.consequences'] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: confirmId, children:
|
|
96
|
+
return (_jsxs("section", { "aria-labelledby": "portal-data-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: "portal-data-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['dataControls.heading'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { variant: "outline", onClick: exportData, disabled: pending, children: labels['dataControls.export'] }), !confirming && (_jsx(BookingButton, { variant: "destructive", onClick: () => setConfirming(true), disabled: pending, children: labels['dataControls.delete'] }))] }), _jsx("div", { "aria-live": "polite", children: exportError && _jsx(Field.Error, { children: exportError }) }), confirming && (_jsxs("form", { onSubmit: deleteMe, "aria-labelledby": `${id}-delete-heading`, className: slotClass(classNames, 'confirm', 'space-y-4 rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-4'), children: [_jsx("p", { id: `${id}-delete-heading`, className: "font-semibold", children: labels['dataControls.confirmHeading'] }), _jsx("p", { className: "text-sm", children: labels['dataControls.consequences'] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: confirmId, children: renderLabel(labels['dataControls.confirmLabel'], { word }) }), _jsx(Input, { id: confirmId, autoComplete: "off", autoCapitalize: "characters", value: typed, "aria-describedby": `${confirmId}-help${error ? ` ${errorId}` : ''}`, "aria-invalid": error ? true : undefined, onChange: (event) => {
|
|
96
97
|
setTyped(event.target.value);
|
|
97
98
|
setError(null);
|
|
98
99
|
}, className: BOOKING_INPUT_CLASS }), _jsx(Field.Description, { id: `${confirmId}-help`, children: labels['dataControls.confirmHelp'] })] }), _jsx("div", { "aria-live": "polite", children: error && _jsx(Field.Error, { id: errorId, children: error }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", variant: "destructive", disabled: pending, children: labels['dataControls.deleteForever'] }), _jsx(BookingButton, { variant: "ghost", disabled: pending, onClick: () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalFamilyMemberDto, PortalPersonNouns } from '../types.js';
|
|
5
6
|
import { type PortalActionFailure } from './action-result.js';
|
|
@@ -10,7 +11,7 @@ export declare const FAMILY_EDITOR_LABEL_KEYS: readonly ["familyEditor.heading",
|
|
|
10
11
|
* `heading` is used when no `nouns` are given. `removeNamed`, `ageConfirmed`
|
|
11
12
|
* and `ageSaved` take `{name}`. The card's `ageConfirm.*` keys are included.
|
|
12
13
|
*/
|
|
13
|
-
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number],
|
|
14
|
+
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number], BookingLabel> & AgeConfirmCardLabels;
|
|
14
15
|
/**
|
|
15
16
|
* `root` (the section), `heading`, `list` (the `<ul>` of rows), `card` (each
|
|
16
17
|
* row's form), `listStatus` (the live region under the list), `add` (the add
|