@medalsocial/meda 3.0.0 → 3.3.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 +36 -2
- package/dist/booking/add-child-sheet.d.ts +38 -0
- package/dist/booking/add-child-sheet.js +88 -0
- package/dist/booking/booking-skeleton.d.ts +24 -0
- package/dist/booking/booking-skeleton.js +17 -0
- package/dist/booking/confirmation.d.ts +85 -0
- package/dist/booking/confirmation.js +102 -0
- package/dist/booking/details-screen.d.ts +118 -0
- package/dist/booking/details-screen.js +316 -0
- package/dist/booking/format.d.ts +64 -0
- package/dist/booking/format.js +1 -0
- package/dist/booking/index.d.ts +36 -0
- package/dist/booking/index.js +41 -0
- package/dist/booking/internal/label-parts.d.ts +28 -0
- package/dist/booking/internal/label-parts.js +57 -0
- package/dist/booking/internal/ui.d.ts +33 -0
- package/dist/booking/internal/ui.js +35 -0
- package/dist/booking/label-keys.d.ts +34 -0
- package/dist/booking/label-keys.js +56 -0
- package/dist/booking/labels.d.ts +15 -0
- package/dist/booking/labels.js +17 -0
- package/dist/booking/live-status.d.ts +14 -0
- package/dist/booking/live-status.js +17 -0
- package/dist/booking/login-panel.d.ts +108 -0
- package/dist/booking/login-panel.js +239 -0
- package/dist/booking/login-sheet.d.ts +41 -0
- package/dist/booking/login-sheet.js +49 -0
- package/dist/booking/manage-screen.d.ts +113 -0
- package/dist/booking/manage-screen.js +372 -0
- package/dist/booking/otp-slots.d.ts +42 -0
- package/dist/booking/otp-slots.js +109 -0
- package/dist/booking/portal/account-card.d.ts +24 -0
- package/dist/booking/portal/account-card.js +18 -0
- package/dist/booking/portal/action-result.d.ts +30 -0
- package/dist/booking/portal/action-result.js +18 -0
- package/dist/booking/portal/age-confirm-card.d.ts +39 -0
- package/dist/booking/portal/age-confirm-card.js +42 -0
- package/dist/booking/portal/child-cards.d.ts +65 -0
- package/dist/booking/portal/child-cards.js +59 -0
- package/dist/booking/portal/data-controls.d.ts +48 -0
- package/dist/booking/portal/data-controls.js +104 -0
- package/dist/booking/portal/family-editor.d.ts +141 -0
- package/dist/booking/portal/family-editor.js +422 -0
- package/dist/booking/portal/logout-button.d.ts +21 -0
- package/dist/booking/portal/logout-button.js +27 -0
- package/dist/booking/portal/portal-shell.d.ts +71 -0
- package/dist/booking/portal/portal-shell.js +38 -0
- package/dist/booking/portal/portal-unreachable.d.ts +31 -0
- package/dist/booking/portal/portal-unreachable.js +19 -0
- package/dist/booking/portal/profile-form.d.ts +75 -0
- package/dist/booking/portal/profile-form.js +144 -0
- package/dist/booking/portal/rebook-cards.d.ts +44 -0
- package/dist/booking/portal/rebook-cards.js +35 -0
- package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
- package/dist/booking/portal/upcoming-bookings.js +84 -0
- package/dist/booking/portal/vipps-link-row.d.ts +54 -0
- package/dist/booking/portal/vipps-link-row.js +80 -0
- package/dist/booking/portal/visit-history.d.ts +63 -0
- package/dist/booking/portal/visit-history.js +73 -0
- package/dist/booking/service-screen.d.ts +140 -0
- package/dist/booking/service-screen.js +235 -0
- package/dist/booking/slots.d.ts +12 -0
- package/dist/booking/slots.js +5 -0
- package/dist/booking/stylist-screen.d.ts +128 -0
- package/dist/booking/stylist-screen.js +229 -0
- package/dist/booking/summary-bar.d.ts +26 -0
- package/dist/booking/summary-bar.js +32 -0
- package/dist/booking/time-screen.d.ts +191 -0
- package/dist/booking/time-screen.js +419 -0
- package/dist/booking/types.d.ts +284 -0
- package/dist/booking/types.js +15 -0
- package/dist/booking/vipps-button.d.ts +49 -0
- package/dist/booking/vipps-button.js +33 -0
- package/dist/booking/who-screen.d.ts +121 -0
- package/dist/booking/who-screen.js +164 -0
- package/dist/primitives/checkbox.d.ts +23 -0
- package/dist/primitives/checkbox.js +20 -0
- package/dist/primitives/field.d.ts +47 -0
- package/dist/primitives/field.js +48 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/sheet.d.ts +68 -0
- package/dist/primitives/sheet.js +303 -0
- package/dist/primitives/textarea.d.ts +14 -0
- package/dist/primitives/textarea.js +13 -0
- package/dist/styles/booking.css +10 -0
- package/package.json +15 -8
- package/skills/components/SKILL.md +53 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SlotClassNames } from '../slots.js';
|
|
2
|
+
import type { PortalProfileDto } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* - `root`: the card.
|
|
5
|
+
* - `avatar`: the initial.
|
|
6
|
+
* - `name`, `detail`: the two lines.
|
|
7
|
+
*/
|
|
8
|
+
export type AccountCardSlot = 'root' | 'avatar' | 'name' | 'detail';
|
|
9
|
+
export interface AccountCardProps {
|
|
10
|
+
profile: Pick<PortalProfileDto, 'email' | 'firstName' | 'lastName' | 'phone'>;
|
|
11
|
+
/** The sidebar's narrower card: name and number, no address. */
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
classNames?: SlotClassNames<AccountCardSlot>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Who is logged in — the card at the top of the portal's sidebar, and again at
|
|
17
|
+
* the top of the profile section. Presentational and server-renderable.
|
|
18
|
+
*
|
|
19
|
+
* The e-mail sits beside the number because it is what the login is bound to.
|
|
20
|
+
* Not in the compact card, though: a sidebar is not wide enough for both, and
|
|
21
|
+
* an address cut off half-way answers nothing while looking like it should.
|
|
22
|
+
* The card has no copy of its own, so it takes no labels.
|
|
23
|
+
*/
|
|
24
|
+
export declare function AccountCard({ profile, compact, classNames }: AccountCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { slotClass } from '../slots.js';
|
|
3
|
+
/**
|
|
4
|
+
* Who is logged in — the card at the top of the portal's sidebar, and again at
|
|
5
|
+
* the top of the profile section. Presentational and server-renderable.
|
|
6
|
+
*
|
|
7
|
+
* The e-mail sits beside the number because it is what the login is bound to.
|
|
8
|
+
* Not in the compact card, though: a sidebar is not wide enough for both, and
|
|
9
|
+
* an address cut off half-way answers nothing while looking like it should.
|
|
10
|
+
* The card has no copy of its own, so it takes no labels.
|
|
11
|
+
*/
|
|
12
|
+
export function AccountCard({ profile, compact = false, classNames }) {
|
|
13
|
+
const name = [profile.firstName, profile.lastName].filter(Boolean).join(' ').trim();
|
|
14
|
+
const shown = name.length > 0 ? name : profile.email;
|
|
15
|
+
return (_jsxs("div", { className: slotClass(classNames, 'root', 'flex items-center gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("span", { "aria-hidden": "true", className: slotClass(classNames, 'avatar', 'flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 font-sans text-lg font-bold'), children: shown.trim().charAt(0).toUpperCase() }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: slotClass(classNames, 'name', 'block truncate font-semibold'), children: shown }), _jsx("span", { className: slotClass(classNames, 'detail', 'block truncate text-sm text-muted-foreground'), children: (compact ? [profile.phone ?? profile.email] : [profile.phone, profile.email])
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join(' · ') })] })] }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a portal callback answers a form with when it did NOT succeed.
|
|
3
|
+
*
|
|
4
|
+
* The portal's forms ask the same two questions of every failed call: is the
|
|
5
|
+
* session dead (the caller should send the visitor back to the login — the
|
|
6
|
+
* form says nothing), or is there a sentence to show? `message` is optional:
|
|
7
|
+
* without one the form shows its own «could not reach the booking system»
|
|
8
|
+
* label, which is also what a callback that THROWS reads as.
|
|
9
|
+
*/
|
|
10
|
+
export type PortalActionFailure = {
|
|
11
|
+
ok: false;
|
|
12
|
+
kind: 'session';
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
kind: 'error';
|
|
16
|
+
message?: string;
|
|
17
|
+
};
|
|
18
|
+
/** A portal callback's answer with no payload on success. */
|
|
19
|
+
export type PortalActionResult = {
|
|
20
|
+
ok: true;
|
|
21
|
+
} | PortalActionFailure;
|
|
22
|
+
/**
|
|
23
|
+
* Runs a portal callback and turns a throw into the generic failure, so a
|
|
24
|
+
* form never gets stuck in its pending state on a rejected promise.
|
|
25
|
+
*/
|
|
26
|
+
export declare function settle<T extends {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
}>(run: () => Promise<T>): Promise<T | PortalActionFailure>;
|
|
29
|
+
/** The sentence a failure shows, or `null` when it is a dead session. */
|
|
30
|
+
export declare function failureMessage(failure: PortalActionFailure, unreachable: string): string | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a portal callback and turns a throw into the generic failure, so a
|
|
3
|
+
* form never gets stuck in its pending state on a rejected promise.
|
|
4
|
+
*/
|
|
5
|
+
export async function settle(run) {
|
|
6
|
+
try {
|
|
7
|
+
return await run();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return { ok: false, kind: 'error' };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** The sentence a failure shows, or `null` when it is a dead session. */
|
|
14
|
+
export function failureMessage(failure, unreachable) {
|
|
15
|
+
if (failure.kind === 'session')
|
|
16
|
+
return null;
|
|
17
|
+
return failure.message ?? unreachable;
|
|
18
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type SlotClassNames } from '../slots.js';
|
|
2
|
+
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
|
+
/** `heading` and `legend` take `{name}`. */
|
|
4
|
+
export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number], string>;
|
|
5
|
+
/** `root` (the section), `heading`, `form`, `actions` (the button row). */
|
|
6
|
+
export type AgeConfirmCardSlot = 'root' | 'heading' | 'form' | 'actions';
|
|
7
|
+
export interface AgeConfirmCardProps {
|
|
8
|
+
labels: AgeConfirmCardLabels;
|
|
9
|
+
name: string;
|
|
10
|
+
/** The year on file, as the editor holds it (a string: `''` is «not chosen»). */
|
|
11
|
+
birthYear: string;
|
|
12
|
+
years: readonly number[];
|
|
13
|
+
/** Month names, January first (e.g. from `format.clock.monthName`). */
|
|
14
|
+
months: readonly string[];
|
|
15
|
+
busy: boolean;
|
|
16
|
+
/** Why the last confirm failed, or `null`. */
|
|
17
|
+
error: string | null;
|
|
18
|
+
/** `birthMonth` is `''` for «don't know». */
|
|
19
|
+
onConfirm: (answer: {
|
|
20
|
+
birthYear: string;
|
|
21
|
+
birthMonth: string;
|
|
22
|
+
}) => void;
|
|
23
|
+
onDismiss: () => void;
|
|
24
|
+
classNames?: SlotClassNames<AgeConfirmCardSlot>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* «Confirm {name}'s age» — one small card at the top of the family editor,
|
|
28
|
+
* for a child whose birth month the booking system does not know.
|
|
29
|
+
*
|
|
30
|
+
* Presentational: `FamilyEditor` owns the save, so the editor row below and
|
|
31
|
+
* this card never disagree about what is stored. The year arrives prefilled
|
|
32
|
+
* and the month is optional, because «don't know» is a legitimate answer and
|
|
33
|
+
* the card must not hold a parent hostage for it.
|
|
34
|
+
*
|
|
35
|
+
* LAYOUT-STABLE: the caller decides up front whether the card is drawn, so it
|
|
36
|
+
* is in the first paint or not at all; its error line is always mounted, so a
|
|
37
|
+
* failure does not grow it; and it leaves only on the parent's own tap.
|
|
38
|
+
*/
|
|
39
|
+
export declare function AgeConfirmCard({ labels, name, birthYear, years, months, busy, error, onConfirm, onDismiss, classNames, }: AgeConfirmCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useState } from 'react';
|
|
4
|
+
import { Field } from '../../primitives/field.js';
|
|
5
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
6
|
+
import { BookingButton } from '../internal/ui.js';
|
|
7
|
+
import { slotClass } from '../slots.js';
|
|
8
|
+
export const AGE_CONFIRM_CARD_LABEL_KEYS = [
|
|
9
|
+
'ageConfirm.heading',
|
|
10
|
+
'ageConfirm.lead',
|
|
11
|
+
'ageConfirm.legend',
|
|
12
|
+
'ageConfirm.birthYear',
|
|
13
|
+
'ageConfirm.birthMonth',
|
|
14
|
+
'ageConfirm.birthMonthUnknown',
|
|
15
|
+
'ageConfirm.confirm',
|
|
16
|
+
'ageConfirm.dismiss',
|
|
17
|
+
];
|
|
18
|
+
const SELECT_CLASS = 'h-9 w-full rounded-md border border-input bg-transparent px-2.5 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50';
|
|
19
|
+
/**
|
|
20
|
+
* «Confirm {name}'s age» — one small card at the top of the family editor,
|
|
21
|
+
* for a child whose birth month the booking system does not know.
|
|
22
|
+
*
|
|
23
|
+
* Presentational: `FamilyEditor` owns the save, so the editor row below and
|
|
24
|
+
* this card never disagree about what is stored. The year arrives prefilled
|
|
25
|
+
* and the month is optional, because «don't know» is a legitimate answer and
|
|
26
|
+
* the card must not hold a parent hostage for it.
|
|
27
|
+
*
|
|
28
|
+
* LAYOUT-STABLE: the caller decides up front whether the card is drawn, so it
|
|
29
|
+
* is in the first paint or not at all; its error line is always mounted, so a
|
|
30
|
+
* failure does not grow it; and it leaves only on the parent's own tap.
|
|
31
|
+
*/
|
|
32
|
+
export function AgeConfirmCard({ labels, name, birthYear, years, months, busy, error, onConfirm, onDismiss, classNames, }) {
|
|
33
|
+
const id = useId();
|
|
34
|
+
const [year, setYear] = useState(birthYear);
|
|
35
|
+
const [month, setMonth] = useState('');
|
|
36
|
+
const headingId = `${id}-heading`;
|
|
37
|
+
const errorId = `${id}-error`;
|
|
38
|
+
return (_jsxs("section", { "aria-labelledby": headingId, "aria-busy": busy || undefined, className: slotClass(classNames, 'root', 'space-y-3 rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-4'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-lg font-bold'), children: renderLabel(labels['ageConfirm.heading'], { name }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: labels['ageConfirm.lead'] })] }), _jsxs("form", { onSubmit: (event) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
onConfirm({ birthYear: year, birthMonth: month });
|
|
41
|
+
}, "aria-describedby": error ? errorId : undefined, className: slotClass(classNames, 'form', 'space-y-3'), children: [_jsxs("fieldset", { disabled: busy, className: "grid min-w-0 grid-cols-2 gap-3", children: [_jsx("legend", { className: "sr-only", children: renderLabel(labels['ageConfirm.legend'], { name }) }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['ageConfirm.birthYear'] }), _jsx("select", { id: `${id}-year`, value: year, onChange: (event) => setYear(event.target.value), className: SELECT_CLASS, children: years.map((option) => (_jsx("option", { value: String(option), children: option }, option))) })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['ageConfirm.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: month, onChange: (event) => setMonth(event.target.value), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['ageConfirm.birthMonthUnknown'] }), months.map((label, index) => (_jsx("option", { value: String(index + 1), children: label }, label)))] })] })] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { type: "submit", size: "sm", disabled: busy, children: labels['ageConfirm.confirm'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: onDismiss, children: labels['ageConfirm.dismiss'] }), _jsx("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: error && _jsx(Field.Error, { id: errorId, children: error }) })] })] })] }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type SlotClassNames } from '../slots.js';
|
|
4
|
+
import type { ChildSummary } from '../types.js';
|
|
5
|
+
export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "childCards.neverVisited", "childCards.lastVisit", "childCards.lastVisitNoService", "childCards.book", "childCards.stylist", "childCards.next", "childCards.none"];
|
|
6
|
+
/**
|
|
7
|
+
* Placeholders: `childCards.neverVisited` `{age}`; `childCards.lastVisit`
|
|
8
|
+
* `{age}` `{service}` `{date}`; `childCards.lastVisitNoService` `{age}`
|
|
9
|
+
* `{date}`; `childCards.book` `{name}`.
|
|
10
|
+
*/
|
|
11
|
+
export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number], string>;
|
|
12
|
+
/**
|
|
13
|
+
* - `list`: the grid. - `card`: one child. - `avatar`: the initial.
|
|
14
|
+
* - `name`, `detail`: the two lines. - `details`: the full card's facts.
|
|
15
|
+
* - `link`: the book link. - `empty`: the default empty sentence.
|
|
16
|
+
*/
|
|
17
|
+
export type ChildCardsSlot = 'list' | 'card' | 'avatar' | 'name' | 'detail' | 'details' | 'link' | 'empty';
|
|
18
|
+
export type ChildCardsVariant = 'full' | 'compact';
|
|
19
|
+
export interface ChildCardProps {
|
|
20
|
+
child: ChildSummary;
|
|
21
|
+
/** Position in the list (drives the avatar tint). */
|
|
22
|
+
index: number;
|
|
23
|
+
variant: ChildCardsVariant;
|
|
24
|
+
href: string;
|
|
25
|
+
onBook?: (child: ChildSummary) => void;
|
|
26
|
+
stylistNames?: Readonly<Record<string, string>>;
|
|
27
|
+
labels: ChildCardsLabels;
|
|
28
|
+
format: Pick<BookingFormat, 'ageLabel' | 'clock'>;
|
|
29
|
+
classNames?: SlotClassNames<ChildCardsSlot>;
|
|
30
|
+
}
|
|
31
|
+
export interface ChildCardsComponents {
|
|
32
|
+
ChildCard?: ComponentType<ChildCardProps>;
|
|
33
|
+
}
|
|
34
|
+
export interface ChildCardsProps {
|
|
35
|
+
/** Named `kids` rather than `children`, which would collide with React's own. */
|
|
36
|
+
kids: ChildSummary[];
|
|
37
|
+
/**
|
|
38
|
+
* `full`: avatar, age, last visit, details and the book link (the children
|
|
39
|
+
* section). `compact`: a two-line row (the overview's reminder).
|
|
40
|
+
*/
|
|
41
|
+
variant?: ChildCardsVariant;
|
|
42
|
+
/** The booking link for a child (their last service and usual stylist, when known). */
|
|
43
|
+
hrefFor: (child: ChildSummary) => string;
|
|
44
|
+
/** Called on the tap, before the link navigates (e.g. to stash the name outside the URL). */
|
|
45
|
+
onBook?: (child: ChildSummary) => void;
|
|
46
|
+
/**
|
|
47
|
+
* What to draw when there are no children. Default: the `childCards.empty`
|
|
48
|
+
* sentence. `null` where something next to it already says so.
|
|
49
|
+
*/
|
|
50
|
+
empty?: ReactNode;
|
|
51
|
+
/** Stylist display names by id, for the «usual stylist» line. */
|
|
52
|
+
stylistNames?: Readonly<Record<string, string>>;
|
|
53
|
+
labels: ChildCardsLabels;
|
|
54
|
+
format: Pick<BookingFormat, 'ageLabel' | 'clock'>;
|
|
55
|
+
classNames?: SlotClassNames<ChildCardsSlot>;
|
|
56
|
+
components?: ChildCardsComponents;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* «My children» — one card per child, and one tap to book them again. A plain
|
|
60
|
+
* `<a>` does the navigating, so the card works as a link; the tap only calls
|
|
61
|
+
* `onBook` first.
|
|
62
|
+
*/
|
|
63
|
+
export declare function ChildCards({ kids, variant, hrefFor, onBook, empty, stylistNames, labels, format, classNames, components, }: ChildCardsProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null;
|
|
64
|
+
/** One child (an `<li>`). */
|
|
65
|
+
export declare function DefaultChildCard({ child, index, variant, href, onBook, stylistNames, labels, format, classNames, }: ChildCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ArrowRight } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../lib/utils.js';
|
|
5
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
6
|
+
import { slotClass } from '../slots.js';
|
|
7
|
+
export const CHILD_CARDS_LABEL_KEYS = [
|
|
8
|
+
'childCards.empty',
|
|
9
|
+
'childCards.neverVisited',
|
|
10
|
+
'childCards.lastVisit',
|
|
11
|
+
'childCards.lastVisitNoService',
|
|
12
|
+
'childCards.book',
|
|
13
|
+
'childCards.stylist',
|
|
14
|
+
'childCards.next',
|
|
15
|
+
'childCards.none',
|
|
16
|
+
];
|
|
17
|
+
/** The avatar tints, in order. Cosmetic, and deliberately not derived from the
|
|
18
|
+
* name: a hash would recolour a child the day they are renamed. */
|
|
19
|
+
const AVATAR_TINTS = ['bg-secondary/30', 'bg-primary/10', 'bg-accent'];
|
|
20
|
+
function lastVisitLine(child, labels, format) {
|
|
21
|
+
const age = format.ageLabel(child.ageRange);
|
|
22
|
+
if (child.lastVisitTs === null)
|
|
23
|
+
return renderLabel(labels['childCards.neverVisited'], { age });
|
|
24
|
+
const date = format.clock.date(child.lastVisitTs);
|
|
25
|
+
return child.serviceName
|
|
26
|
+
? renderLabel(labels['childCards.lastVisit'], { age, service: child.serviceName, date })
|
|
27
|
+
: renderLabel(labels['childCards.lastVisitNoService'], { age, date });
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* «My children» — one card per child, and one tap to book them again. A plain
|
|
31
|
+
* `<a>` does the navigating, so the card works as a link; the tap only calls
|
|
32
|
+
* `onBook` first.
|
|
33
|
+
*/
|
|
34
|
+
export function ChildCards({ kids, variant = 'full', hrefFor, onBook, empty, stylistNames, labels, format, classNames, components, }) {
|
|
35
|
+
if (kids.length === 0) {
|
|
36
|
+
return empty === undefined ? (_jsx("p", { className: slotClass(classNames, 'empty', 'text-muted-foreground'), children: labels['childCards.empty'] })) : (empty);
|
|
37
|
+
}
|
|
38
|
+
const Card = components?.ChildCard ?? DefaultChildCard;
|
|
39
|
+
return (_jsx("ul", { className: slotClass(classNames, 'list', 'grid gap-4', variant === 'full' && 'sm:grid-cols-2'), children: kids.map((child, index) => (_jsx(Card
|
|
40
|
+
// Twins without Medal ids share name and year; the position keeps them apart.
|
|
41
|
+
, { child: child, index: index, variant: variant, href: hrefFor(child), onBook: onBook, stylistNames: stylistNames, labels: labels, format: format, classNames: classNames }, child.personId ?? `${child.name}-${child.birthYear}-${index}`))) }));
|
|
42
|
+
}
|
|
43
|
+
/** One child (an `<li>`). */
|
|
44
|
+
export function DefaultChildCard({ child, index, variant, href, onBook, stylistNames, labels, format, classNames, }) {
|
|
45
|
+
return (_jsxs("li", { className: slotClass(classNames, 'card', 'flex gap-4 rounded-lg border border-border bg-card px-5 py-4', variant === 'full' ? 'flex-col' : 'items-center'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("span", { "aria-hidden": "true", className: slotClass(classNames, 'avatar', 'flex size-11 shrink-0 items-center justify-center rounded-full font-sans text-lg font-bold', AVATAR_TINTS[index % AVATAR_TINTS.length]), children: child.name.trim().charAt(0).toUpperCase() }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: slotClass(classNames, 'name', 'block truncate font-semibold'), children: child.name }), _jsx("span", { className: slotClass(classNames, 'detail', 'block truncate text-sm text-muted-foreground'), children: lastVisitLine(child, labels, format) })] })] }), variant === 'full' && (_jsx(ChildDetails, { child: child, stylistNames: stylistNames, labels: labels, format: format, className: classNames?.details })), _jsxs("a", { href: href, onClick: onBook ? () => onBook(child) : undefined, className: slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4', variant === 'full'
|
|
46
|
+
? 'inline-flex items-center gap-2 no-underline'
|
|
47
|
+
: 'ms-auto shrink-0 text-sm'), children: [renderLabel(labels['childCards.book'], { name: child.name }), variant === 'full' && _jsx(ArrowRight, { "aria-hidden": "true", className: "size-4" })] })] }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The full card's two facts beyond the last visit: the usual stylist and the
|
|
51
|
+
* next appointment. Both lines are always drawn — a dash where there is
|
|
52
|
+
* nothing — so every card in the grid is the same height.
|
|
53
|
+
*/
|
|
54
|
+
function ChildDetails({ child, stylistNames, labels, format, className, }) {
|
|
55
|
+
const stylist = child.preferredResourceId === null ? null : (stylistNames?.[child.preferredResourceId] ?? null);
|
|
56
|
+
return (_jsxs("dl", { className: cn('grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm', className), children: [_jsx("dt", { className: "text-muted-foreground", children: labels['childCards.stylist'] }), _jsx("dd", { className: "truncate", children: stylist ?? labels['childCards.none'] }), _jsx("dt", { className: "text-muted-foreground", children: labels['childCards.next'] }), _jsx("dd", { className: "truncate", children: child.nextVisitTs === null
|
|
57
|
+
? labels['childCards.none']
|
|
58
|
+
: format.clock.dateTime(child.nextVisitTs) })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type SlotClassNames } from '../slots.js';
|
|
2
|
+
import { type PortalActionFailure, type PortalActionResult } from './action-result.js';
|
|
3
|
+
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"];
|
|
4
|
+
/**
|
|
5
|
+
* `confirmWord` is the literal the visitor must type (e.g. «DELETE»);
|
|
6
|
+
* `confirmLabel` and `wrongWord` take `{word}`.
|
|
7
|
+
*/
|
|
8
|
+
export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number], string>;
|
|
9
|
+
/**
|
|
10
|
+
* `root` (the section), `heading`, `actions` (the export/delete row),
|
|
11
|
+
* `confirm` (the confirm panel form).
|
|
12
|
+
*/
|
|
13
|
+
export type DataControlsSlot = 'root' | 'heading' | 'actions' | 'confirm';
|
|
14
|
+
/**
|
|
15
|
+
* The export's answer. With `filename` and `json` the screen turns it into a
|
|
16
|
+
* file download (nothing of it is rendered into the page); a plain
|
|
17
|
+
* `{ ok: true }` means the caller delivered the file itself.
|
|
18
|
+
*/
|
|
19
|
+
export type DataExportResult = {
|
|
20
|
+
ok: true;
|
|
21
|
+
filename?: string;
|
|
22
|
+
json?: string;
|
|
23
|
+
} | PortalActionFailure;
|
|
24
|
+
export interface DataControlsProps {
|
|
25
|
+
labels: DataControlsLabels;
|
|
26
|
+
/** Take a copy of the visitor's data. */
|
|
27
|
+
onExport: () => Promise<DataExportResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Delete the visitor. Called only with the confirm word typed exactly
|
|
30
|
+
* (trimmed); the backend should re-check it. On `{ ok: true }` the caller
|
|
31
|
+
* navigates away (a full document load, so no cached dashboard survives).
|
|
32
|
+
*/
|
|
33
|
+
onDelete: (confirmation: string) => Promise<PortalActionResult>;
|
|
34
|
+
/** A callback answered `{ kind: 'session' }`: send the visitor to the login. */
|
|
35
|
+
onSessionExpired?: () => void;
|
|
36
|
+
classNames?: SlotClassNames<DataControlsSlot>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* «My data» — the two data-protection gestures: take a copy, and leave.
|
|
40
|
+
*
|
|
41
|
+
* The export is a file, not a page: the callback hands back the JSON as a
|
|
42
|
+
* string and this turns it into a download, so nothing about the visitor is
|
|
43
|
+
* rendered into the DOM or left in the history. The delete is two steps and
|
|
44
|
+
* a typed word — the button alone is not the confirmation, the literal word
|
|
45
|
+
* is, and the backend should re-check it because a page can be driven
|
|
46
|
+
* without its buttons.
|
|
47
|
+
*/
|
|
48
|
+
export declare function DataControls({ labels, onExport, onDelete, onSessionExpired, classNames, }: DataControlsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useState, useTransition } from 'react';
|
|
4
|
+
import { Field } from '../../primitives/field.js';
|
|
5
|
+
import { Input } from '../../primitives/input.js';
|
|
6
|
+
import { renderLabel } from '../internal/label-parts.js';
|
|
7
|
+
import { BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
|
|
8
|
+
import { fillLabel } from '../labels.js';
|
|
9
|
+
import { slotClass } from '../slots.js';
|
|
10
|
+
import { settle } from './action-result.js';
|
|
11
|
+
export const DATA_CONTROLS_LABEL_KEYS = [
|
|
12
|
+
'dataControls.heading',
|
|
13
|
+
'dataControls.export',
|
|
14
|
+
'dataControls.delete',
|
|
15
|
+
'dataControls.confirmHeading',
|
|
16
|
+
'dataControls.consequences',
|
|
17
|
+
'dataControls.confirmLabel',
|
|
18
|
+
'dataControls.confirmHelp',
|
|
19
|
+
'dataControls.wrongWord',
|
|
20
|
+
'dataControls.deleteForever',
|
|
21
|
+
'dataControls.cancel',
|
|
22
|
+
'dataControls.unreachable',
|
|
23
|
+
'dataControls.confirmWord',
|
|
24
|
+
];
|
|
25
|
+
function download(filename, json) {
|
|
26
|
+
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
|
|
27
|
+
const anchor = document.createElement('a');
|
|
28
|
+
anchor.href = url;
|
|
29
|
+
anchor.download = filename;
|
|
30
|
+
document.body.appendChild(anchor);
|
|
31
|
+
anchor.click();
|
|
32
|
+
anchor.remove();
|
|
33
|
+
// Deferred a tick: Safari starts the download after `click()` returns, and
|
|
34
|
+
// a URL revoked synchronously is a download of nothing.
|
|
35
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* «My data» — the two data-protection gestures: take a copy, and leave.
|
|
39
|
+
*
|
|
40
|
+
* The export is a file, not a page: the callback hands back the JSON as a
|
|
41
|
+
* string and this turns it into a download, so nothing about the visitor is
|
|
42
|
+
* rendered into the DOM or left in the history. The delete is two steps and
|
|
43
|
+
* a typed word — the button alone is not the confirmation, the literal word
|
|
44
|
+
* is, and the backend should re-check it because a page can be driven
|
|
45
|
+
* without its buttons.
|
|
46
|
+
*/
|
|
47
|
+
export function DataControls({ labels, onExport, onDelete, onSessionExpired, classNames, }) {
|
|
48
|
+
const id = useId();
|
|
49
|
+
const [confirming, setConfirming] = useState(false);
|
|
50
|
+
const [typed, setTyped] = useState('');
|
|
51
|
+
const [error, setError] = useState(null);
|
|
52
|
+
const [exportError, setExportError] = useState(null);
|
|
53
|
+
const [pending, startTransition] = useTransition();
|
|
54
|
+
const word = labels['dataControls.confirmWord'];
|
|
55
|
+
const confirmId = `${id}-confirm`;
|
|
56
|
+
const errorId = `${id}-error`;
|
|
57
|
+
function failed(failure) {
|
|
58
|
+
if (failure.kind === 'session') {
|
|
59
|
+
onSessionExpired?.();
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return failure.message ?? labels['dataControls.unreachable'];
|
|
63
|
+
}
|
|
64
|
+
function exportData() {
|
|
65
|
+
setExportError(null);
|
|
66
|
+
startTransition(async () => {
|
|
67
|
+
const result = await settle(onExport);
|
|
68
|
+
if (result.ok) {
|
|
69
|
+
if ('filename' in result && result.filename && result.json !== undefined) {
|
|
70
|
+
download(result.filename, result.json);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const message = failed(result);
|
|
75
|
+
if (message !== null)
|
|
76
|
+
setExportError(message);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function deleteMe(event) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
const confirm = typed.trim();
|
|
82
|
+
if (confirm !== word) {
|
|
83
|
+
setError(fillLabel(labels['dataControls.wrongWord'], { word }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setError(null);
|
|
87
|
+
startTransition(async () => {
|
|
88
|
+
const result = await settle(() => onDelete(confirm));
|
|
89
|
+
if (result.ok)
|
|
90
|
+
return;
|
|
91
|
+
const message = failed(result);
|
|
92
|
+
if (message !== null)
|
|
93
|
+
setError(message);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return (_jsxs("section", { "aria-labelledby": "portal-data-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: "portal-data-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['dataControls.heading'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { variant: "outline", onClick: exportData, disabled: pending, children: labels['dataControls.export'] }), !confirming && (_jsx(BookingButton, { variant: "destructive", onClick: () => setConfirming(true), disabled: pending, children: labels['dataControls.delete'] }))] }), _jsx("div", { "aria-live": "polite", children: exportError && _jsx(Field.Error, { children: exportError }) }), confirming && (_jsxs("form", { onSubmit: deleteMe, "aria-labelledby": `${id}-delete-heading`, className: slotClass(classNames, 'confirm', 'space-y-4 rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-4'), children: [_jsx("p", { id: `${id}-delete-heading`, className: "font-semibold", children: labels['dataControls.confirmHeading'] }), _jsx("p", { className: "text-sm", children: labels['dataControls.consequences'] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: confirmId, children: renderLabel(labels['dataControls.confirmLabel'], { word }) }), _jsx(Input, { id: confirmId, autoComplete: "off", autoCapitalize: "characters", value: typed, "aria-describedby": `${confirmId}-help${error ? ` ${errorId}` : ''}`, "aria-invalid": error ? true : undefined, onChange: (event) => {
|
|
97
|
+
setTyped(event.target.value);
|
|
98
|
+
setError(null);
|
|
99
|
+
}, className: BOOKING_INPUT_CLASS }), _jsx(Field.Description, { id: `${confirmId}-help`, children: labels['dataControls.confirmHelp'] })] }), _jsx("div", { "aria-live": "polite", children: error && _jsx(Field.Error, { id: errorId, children: error }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", variant: "destructive", disabled: pending, children: labels['dataControls.deleteForever'] }), _jsx(BookingButton, { variant: "ghost", disabled: pending, onClick: () => {
|
|
100
|
+
setConfirming(false);
|
|
101
|
+
setTyped('');
|
|
102
|
+
setError(null);
|
|
103
|
+
}, children: labels['dataControls.cancel'] })] })] }))] }));
|
|
104
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type ComponentType } from 'react';
|
|
2
|
+
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import { type SlotClassNames } from '../slots.js';
|
|
4
|
+
import type { PortalFamilyMemberDto, PortalPersonNouns } from '../types.js';
|
|
5
|
+
import { type PortalActionFailure } from './action-result.js';
|
|
6
|
+
import { type AgeConfirmCardLabels } from './age-confirm-card.js';
|
|
7
|
+
export declare const FAMILY_EDITOR_OWN_LABEL_KEYS: readonly ["familyEditor.heading", "familyEditor.lead", "familyEditor.empty", "familyEditor.legend", "familyEditor.add", "familyEditor.name", "familyEditor.birthYear", "familyEditor.birthYearPlaceholder", "familyEditor.birthMonth", "familyEditor.birthMonthUnknown", "familyEditor.stylist", "familyEditor.stylistNone", "familyEditor.stylistFormer", "familyEditor.notes", "familyEditor.notesPlaceholder", "familyEditor.newRow", "familyEditor.save", "familyEditor.remove", "familyEditor.removeNamed", "familyEditor.removeRow", "familyEditor.saved", "familyEditor.savedNoDetails", "familyEditor.partial", "familyEditor.removed", "familyEditor.unreachable", "familyEditor.ageConfirmed", "familyEditor.ageSaved"];
|
|
8
|
+
export declare const FAMILY_EDITOR_LABEL_KEYS: readonly ["familyEditor.heading", "familyEditor.lead", "familyEditor.empty", "familyEditor.legend", "familyEditor.add", "familyEditor.name", "familyEditor.birthYear", "familyEditor.birthYearPlaceholder", "familyEditor.birthMonth", "familyEditor.birthMonthUnknown", "familyEditor.stylist", "familyEditor.stylistNone", "familyEditor.stylistFormer", "familyEditor.notes", "familyEditor.notesPlaceholder", "familyEditor.newRow", "familyEditor.save", "familyEditor.remove", "familyEditor.removeNamed", "familyEditor.removeRow", "familyEditor.saved", "familyEditor.savedNoDetails", "familyEditor.partial", "familyEditor.removed", "familyEditor.unreachable", "familyEditor.ageConfirmed", "familyEditor.ageSaved", "ageConfirm.heading", "ageConfirm.lead", "ageConfirm.legend", "ageConfirm.birthYear", "ageConfirm.birthMonth", "ageConfirm.birthMonthUnknown", "ageConfirm.confirm", "ageConfirm.dismiss"];
|
|
9
|
+
/**
|
|
10
|
+
* `heading` is used when no `nouns` are given. `removeNamed`, `ageConfirmed`
|
|
11
|
+
* and `ageSaved` take `{name}`. The card's `ageConfirm.*` keys are included.
|
|
12
|
+
*/
|
|
13
|
+
export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number], string> & AgeConfirmCardLabels;
|
|
14
|
+
/**
|
|
15
|
+
* `root` (the section), `heading`, `list` (the `<ul>` of rows), `card` (each
|
|
16
|
+
* row's form), `listStatus` (the live region under the list), `add` (the add
|
|
17
|
+
* button), `ageCard` (the age prompt's section).
|
|
18
|
+
*/
|
|
19
|
+
export type FamilyEditorSlot = 'root' | 'heading' | 'list' | 'card' | 'listStatus' | 'add' | 'ageCard';
|
|
20
|
+
/** One child as the form holds it. Every field is text; `''` is «not chosen». */
|
|
21
|
+
export interface FamilyEditorRow {
|
|
22
|
+
key: number;
|
|
23
|
+
/** The backend's id for the child, `null` for a new row or one it could not name. */
|
|
24
|
+
personId: string | null;
|
|
25
|
+
/** Where the child sits in the profile's family — the fallback's address. */
|
|
26
|
+
index: number | null;
|
|
27
|
+
name: string;
|
|
28
|
+
birthYear: string;
|
|
29
|
+
birthMonth: string;
|
|
30
|
+
notes: string;
|
|
31
|
+
stylist: string;
|
|
32
|
+
}
|
|
33
|
+
export type FamilyEditorRowStatus = {
|
|
34
|
+
kind: 'saved';
|
|
35
|
+
note?: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'error';
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
/** A name without a year, or the reverse. */
|
|
41
|
+
| {
|
|
42
|
+
kind: 'partial';
|
|
43
|
+
};
|
|
44
|
+
export type FamilyEditorRowPatch = Partial<Omit<FamilyEditorRow, 'key' | 'personId' | 'index'>>;
|
|
45
|
+
/** Which child a call is about: the id, or the family index where there is none. */
|
|
46
|
+
export interface PersonTarget {
|
|
47
|
+
personId: string | null;
|
|
48
|
+
/** Present only when `personId` is `null` and the child is on file. */
|
|
49
|
+
index?: number;
|
|
50
|
+
}
|
|
51
|
+
/** A person as a save sends it. The three detail fields are absent unless offered; `null` clears. */
|
|
52
|
+
export interface PersonPatch {
|
|
53
|
+
name: string;
|
|
54
|
+
birthYear: number;
|
|
55
|
+
birthMonth?: number | null;
|
|
56
|
+
notes?: string | null;
|
|
57
|
+
preferredResourceId?: string | null;
|
|
58
|
+
}
|
|
59
|
+
export interface PersonSaveRequest {
|
|
60
|
+
/** A new child (no id and no index yet). */
|
|
61
|
+
create: boolean;
|
|
62
|
+
target: PersonTarget;
|
|
63
|
+
person: PersonPatch;
|
|
64
|
+
}
|
|
65
|
+
/** A person save or removal's answer: the family as now stored, or why not. */
|
|
66
|
+
export type PersonSaveResult = {
|
|
67
|
+
ok: true;
|
|
68
|
+
family: PortalFamilyMemberDto[];
|
|
69
|
+
/** The id a created child got, when the backend names one. */
|
|
70
|
+
personId: string | null;
|
|
71
|
+
/** The backend could keep only name and year (no per-person endpoints). */
|
|
72
|
+
fallback: boolean;
|
|
73
|
+
} | PortalActionFailure;
|
|
74
|
+
export interface FamilyMemberCardProps {
|
|
75
|
+
/** Unique id prefix for the row's controls. */
|
|
76
|
+
id: string;
|
|
77
|
+
row: FamilyEditorRow;
|
|
78
|
+
years: readonly number[];
|
|
79
|
+
months: readonly string[];
|
|
80
|
+
/** Whether month, stylist and notes are offered for this row. */
|
|
81
|
+
offerDetails: boolean;
|
|
82
|
+
stylists: ReadonlyArray<{
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
}>;
|
|
86
|
+
status: FamilyEditorRowStatus | null;
|
|
87
|
+
labels: FamilyEditorLabels;
|
|
88
|
+
className?: string;
|
|
89
|
+
onPatch: (patch: FamilyEditorRowPatch) => void;
|
|
90
|
+
onSave: () => void;
|
|
91
|
+
onRemove: () => void;
|
|
92
|
+
}
|
|
93
|
+
export interface FamilyEditorComponents {
|
|
94
|
+
/** One child's row (a `<form>`). Wrap `DefaultFamilyMemberCard` to extend it. */
|
|
95
|
+
FamilyMemberCard?: ComponentType<FamilyMemberCardProps>;
|
|
96
|
+
}
|
|
97
|
+
export interface FamilyEditorProps {
|
|
98
|
+
labels: FamilyEditorLabels;
|
|
99
|
+
/** Month names come from `format.clock.monthName`; the current year from `format.clock.dayKey(now)`. */
|
|
100
|
+
format: BookingFormat;
|
|
101
|
+
family: PortalFamilyMemberDto[];
|
|
102
|
+
/** What the business calls the people on a profile; `persons` becomes the heading. */
|
|
103
|
+
nouns?: PortalPersonNouns;
|
|
104
|
+
/** Overrides the year derived from `now`. */
|
|
105
|
+
currentYear?: number;
|
|
106
|
+
/** Defaults to `Date.now()`. */
|
|
107
|
+
now?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Whether the backend keeps month, notes and stylist. A new row offers them
|
|
110
|
+
* too when there is nobody on the profile to tell from.
|
|
111
|
+
*/
|
|
112
|
+
personDetails?: boolean;
|
|
113
|
+
/** Preferred-stylist choices, display names. */
|
|
114
|
+
stylists?: ReadonlyArray<{
|
|
115
|
+
id: string;
|
|
116
|
+
name: string;
|
|
117
|
+
}>;
|
|
118
|
+
/**
|
|
119
|
+
* Draw the age prompt for the first child with no birth month.
|
|
120
|
+
* `dismissed` are the ids already answered on this device. Absent: no card.
|
|
121
|
+
*/
|
|
122
|
+
agePrompt?: {
|
|
123
|
+
dismissed: readonly string[];
|
|
124
|
+
};
|
|
125
|
+
/** Saves one child. Called with the trimmed name. */
|
|
126
|
+
onSavePerson: (request: PersonSaveRequest) => Promise<PersonSaveResult>;
|
|
127
|
+
/** Removes one child. */
|
|
128
|
+
onRemovePerson: (target: PersonTarget) => Promise<PersonSaveResult>;
|
|
129
|
+
/** A callback answered `{ kind: 'session' }`: send the visitor to the login. */
|
|
130
|
+
onSessionExpired?: () => void;
|
|
131
|
+
/**
|
|
132
|
+
* The age prompt was answered (confirmed or waved away). `dismissed` is the
|
|
133
|
+
* whole list to remember, the new id last.
|
|
134
|
+
*/
|
|
135
|
+
onAgeAnswered?: (dismissed: readonly string[]) => void;
|
|
136
|
+
classNames?: SlotClassNames<FamilyEditorSlot>;
|
|
137
|
+
components?: FamilyEditorComponents;
|
|
138
|
+
}
|
|
139
|
+
export declare function FamilyEditor({ labels, format, family, nouns, now, currentYear, personDetails, stylists, agePrompt, onSavePerson, onRemovePerson, onSessionExpired, onAgeAnswered, classNames, components, }: FamilyEditorProps): import("react").JSX.Element;
|
|
140
|
+
/** The default row: name + year, the three details where offered, save / remove and a status line. */
|
|
141
|
+
export declare function DefaultFamilyMemberCard({ id, row, years, months, offerDetails, stylists, status, labels, className, onPatch, onSave, onRemove, }: FamilyMemberCardProps): import("react").JSX.Element;
|