@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
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. */
|
|
@@ -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
|
|
@@ -225,7 +226,7 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
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
|
|
@@ -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
|
/**
|
|
@@ -4,7 +4,7 @@ import { useEffect, 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
|
import { TimeScreen, } from './time-screen.js';
|
|
10
10
|
/**
|
|
@@ -121,7 +121,7 @@ const LINK_CLASS = 'font-semibold text-primary underline underline-offset-4';
|
|
|
121
121
|
/** «Name's appointment» when a name was given, the bare noun when not. */
|
|
122
122
|
function theirAppointment(labels, name) {
|
|
123
123
|
if (!name)
|
|
124
|
-
return labels['manage.appointment.unnamed'];
|
|
124
|
+
return labelText(labels['manage.appointment.unnamed']);
|
|
125
125
|
return fillLabel(labels[/[sxz]$/i.test(name) ? 'manage.appointment.namedSibilant' : 'manage.appointment.named'], { name });
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
@@ -142,16 +142,20 @@ function remainingLabel(labels, ms) {
|
|
|
142
142
|
return fillLabel(labels['manage.remaining.in'], { amount: hourPart });
|
|
143
143
|
const minutes = Math.floor((ms % HOUR_MS) / 60_000);
|
|
144
144
|
if (minutes < 1)
|
|
145
|
-
return labels['manage.remaining.underMinute'];
|
|
145
|
+
return labelText(labels['manage.remaining.underMinute']);
|
|
146
146
|
return fillLabel(labels['manage.remaining.in'], {
|
|
147
147
|
amount: fillLabel(labels[minutes === 1 ? 'manage.remaining.minute.one' : 'manage.remaining.minute.other'], { count: minutes }),
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
150
|
function ringUs(labels, phone) {
|
|
151
|
-
return phone
|
|
151
|
+
return phone
|
|
152
|
+
? fillLabel(labels['manage.ringUsOn'], { phone })
|
|
153
|
+
: labelText(labels['manage.ringUs']);
|
|
152
154
|
}
|
|
153
155
|
function callUs(labels, phone) {
|
|
154
|
-
return phone
|
|
156
|
+
return phone
|
|
157
|
+
? fillLabel(labels['manage.callUsOn'], { phone })
|
|
158
|
+
: labelText(labels['manage.callUs']);
|
|
155
159
|
}
|
|
156
160
|
/** What the visitor is told went wrong — never the upstream's own words. */
|
|
157
161
|
function errorSentence(labels, code, phone) {
|
|
@@ -159,13 +163,13 @@ function errorSentence(labels, code, phone) {
|
|
|
159
163
|
case 'windowPassed':
|
|
160
164
|
return fillLabel(labels['manage.error.windowPassed'], { ringUs: ringUs(labels, phone) });
|
|
161
165
|
case 'slotTaken':
|
|
162
|
-
return labels['manage.error.slotTaken'];
|
|
166
|
+
return labelText(labels['manage.error.slotTaken']);
|
|
163
167
|
case 'notFound':
|
|
164
168
|
return fillLabel(labels['manage.error.notFound'], { callUs: callUs(labels, phone) });
|
|
165
169
|
case 'conflict':
|
|
166
170
|
return fillLabel(labels['manage.error.conflict'], { ringUs: ringUs(labels, phone) });
|
|
167
171
|
case 'invalidInput':
|
|
168
|
-
return labels['manage.error.invalidInput'];
|
|
172
|
+
return labelText(labels['manage.error.invalidInput']);
|
|
169
173
|
default:
|
|
170
174
|
return fillLabel(labels['manage.error.unavailable'], { ringUs: ringUs(labels, phone) });
|
|
171
175
|
}
|
|
@@ -200,7 +204,7 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
|
|
|
200
204
|
? null
|
|
201
205
|
: format.stylistName(booking.resourceName) || null;
|
|
202
206
|
const serviceLabel = booking.serviceName === UNKNOWN_SERVICE
|
|
203
|
-
? labels['manage.serviceFallback']
|
|
207
|
+
? labelText(labels['manage.serviceFallback'])
|
|
204
208
|
: booking.serviceName;
|
|
205
209
|
// The LARGER window wins: the sentence promises both actions, and the earlier
|
|
206
210
|
// of the two deadlines is the one true of both.
|
|
@@ -330,7 +334,7 @@ function ClosedBooking({ labels, format, status, bookingHref, phone, className,
|
|
|
330
334
|
* contiguous, which the sub-line says.
|
|
331
335
|
*/
|
|
332
336
|
function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
|
|
333
|
-
const who = bookedForName ?? labels['manage.party.unnamed'];
|
|
337
|
+
const who = bookedForName ?? labelText(labels['manage.party.unnamed']);
|
|
334
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']] })] }));
|
|
335
339
|
}
|
|
336
340
|
/**
|
|
@@ -362,11 +366,12 @@ function ConfirmCancel({ labels, format, booking, reason, busy, onPickReason, on
|
|
|
362
366
|
dateTime: format.clock.dateTime(booking.startTs),
|
|
363
367
|
hours: booking.cancelWindowHours,
|
|
364
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) => {
|
|
365
|
-
|
|
369
|
+
// The reason travels as text; the chip shows the label in its own pieces.
|
|
370
|
+
const option = labelText(labels[key]);
|
|
366
371
|
const picked = reason === option;
|
|
367
372
|
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": picked,
|
|
368
373
|
// Tapping the chosen chip again clears it: the answer is
|
|
369
374
|
// optional, so there has to be a way back to having given none.
|
|
370
|
-
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));
|
|
371
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'] })] })] }));
|
|
372
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 {
|
|
@@ -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.
|
|
@@ -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).
|
|
@@ -5,7 +5,7 @@ import { Field } from '../../primitives/field.js';
|
|
|
5
5
|
import { Input } from '../../primitives/input.js';
|
|
6
6
|
import { renderLabel } from '../internal/label-parts.js';
|
|
7
7
|
import { BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
|
|
8
|
-
import { fillLabel } from '../labels.js';
|
|
8
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
9
9
|
import { slotClass } from '../slots.js';
|
|
10
10
|
import { settle } from './action-result.js';
|
|
11
11
|
export const DATA_CONTROLS_LABEL_KEYS = [
|
|
@@ -51,7 +51,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
51
51
|
const [error, setError] = useState(null);
|
|
52
52
|
const [exportError, setExportError] = useState(null);
|
|
53
53
|
const [pending, startTransition] = useTransition();
|
|
54
|
-
const word = labels['dataControls.confirmWord'];
|
|
54
|
+
const word = labelText(labels['dataControls.confirmWord']);
|
|
55
55
|
const confirmId = `${id}-confirm`;
|
|
56
56
|
const errorId = `${id}-error`;
|
|
57
57
|
function failed(failure) {
|
|
@@ -59,7 +59,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
|
|
|
59
59
|
onSessionExpired?.();
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
|
-
return failure.message ?? labels['dataControls.unreachable'];
|
|
62
|
+
return failure.message ?? labelText(labels['dataControls.unreachable']);
|
|
63
63
|
}
|
|
64
64
|
function exportData() {
|
|
65
65
|
setExportError(null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalFamilyMemberDto, PortalPersonNouns } from '../types.js';
|
|
5
6
|
import { type PortalActionFailure } from './action-result.js';
|
|
@@ -10,7 +11,7 @@ export declare const FAMILY_EDITOR_LABEL_KEYS: readonly ["familyEditor.heading",
|
|
|
10
11
|
* `heading` is used when no `nouns` are given. `removeNamed`, `ageConfirmed`
|
|
11
12
|
* and `ageSaved` take `{name}`. The card's `ageConfirm.*` keys are included.
|
|
12
13
|
*/
|
|
13
|
-
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number],
|
|
14
|
+
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number], BookingLabel> & AgeConfirmCardLabels;
|
|
14
15
|
/**
|
|
15
16
|
* `root` (the section), `heading`, `list` (the `<ul>` of rows), `card` (each
|
|
16
17
|
* row's form), `listStatus` (the live region under the list), `add` (the add
|
|
@@ -6,7 +6,7 @@ import { Field } from '../../primitives/field.js';
|
|
|
6
6
|
import { Input } from '../../primitives/input.js';
|
|
7
7
|
import { Textarea } from '../../primitives/textarea.js';
|
|
8
8
|
import { BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton } from '../internal/ui.js';
|
|
9
|
-
import { fillLabel } from '../labels.js';
|
|
9
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
10
10
|
import { slotClass } from '../slots.js';
|
|
11
11
|
import { settle } from './action-result.js';
|
|
12
12
|
import { AGE_CONFIRM_CARD_LABEL_KEYS, AgeConfirmCard, } from './age-confirm-card.js';
|
|
@@ -196,7 +196,7 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
|
|
|
196
196
|
onSessionExpired?.();
|
|
197
197
|
return null;
|
|
198
198
|
}
|
|
199
|
-
return failure.message ?? labels['familyEditor.unreachable'];
|
|
199
|
+
return failure.message ?? labelText(labels['familyEditor.unreachable']);
|
|
200
200
|
}
|
|
201
201
|
/** Never ask about this child again. */
|
|
202
202
|
function rememberAgeAnswer(personId) {
|
|
@@ -352,7 +352,7 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
|
|
|
352
352
|
adopt(result.family, row.key, isNew ? result.personId : null);
|
|
353
353
|
setStatuses({
|
|
354
354
|
[row.key]: result.fallback && gaveDetails
|
|
355
|
-
? { kind: 'saved', note: labels['familyEditor.savedNoDetails'] }
|
|
355
|
+
? { kind: 'saved', note: labelText(labels['familyEditor.savedNoDetails']) }
|
|
356
356
|
: { kind: 'saved' },
|
|
357
357
|
});
|
|
358
358
|
setListStatus(isNew ? { kind: 'saved' } : null);
|
|
@@ -371,13 +371,13 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
|
|
|
371
371
|
showSaving({
|
|
372
372
|
rows: rows.filter((current) => current.key !== row.key),
|
|
373
373
|
statuses,
|
|
374
|
-
listStatus: { kind: 'saved', note: labels['familyEditor.removed'] },
|
|
374
|
+
listStatus: { kind: 'saved', note: labelText(labels['familyEditor.removed']) },
|
|
375
375
|
});
|
|
376
376
|
const result = await settle(() => onRemovePerson(target(row)));
|
|
377
377
|
if (result.ok) {
|
|
378
378
|
adopt(result.family, row.key, null);
|
|
379
379
|
setStatuses({});
|
|
380
|
-
setListStatus({ kind: 'saved', note: labels['familyEditor.removed'] });
|
|
380
|
+
setListStatus({ kind: 'saved', note: labelText(labels['familyEditor.removed']) });
|
|
381
381
|
return;
|
|
382
382
|
}
|
|
383
383
|
fail(result, null);
|
|
@@ -404,18 +404,18 @@ export function DefaultFamilyMemberCard({ id, row, years, months, offerDetails,
|
|
|
404
404
|
// A stylist no longer on the roster keeps their option rather than the
|
|
405
405
|
// select silently showing «none» for a preference the backend still holds.
|
|
406
406
|
const stylistOptions = row.stylist !== '' && !stylists.some((stylist) => stylist.id === row.stylist)
|
|
407
|
-
? [...stylists, { id: row.stylist, name: labels['familyEditor.stylistFormer'] }]
|
|
407
|
+
? [...stylists, { id: row.stylist, name: labelText(labels['familyEditor.stylistFormer']) }]
|
|
408
408
|
: stylists;
|
|
409
409
|
return (_jsxs("form", { onSubmit: (event) => {
|
|
410
410
|
event.preventDefault();
|
|
411
411
|
onSave();
|
|
412
|
-
}, "aria-label": name || labels['familyEditor.newRow'], className: cn('space-y-3 rounded-lg border border-border bg-card px-5 py-4', className), children: [_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['familyEditor.name'] }), _jsx(Input, { id: `${id}-name`, value: row.name, maxLength: 60, "aria-invalid": nameMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ name: event.target.value }), className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['familyEditor.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: row.birthYear, "aria-invalid": yearMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ birthYear: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthYearPlaceholder'] }), years.map((year) => (_jsx("option", { value: String(year), children: year }, year)))] })] })] }), offerDetails && (_jsx(DetailFields, { id: id, row: row, months: months, stylists: stylistOptions, labels: labels, onPatch: onPatch })), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", size: "sm", children: labels['familyEditor.save'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", "aria-label": name
|
|
412
|
+
}, "aria-label": name || labelText(labels['familyEditor.newRow']), className: cn('space-y-3 rounded-lg border border-border bg-card px-5 py-4', className), children: [_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['familyEditor.name'] }), _jsx(Input, { id: `${id}-name`, value: row.name, maxLength: 60, "aria-invalid": nameMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ name: event.target.value }), className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['familyEditor.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: row.birthYear, "aria-invalid": yearMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ birthYear: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthYearPlaceholder'] }), years.map((year) => (_jsx("option", { value: String(year), children: year }, year)))] })] })] }), offerDetails && (_jsx(DetailFields, { id: id, row: row, months: months, stylists: stylistOptions, labels: labels, onPatch: onPatch })), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", size: "sm", children: labels['familyEditor.save'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", "aria-label": name
|
|
413
413
|
? fillLabel(labels['familyEditor.removeNamed'], { name })
|
|
414
|
-
: labels['familyEditor.removeRow'], onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
|
|
414
|
+
: labelText(labels['familyEditor.removeRow']), onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
|
|
415
415
|
}
|
|
416
416
|
/** Month, preferred stylist and a note for the stylist — where the backend keeps them. */
|
|
417
417
|
function DetailFields({ id, row, months, stylists, labels, onPatch, }) {
|
|
418
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['familyEditor.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: row.birthMonth, onChange: (event) => onPatch({ birthMonth: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthMonthUnknown'] }), months.map((month, index) => (_jsx("option", { value: String(index + 1), children: month }, month)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-stylist`, children: labels['familyEditor.stylist'] }), _jsxs("select", { id: `${id}-stylist`, value: row.stylist, onChange: (event) => onPatch({ stylist: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.stylistNone'] }), stylists.map((stylist) => (_jsx("option", { value: stylist.id, children: stylist.name }, stylist.id)))] })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['familyEditor.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: NOTES_MAX, placeholder: labels['familyEditor.notesPlaceholder'], value: row.notes, onChange: (event) => onPatch({ notes: event.target.value }), className: BOOKING_TEXTAREA_CLASS })] })] }));
|
|
418
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['familyEditor.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: row.birthMonth, onChange: (event) => onPatch({ birthMonth: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthMonthUnknown'] }), months.map((month, index) => (_jsx("option", { value: String(index + 1), children: month }, month)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-stylist`, children: labels['familyEditor.stylist'] }), _jsxs("select", { id: `${id}-stylist`, value: row.stylist, onChange: (event) => onPatch({ stylist: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.stylistNone'] }), stylists.map((stylist) => (_jsx("option", { value: stylist.id, children: stylist.name }, stylist.id)))] })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['familyEditor.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: NOTES_MAX, placeholder: labelText(labels['familyEditor.notesPlaceholder']), value: row.notes, onChange: (event) => onPatch({ notes: event.target.value }), className: BOOKING_TEXTAREA_CLASS })] })] }));
|
|
419
419
|
}
|
|
420
420
|
function RowStatusLine({ id, status, labels, }) {
|
|
421
421
|
return (_jsxs("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: [status?.kind === 'saved' && (_jsx("span", { id: id, className: "text-muted-foreground", children: status.note ?? labels['familyEditor.saved'] })), status?.kind === 'partial' && (_jsx(Field.Error, { id: id, children: labels['familyEditor.partial'] })), status?.kind === 'error' && _jsx(Field.Error, { id: id, children: status.message })] }));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
import type { SaveResult } from '../types.js';
|
|
3
4
|
export declare const LOGOUT_BUTTON_LABEL_KEYS: readonly ["logout.button", "logout.unreachable"];
|
|
4
|
-
export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number],
|
|
5
|
+
export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number], BookingLabel>;
|
|
5
6
|
/** `root` (the form), `button`, `error` (the live line under it). */
|
|
6
7
|
export type LogoutButtonSlot = 'root' | 'button' | 'error';
|
|
7
8
|
export interface LogoutButtonProps {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useTransition } from 'react';
|
|
4
4
|
import { BookingButton } from '../internal/ui.js';
|
|
5
|
+
import { labelText } from '../labels.js';
|
|
5
6
|
import { slotClass } from '../slots.js';
|
|
6
7
|
export const LOGOUT_BUTTON_LABEL_KEYS = ['logout.button', 'logout.unreachable'];
|
|
7
8
|
/** «Log out», locked while the callback runs. */
|
|
@@ -20,7 +21,7 @@ export function LogoutButton({ labels, onLogout, classNames }) {
|
|
|
20
21
|
result = { ok: false, message: '' };
|
|
21
22
|
}
|
|
22
23
|
if (!result.ok)
|
|
23
|
-
setError(result.message || labels['logout.unreachable']);
|
|
24
|
+
setError(result.message || labelText(labels['logout.unreachable']));
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
27
|
return (_jsxs("form", { onSubmit: logout, className: slotClass(classNames, 'root'), children: [_jsx(BookingButton, { type: "submit", variant: "outline", disabled: pending, className: slotClass(classNames, 'button'), children: labels['logout.button'] }), _jsx("p", { "aria-live": "polite", className: slotClass(classNames, 'error', 'mt-2 text-sm text-destructive'), children: error })] }));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
import { type BookingLabel } from '../labels.js';
|
|
2
3
|
import { type SlotClassNames } from '../slots.js';
|
|
3
4
|
export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "portalShell.navBar"];
|
|
4
5
|
/**
|
|
@@ -6,7 +7,7 @@ export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "port
|
|
|
6
7
|
* bottom bar. Only one of the two is displayed at a time; a pack may give both
|
|
7
8
|
* the same name.
|
|
8
9
|
*/
|
|
9
|
-
export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number],
|
|
10
|
+
export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number], BookingLabel>;
|
|
10
11
|
/** An icon the shell draws beside a tab label (e.g. a lucide icon). */
|
|
11
12
|
export type PortalShellIcon = ComponentType<{
|
|
12
13
|
className?: string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { cn } from '../../lib/utils.js';
|
|
5
|
+
import { labelText } from '../labels.js';
|
|
5
6
|
import { slotClass } from '../slots.js';
|
|
6
7
|
export const PORTAL_SHELL_LABEL_KEYS = ['portalShell.nav', 'portalShell.navBar'];
|
|
7
8
|
/**
|
|
@@ -26,12 +27,12 @@ export function PortalShell({ labels, tabs, tab: tabProp, defaultTab, onTabChang
|
|
|
26
27
|
setUncontrolled(next);
|
|
27
28
|
onTabChange?.(next);
|
|
28
29
|
}
|
|
29
|
-
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-8'), children: [header, _jsxs("div", { className: "gap-10 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]", children: [_jsx("aside", { className: slotClass(classNames, 'rail', 'hidden lg:block'), children: _jsxs("div", { className: "sticky top-8 space-y-4", children: [account, _jsx("nav", { "aria-label": labels['portalShell.nav'], children: _jsx("ul", { className: "space-y-1", children: tabs.map(({ id, label, icon: Icon }) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'railItem', 'flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition-colors', tab === id
|
|
30
|
+
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-8'), children: [header, _jsxs("div", { className: "gap-10 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]", children: [_jsx("aside", { className: slotClass(classNames, 'rail', 'hidden lg:block'), children: _jsxs("div", { className: "sticky top-8 space-y-4", children: [account, _jsx("nav", { "aria-label": labelText(labels['portalShell.nav']), children: _jsx("ul", { className: "space-y-1", children: tabs.map(({ id, label, icon: Icon }) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'railItem', 'flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition-colors', tab === id
|
|
30
31
|
? 'bg-foreground text-background'
|
|
31
32
|
: 'text-foreground hover:bg-muted'), tab === id && classNames?.railItemActive), children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0" }), label] }) }, id))) }) }), logout] }) }), _jsx("div", { className: "pb-28 lg:pb-0", children: tabs.map(({ id, label, content }) => (_jsxs("section", { hidden: tab !== id, "aria-label": label,
|
|
32
33
|
// `space-y` on the section rather than a wrapper, so a hidden one
|
|
33
34
|
// contributes no margin.
|
|
34
|
-
className: slotClass(classNames, 'section', 'space-y-10'), children: [content, id === logoutIn && logout && _jsx("div", { className: "lg:hidden", children: logout })] }, id))) })] }), _jsx("nav", { "aria-label": labels['portalShell.navBar'], className: slotClass(classNames, 'bar', 'fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md lg:hidden'), children: _jsx("ul", { className: "mx-auto flex max-w-lg", children: tabs.map(({ id, label, short, icon, shortIcon }) => {
|
|
35
|
+
className: slotClass(classNames, 'section', 'space-y-10'), children: [content, id === logoutIn && logout && _jsx("div", { className: "lg:hidden", children: logout })] }, id))) })] }), _jsx("nav", { "aria-label": labelText(labels['portalShell.navBar']), className: slotClass(classNames, 'bar', 'fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md lg:hidden'), children: _jsx("ul", { className: "mx-auto flex max-w-lg", children: tabs.map(({ id, label, short, icon, shortIcon }) => {
|
|
35
36
|
const ShortIcon = shortIcon ?? icon;
|
|
36
37
|
return (_jsx("li", { className: "flex-1", children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'barItem', 'flex w-full flex-col items-center gap-1 px-2 py-3 text-[0.7rem] font-bold transition-colors', tab === id ? 'text-primary' : 'text-muted-foreground'), tab === id && classNames?.barItemActive), children: [ShortIcon && _jsx(ShortIcon, { "aria-hidden": "true", className: "size-5" }), short ?? label] }) }, id));
|
|
37
38
|
}) }) })] }));
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import type { BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
export declare const PORTAL_UNREACHABLE_LABEL_KEYS: readonly ["portalUnreachable.heading", "portalUnreachable.body", "portalUnreachable.retry", "portalUnreachable.call"];
|
|
5
6
|
/** `portalUnreachable.call` takes `{phone}`. */
|
|
6
|
-
export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number],
|
|
7
|
+
export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number], BookingLabel>;
|
|
7
8
|
/** - `root`: the section. - `heading`. - `actions`: the row of links. - `link`: each link. */
|
|
8
9
|
export type PortalUnreachableSlot = 'root' | 'heading' | 'actions' | 'link';
|
|
9
10
|
export interface PortalUnreachableProps {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
import type { PortalProfileDto } from '../types.js';
|
|
3
4
|
import { type PortalActionFailure } from './action-result.js';
|
|
4
5
|
export declare const PROFILE_FORM_LABEL_KEYS: readonly ["profileForm.heading", "profileForm.firstName", "profileForm.lastName", "profileForm.phone", "profileForm.email", "profileForm.emailHelp", "profileForm.save", "profileForm.saved", "profileForm.restored", "profileForm.marketing", "profileForm.unreachable"];
|
|
5
6
|
/** `marketing` is the consent sentence and usually names the business. */
|
|
6
|
-
export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number],
|
|
7
|
+
export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number], BookingLabel>;
|
|
7
8
|
/**
|
|
8
9
|
* `root` (the section), `heading`, `form`, `status` (the save notice's live
|
|
9
10
|
* region), `save` (the submit button), `consent` (the marketing box's field).
|
|
@@ -5,6 +5,7 @@ import { Checkbox } from '../../primitives/checkbox.js';
|
|
|
5
5
|
import { Field } from '../../primitives/field.js';
|
|
6
6
|
import { Input } from '../../primitives/input.js';
|
|
7
7
|
import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
|
|
8
|
+
import { labelText } from '../labels.js';
|
|
8
9
|
import { slotClass } from '../slots.js';
|
|
9
10
|
import { settle } from './action-result.js';
|
|
10
11
|
export const PROFILE_FORM_LABEL_KEYS = [
|
|
@@ -103,7 +104,7 @@ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessio
|
|
|
103
104
|
}
|
|
104
105
|
setStatus({
|
|
105
106
|
kind: 'error',
|
|
106
|
-
message: result.message ?? labels['profileForm.unreachable'],
|
|
107
|
+
message: result.message ?? labelText(labels['profileForm.unreachable']),
|
|
107
108
|
field: 'field' in result && result.field === 'phone' ? 'phone' : null,
|
|
108
109
|
});
|
|
109
110
|
});
|
|
@@ -124,7 +125,7 @@ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessio
|
|
|
124
125
|
onSessionExpired?.();
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
127
|
-
setConsentError(labels['profileForm.unreachable']);
|
|
128
|
+
setConsentError(labelText(labels['profileForm.unreachable']));
|
|
128
129
|
});
|
|
129
130
|
}
|
|
130
131
|
const view = shown.status;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
|
+
import { type BookingLabel } from '../labels.js';
|
|
2
3
|
import { type SlotClassNames } from '../slots.js';
|
|
3
4
|
import type { RebookSuggestion } from '../types.js';
|
|
4
5
|
export declare const REBOOK_CARDS_LABEL_KEYS: readonly ["rebook.heading", "rebook.empty", "rebook.serviceFallback", "rebook.anyStylist", "rebook.cta"];
|
|
5
|
-
export type RebookCardsLabels = Record<(typeof REBOOK_CARDS_LABEL_KEYS)[number],
|
|
6
|
+
export type RebookCardsLabels = Record<(typeof REBOOK_CARDS_LABEL_KEYS)[number], BookingLabel>;
|
|
6
7
|
/** - `root`: the section. - `heading`. - `empty`. - `list`. - `card`. - `link`: the book link. */
|
|
7
8
|
export type RebookCardsSlot = 'root' | 'heading' | 'empty' | 'list' | 'card' | 'link';
|
|
8
9
|
export interface RebookCardProps {
|