@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
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
3
3
|
import { slotClass } from '../slots.js';
|
|
4
4
|
export const PORTAL_UNREACHABLE_LABEL_KEYS = [
|
|
5
5
|
'portalUnreachable.heading',
|
|
@@ -15,5 +15,5 @@ export const PORTAL_UNREACHABLE_LABEL_KEYS = [
|
|
|
15
15
|
*/
|
|
16
16
|
export function PortalUnreachable({ labels, format, phone, retryHref, logout, headingId = 'portal-unreachable-heading', classNames, }) {
|
|
17
17
|
const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
|
|
18
|
-
return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['portalUnreachable.heading'] }), _jsx("p", { className: "text-muted-foreground", children: labels['portalUnreachable.body'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-4'), children: [_jsx("a", { href: retryHref, className: link, children: labels['portalUnreachable.retry'] }), phone && (_jsx("a", { href: format.telHref(phone), className: link, children:
|
|
18
|
+
return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['portalUnreachable.heading'] }), _jsx("p", { className: "text-muted-foreground", children: labels['portalUnreachable.body'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-4'), children: [_jsx("a", { href: retryHref, className: link, children: labels['portalUnreachable.retry'] }), phone && (_jsx("a", { href: format.telHref(phone), className: link, children: renderLabel(labels['portalUnreachable.call'], { phone }) })), logout] })] }));
|
|
19
19
|
}
|
|
@@ -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 {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { labelText } from '../labels.js';
|
|
3
4
|
import { slotClass } from '../slots.js';
|
|
4
5
|
export const REBOOK_CARDS_LABEL_KEYS = [
|
|
5
6
|
'rebook.heading',
|
|
@@ -26,9 +27,9 @@ export function RebookCards({ suggestions, hrefFor, onRebook, labels, headingId
|
|
|
26
27
|
/** One suggestion (an `<li>`): what · who for · with whom, and the book link. */
|
|
27
28
|
export function DefaultRebookCard({ suggestion, href, onRebook, labels, classNames, }) {
|
|
28
29
|
return (_jsxs("li", { className: slotClass(classNames, 'card', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: [
|
|
29
|
-
suggestion.serviceName ?? labels['rebook.serviceFallback'],
|
|
30
|
+
suggestion.serviceName ?? labelText(labels['rebook.serviceFallback']),
|
|
30
31
|
suggestion.bookedForName,
|
|
31
|
-
suggestion.resourceName ?? labels['rebook.anyStylist'],
|
|
32
|
+
suggestion.resourceName ?? labelText(labels['rebook.anyStylist']),
|
|
32
33
|
]
|
|
33
34
|
.filter(Boolean)
|
|
34
35
|
.join(SEPARATOR) }), _jsx("a", { href: href, onClick: onRebook ? () => onRebook(suggestion) : undefined, className: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4'), children: labels['rebook.cta'] })] }));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalBookingDto } from '../types.js';
|
|
5
6
|
export declare const UPCOMING_BOOKINGS_LABEL_KEYS: readonly ["upcoming.heading", "upcoming.empty", "upcoming.book", "upcoming.next", "upcoming.today", "upcoming.tomorrow", "upcoming.inDays", "upcoming.when", "upcoming.serviceFallback", "upcoming.anyStylist", "upcoming.manage", "upcoming.noManage", "upcoming.noManageWithPhone", "upcoming.call"];
|
|
@@ -9,7 +10,7 @@ export declare const UPCOMING_BOOKINGS_LABEL_KEYS: readonly ["upcoming.heading",
|
|
|
9
10
|
* `upcoming.noManageWithPhone` `{call}` (where the `upcoming.call` link goes),
|
|
10
11
|
* `upcoming.call` `{phone}`.
|
|
11
12
|
*/
|
|
12
|
-
export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number],
|
|
13
|
+
export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number], BookingLabel>;
|
|
13
14
|
/**
|
|
14
15
|
* - `root`: the section. - `heading`. - `empty`: the no-bookings box.
|
|
15
16
|
* - `list`. - `card`: a plain card; `cardHero`: the first (next) one.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { cn } from '../../lib/utils.js';
|
|
4
|
-
import { labelParts } from '../internal/label-parts.js';
|
|
4
|
+
import { joinLabelParts, labelParts, renderLabel } from '../internal/label-parts.js';
|
|
5
5
|
import { bookingButtonClass } from '../internal/ui.js';
|
|
6
|
-
import { fillLabel } from '../labels.js';
|
|
6
|
+
import { fillLabel, labelText } from '../labels.js';
|
|
7
7
|
import { slotClass } from '../slots.js';
|
|
8
8
|
export const UPCOMING_BOOKINGS_LABEL_KEYS = [
|
|
9
9
|
'upcoming.heading',
|
|
@@ -29,29 +29,25 @@ const SEPARATOR = ' · ';
|
|
|
29
29
|
function howFarOff(startTs, now, labels, format) {
|
|
30
30
|
const days = format.clock.daysBetween(now, startTs);
|
|
31
31
|
if (days <= 0)
|
|
32
|
-
return labels['upcoming.today'];
|
|
32
|
+
return labelText(labels['upcoming.today']);
|
|
33
33
|
if (days === 1)
|
|
34
|
-
return labels['upcoming.tomorrow'];
|
|
34
|
+
return labelText(labels['upcoming.tomorrow']);
|
|
35
35
|
return fillLabel(labels['upcoming.inDays'], { count: days });
|
|
36
36
|
}
|
|
37
37
|
/** What is booked and who for. */
|
|
38
38
|
function whatLine(booking, labels) {
|
|
39
|
-
return [booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName]
|
|
40
|
-
.filter(Boolean)
|
|
41
|
-
.join(SEPARATOR);
|
|
39
|
+
return joinLabelParts([booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName], SEPARATOR);
|
|
42
40
|
}
|
|
43
41
|
/** When, with whom, and what it costs. */
|
|
44
42
|
function whenLine(booking, now, labels, format) {
|
|
45
|
-
return [
|
|
46
|
-
|
|
43
|
+
return joinLabelParts([
|
|
44
|
+
renderLabel(labels['upcoming.when'], {
|
|
47
45
|
day: format.clock.dayLabel(booking.startTs, now),
|
|
48
46
|
time: format.clock.formatTime(booking.startTs),
|
|
49
47
|
}),
|
|
50
48
|
booking.resourceName ?? labels['upcoming.anyStylist'],
|
|
51
49
|
booking.amountOre === null ? null : format.price(booking.amountOre),
|
|
52
|
-
]
|
|
53
|
-
.filter(Boolean)
|
|
54
|
-
.join(SEPARATOR);
|
|
50
|
+
], SEPARATOR);
|
|
55
51
|
}
|
|
56
52
|
/**
|
|
57
53
|
* «Upcoming appointments» — one card per appointment still ahead.
|
|
@@ -72,7 +68,7 @@ export function UpcomingBookings({ bookings, phone, labels, format, bookingHref,
|
|
|
72
68
|
export function DefaultBookingCard({ booking, phone, now, hero, labels, format, classNames, }) {
|
|
73
69
|
return (_jsxs("li", { className: cn('space-y-2 px-5 py-4', hero
|
|
74
70
|
? slotClass(classNames, 'cardHero', 'rounded-2xl bg-primary p-6 text-primary-foreground md:p-8')
|
|
75
|
-
: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card')), children: [hero && (_jsxs("p", { className: slotClass(classNames, 'badge', 'inline-flex items-center gap-2 rounded-full bg-primary-foreground/15 px-3 py-1 text-xs font-bold'), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }),
|
|
71
|
+
: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card')), children: [hero && (_jsxs("p", { className: slotClass(classNames, 'badge', 'inline-flex items-center gap-2 rounded-full bg-primary-foreground/15 px-3 py-1 text-xs font-bold'), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }), renderLabel(labels['upcoming.next'], {
|
|
76
72
|
when: howFarOff(booking.startTs, now, labels, format),
|
|
77
73
|
})] })), _jsx("p", { className: cn('font-semibold', hero && 'font-sans text-2xl md:text-3xl'), children: whatLine(booking, labels) }), _jsx("p", { className: cn('tabular-nums', hero && slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')), children: whenLine(booking, now, labels, format) }), booking.managePath ? (_jsx("a", { href: booking.managePath, className: slotClass(classNames, 'manage', 'text-sm font-semibold underline underline-offset-4', hero
|
|
78
74
|
? 'inline-block rounded-lg bg-background px-4 py-2 text-foreground no-underline'
|
|
@@ -80,7 +76,7 @@ export function DefaultBookingCard({ booking, phone, now, hero, labels, format,
|
|
|
80
76
|
? slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')
|
|
81
77
|
: 'text-muted-foreground'), children: phone
|
|
82
78
|
? labelParts(labels['upcoming.noManageWithPhone'], {
|
|
83
|
-
call: (_jsx("a", { href: format.telHref(phone), className: cn('font-semibold underline underline-offset-4', hero ? 'text-primary-foreground' : 'text-primary'), children:
|
|
79
|
+
call: (_jsx("a", { href: format.telHref(phone), className: cn('font-semibold underline underline-offset-4', hero ? 'text-primary-foreground' : 'text-primary'), children: renderLabel(labels['upcoming.call'], { phone }) })),
|
|
84
80
|
})
|
|
85
81
|
: // No number on file: the sentence without a link, because a `tel:`
|
|
86
82
|
// that dials nothing fails in the hand of someone already stuck.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type BookingLabel } from '../labels.js';
|
|
1
2
|
import { type SlotClassNames } from '../slots.js';
|
|
2
3
|
export declare const VIPPS_LINK_ROW_LABEL_KEYS: readonly ["vippsLink.heading", "vippsLink.linked", "vippsLink.pitch", "vippsLink.button", "vippsLink.success", "vippsLink.conflict", "vippsLink.failed", "vippsLink.unavailable", "vippsLink.throttled"];
|
|
3
|
-
export type VippsLinkRowLabels = Record<(typeof VIPPS_LINK_ROW_LABEL_KEYS)[number],
|
|
4
|
+
export type VippsLinkRowLabels = Record<(typeof VIPPS_LINK_ROW_LABEL_KEYS)[number], BookingLabel>;
|
|
4
5
|
/** `root` (the section), `card` (the bordered row), `heading`, `button`, `notice` (the live line). */
|
|
5
6
|
export type VippsLinkRowSlot = 'root' | 'card' | 'heading' | 'button' | 'notice';
|
|
6
7
|
/** The outcome of a link attempt that just returned, as the server recorded it. */
|
|
@@ -4,6 +4,7 @@ import { useActionState, useEffect, useId, useRef } from 'react';
|
|
|
4
4
|
import { useFormStatus } from 'react-dom';
|
|
5
5
|
import { cn } from '../../lib/utils.js';
|
|
6
6
|
import { BookingButton } from '../internal/ui.js';
|
|
7
|
+
import { labelText } from '../labels.js';
|
|
7
8
|
import { slotClass } from '../slots.js';
|
|
8
9
|
export const VIPPS_LINK_ROW_LABEL_KEYS = [
|
|
9
10
|
'vippsLink.heading',
|
|
@@ -23,17 +24,17 @@ function LinkButton({ label, className }) {
|
|
|
23
24
|
/** The sentence under the row: a failed start first, else what the return said. */
|
|
24
25
|
function noticeFor(state, isLinked, flash, labels, announceSuccess) {
|
|
25
26
|
if (state?.reason === 'unavailable')
|
|
26
|
-
return labels['vippsLink.unavailable'];
|
|
27
|
+
return labelText(labels['vippsLink.unavailable']);
|
|
27
28
|
if (state?.reason === 'throttled')
|
|
28
|
-
return labels['vippsLink.throttled'];
|
|
29
|
+
return labelText(labels['vippsLink.throttled']);
|
|
29
30
|
if (flash === 'linked' && announceSuccess)
|
|
30
|
-
return labels['vippsLink.success'];
|
|
31
|
+
return labelText(labels['vippsLink.success']);
|
|
31
32
|
if (isLinked)
|
|
32
33
|
return null;
|
|
33
34
|
if (flash === 'link_conflict')
|
|
34
|
-
return labels['vippsLink.conflict'];
|
|
35
|
+
return labelText(labels['vippsLink.conflict']);
|
|
35
36
|
if (flash === 'link_failed')
|
|
36
|
-
return labels['vippsLink.failed'];
|
|
37
|
+
return labelText(labels['vippsLink.failed']);
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
@@ -76,5 +77,5 @@ export function VippsLinkRow({ labels, linked, flash, onStart, onSessionExpired,
|
|
|
76
77
|
const sentence = noticeFor(state, linked, flash, labels, announceSuccess);
|
|
77
78
|
const headingId = `${id}-heading`;
|
|
78
79
|
const noticeId = `${id}-notice`;
|
|
79
|
-
return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-2'), children: [_jsxs("div", { className: slotClass(classNames, 'card', 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-semibold'), children: labels['vippsLink.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: linked ? labels['vippsLink.linked'] : labels['vippsLink.pitch'] })] }), !linked && (_jsx("form", { action: formAction, "aria-describedby": sentence ? noticeId : undefined, children: _jsx(LinkButton, { label: labels['vippsLink.button'], className: classNames?.button }) }))] }), _jsx("div", { "aria-live": "polite", className: slotClass(classNames, 'notice', 'min-h-5'), children: sentence && (_jsx("p", { id: noticeId, className: "text-sm text-muted-foreground", children: sentence })) })] }));
|
|
80
|
+
return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-2'), children: [_jsxs("div", { className: slotClass(classNames, 'card', 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-semibold'), children: labels['vippsLink.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: linked ? labels['vippsLink.linked'] : labels['vippsLink.pitch'] })] }), !linked && (_jsx("form", { action: formAction, "aria-describedby": sentence ? noticeId : undefined, children: _jsx(LinkButton, { label: labelText(labels['vippsLink.button']), className: classNames?.button }) }))] }), _jsx("div", { "aria-live": "polite", className: slotClass(classNames, 'notice', 'min-h-5'), children: sentence && (_jsx("p", { id: noticeId, className: "text-sm text-muted-foreground", children: sentence })) })] }));
|
|
80
81
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingClock, BookingFormat } from '../format.js';
|
|
3
|
+
import type { BookingLabel } from '../labels.js';
|
|
3
4
|
import { type SlotClassNames } from '../slots.js';
|
|
4
5
|
import type { PortalBookingDto } from '../types.js';
|
|
5
6
|
export declare const VISIT_HISTORY_LABEL_KEYS: readonly ["history.heading", "history.empty", "history.serviceFallback", "history.count.one", "history.count.other", "history.unknownAmount"];
|
|
6
7
|
/** `history.count.*` take `{count}` and `{year}`. */
|
|
7
|
-
export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number],
|
|
8
|
+
export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number], BookingLabel>;
|
|
8
9
|
/**
|
|
9
10
|
* - `root`: the section. - `heading`. - `empty`: the no-visits box.
|
|
10
11
|
* - `yearChip` / `yearChipSelected`: the year switcher's buttons.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from '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 VISIT_HISTORY_LABEL_KEYS = [
|
|
8
8
|
'history.heading',
|
|
@@ -60,7 +60,7 @@ export function VisitHistory({ past, labels, format, years: yearsProp, headingId
|
|
|
60
60
|
return (_jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => setSelected(year.year), className: cn(slotClass(classNames, 'yearChip', 'rounded-full border px-4 py-1.5 text-sm font-semibold transition-colors', pressed
|
|
61
61
|
? 'border-foreground bg-foreground text-background'
|
|
62
62
|
: 'border-border hover:bg-muted'), pressed && classNames?.yearChipSelected), children: year.year }, year.year));
|
|
63
|
-
}) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children:
|
|
63
|
+
}) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children: renderLabel(labels[shown.visits.length === 1 ? 'history.count.one' : 'history.count.other'], { count: shown.visits.length, year: shown.year }) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: shown.totalOre === null
|
|
64
64
|
? labels['history.unknownAmount']
|
|
65
65
|
: format.price(shown.totalOre) })] })] }))] }));
|
|
66
66
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingServiceDto } from './types.js';
|
|
5
6
|
/**
|
|
@@ -10,15 +11,16 @@ import type { BookingServiceDto } from './types.js';
|
|
|
10
11
|
* already holds a child.
|
|
11
12
|
*/
|
|
12
13
|
export declare const SERVICE_SCREEN_LABEL_KEYS: readonly ["service.heading", "service.categoriesLegend", "service.sameAsLast", "service.chooseOther", "service.duration", "service.phoneOnly", "service.phoneOnlyLinkLabel", "service.ageDivider.named", "service.ageDivider.unnamed", "service.party.listLabel", "service.party.adultAlone", "service.party.nothingFits", "service.party.remove"];
|
|
13
|
-
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number],
|
|
14
|
+
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
14
15
|
/**
|
|
15
16
|
* - `root` — the `<section>`
|
|
16
17
|
* - `heading` — the `<h2>`
|
|
17
18
|
* - `pill` / `pillSelected` — a category pill / the marked one
|
|
18
19
|
* - `groupHeading` — a category's (or a party member's) `<h3>`
|
|
19
20
|
* - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
|
|
21
|
+
* - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
|
|
20
22
|
*/
|
|
21
|
-
export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected';
|
|
23
|
+
export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
|
|
22
24
|
/** One service group, in display order. */
|
|
23
25
|
export interface ServiceScreenCategory {
|
|
24
26
|
key: string;
|
|
@@ -56,6 +58,8 @@ export interface ServiceCardProps {
|
|
|
56
58
|
labels: ServiceScreenLabels;
|
|
57
59
|
className?: string;
|
|
58
60
|
selectedClassName?: string;
|
|
61
|
+
/** Merged onto the price (the `price` slot). */
|
|
62
|
+
priceClassName?: string;
|
|
59
63
|
}
|
|
60
64
|
export interface ServiceScreenComponents {
|
|
61
65
|
ServiceCard?: ComponentType<ServiceCardProps>;
|
|
@@ -134,4 +138,4 @@ export declare function ServiceScreen(props: ServiceScreenProps): import("react"
|
|
|
134
138
|
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
135
139
|
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
136
140
|
*/
|
|
137
|
-
export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }: ServiceCardProps): import("react").JSX.Element;
|
|
141
|
+
export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { 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
|
/**
|
|
9
10
|
* The service step — what should be done?
|
|
@@ -36,7 +37,7 @@ const asIs = (name) => name;
|
|
|
36
37
|
/** The age divider's sentence for `name`. */
|
|
37
38
|
function ageDividerLabel(labels, name, possessive) {
|
|
38
39
|
if (!name)
|
|
39
|
-
return labels['service.ageDivider.unnamed'];
|
|
40
|
+
return labelText(labels['service.ageDivider.unnamed']);
|
|
40
41
|
return fillLabel(labels['service.ageDivider.named'], {
|
|
41
42
|
name,
|
|
42
43
|
nameGenitive: possessive(name),
|
|
@@ -57,6 +58,7 @@ function cardProps(shared) {
|
|
|
57
58
|
labels: shared.labels,
|
|
58
59
|
className: shared.classNames?.card,
|
|
59
60
|
selectedClassName: shared.classNames?.cardSelected,
|
|
61
|
+
priceClassName: shared.classNames?.price,
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
export function ServiceScreen(props) {
|
|
@@ -187,7 +189,7 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
|
|
|
187
189
|
*/
|
|
188
190
|
function NothingInThisParty({ person, labels, onRemove, }) {
|
|
189
191
|
const values = { label: person.label };
|
|
190
|
-
return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children:
|
|
192
|
+
return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: renderLabel(labels[person.adult ? 'service.party.adultAlone' : 'service.party.nothingFits'], values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(labels['service.party.remove'], values) }))] }));
|
|
191
193
|
}
|
|
192
194
|
/**
|
|
193
195
|
* One person with a «same as last time»: the suggestion as the answer, and the
|
|
@@ -206,7 +208,7 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
|
|
|
206
208
|
const { labels, format, classNames } = shared;
|
|
207
209
|
return (_jsxs("div", { className: "space-y-1", children: [_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onPick, className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border px-5 py-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
|
|
208
210
|
? 'border-primary bg-primary/5 ring-2 ring-primary'
|
|
209
|
-
: 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children:
|
|
211
|
+
: 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: renderLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: slotClass(classNames, 'price', 'whitespace-nowrap font-semibold text-foreground tabular-nums'), children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
|
|
210
212
|
}
|
|
211
213
|
/**
|
|
212
214
|
* The default service card.
|
|
@@ -218,9 +220,9 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
|
|
|
218
220
|
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
219
221
|
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
220
222
|
*/
|
|
221
|
-
export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }) {
|
|
222
|
-
const duration =
|
|
223
|
-
const price = (_jsx("span", { className:
|
|
223
|
+
export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }) {
|
|
224
|
+
const duration = renderLabel(labels['service.duration'], { minutes: service.durationMinutes });
|
|
225
|
+
const price = (_jsx("span", { className: cn('whitespace-nowrap font-semibold text-foreground tabular-nums', priceClassName), children: format.price(service.priceOre) }));
|
|
224
226
|
if (kind === 'phone') {
|
|
225
227
|
return (_jsxs("div", { className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border border-dashed border-border px-5 py-4', className), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), _jsxs("span", { className: "text-sm", children: [labels['service.phoneOnly'], phone ? (_jsxs(_Fragment, { children: [' – ', _jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['service.phoneOnlyLinkLabel'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: phone })] })) : null] })] })] }));
|
|
226
228
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType, type Ref } from 'react';
|
|
2
2
|
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingResourceDto, PartyMode } from './types.js';
|
|
5
6
|
/**
|
|
@@ -20,15 +21,16 @@ import type { BookingResourceDto, PartyMode } from './types.js';
|
|
|
20
21
|
* available» included. Arrow keys move and choose, Tab enters and leaves once.
|
|
21
22
|
*/
|
|
22
23
|
export declare const STYLIST_SCREEN_LABEL_KEYS: readonly ["stylist.heading", "stylist.firstAvailable", "stylist.firstAvailableSubtitle", "stylist.firstAvailableBadge", "stylist.pendingName", "stylist.nextAvailable", "stylist.loading", "stylist.party.legend", "stylist.party.sequential", "stylist.party.sequentialMinutes", "stylist.party.parallel.two", "stylist.party.parallel.three", "stylist.party.parallel.other", "stylist.party.parallelMinutes", "stylist.party.parallelNote.two", "stylist.party.parallelNote.three", "stylist.party.parallelNote.other"];
|
|
23
|
-
export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number],
|
|
24
|
+
export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
24
25
|
/**
|
|
25
26
|
* - `root` — the `<section>`
|
|
26
27
|
* - `heading` — the `<h2>`
|
|
27
28
|
* - `group` — the radiogroup (the sideways row / the column)
|
|
28
29
|
* - `card` / `cardSelected` — a stylist option / the checked one (passed to `StylistCard`)
|
|
29
30
|
* - `modeCard` / `modeCardSelected` — a family's mode card / the pressed one
|
|
31
|
+
* - `nextAvailable` — a stylist option's next-opening line (passed to `StylistCard`)
|
|
30
32
|
*/
|
|
31
|
-
export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected';
|
|
33
|
+
export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected' | 'nextAvailable';
|
|
32
34
|
/** One option of the radiogroup as the card draws it. */
|
|
33
35
|
export interface StylistOption {
|
|
34
36
|
/** `null` is «first available». */
|
|
@@ -55,6 +57,8 @@ export interface StylistCardProps {
|
|
|
55
57
|
labels: StylistScreenLabels;
|
|
56
58
|
className?: string;
|
|
57
59
|
selectedClassName?: string;
|
|
60
|
+
/** Merged onto the next-opening line (the `nextAvailable` slot). */
|
|
61
|
+
nextAvailableClassName?: string;
|
|
58
62
|
}
|
|
59
63
|
export interface StylistScreenComponents {
|
|
60
64
|
StylistCard?: ComponentType<StylistCardProps>;
|
|
@@ -102,6 +106,14 @@ export interface StylistScreenProps {
|
|
|
102
106
|
mode: PartyMode;
|
|
103
107
|
/** How many people, for the option labels. */
|
|
104
108
|
size: number;
|
|
109
|
+
/**
|
|
110
|
+
* The size as the copy spells it (e.g. «two»), filled into `{sizeWord}`
|
|
111
|
+
* in `stylist.party.parallel.*` and `stylist.party.parallelNote.*`.
|
|
112
|
+
* Default: the number. Lets a pack keep the word as its own text piece
|
|
113
|
+
* (`'We find {sizeWord} stylists …'`) rather than writing it into the
|
|
114
|
+
* sentence.
|
|
115
|
+
*/
|
|
116
|
+
sizeWord?: string;
|
|
105
117
|
/** How long the visit is in each mode. */
|
|
106
118
|
minutes: {
|
|
107
119
|
sequential: number;
|
|
@@ -114,4 +126,4 @@ export interface StylistScreenProps {
|
|
|
114
126
|
}
|
|
115
127
|
export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
|
|
116
128
|
/** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
|
|
117
|
-
export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }: StylistCardProps): import("react").JSX.Element;
|
|
129
|
+
export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }: StylistCardProps): import("react").JSX.Element;
|