@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.
Files changed (88) hide show
  1. package/README.md +36 -2
  2. package/dist/booking/add-child-sheet.d.ts +38 -0
  3. package/dist/booking/add-child-sheet.js +88 -0
  4. package/dist/booking/booking-skeleton.d.ts +24 -0
  5. package/dist/booking/booking-skeleton.js +17 -0
  6. package/dist/booking/confirmation.d.ts +85 -0
  7. package/dist/booking/confirmation.js +102 -0
  8. package/dist/booking/details-screen.d.ts +118 -0
  9. package/dist/booking/details-screen.js +316 -0
  10. package/dist/booking/format.d.ts +64 -0
  11. package/dist/booking/format.js +1 -0
  12. package/dist/booking/index.d.ts +36 -0
  13. package/dist/booking/index.js +41 -0
  14. package/dist/booking/internal/label-parts.d.ts +28 -0
  15. package/dist/booking/internal/label-parts.js +57 -0
  16. package/dist/booking/internal/ui.d.ts +33 -0
  17. package/dist/booking/internal/ui.js +35 -0
  18. package/dist/booking/label-keys.d.ts +34 -0
  19. package/dist/booking/label-keys.js +56 -0
  20. package/dist/booking/labels.d.ts +15 -0
  21. package/dist/booking/labels.js +17 -0
  22. package/dist/booking/live-status.d.ts +14 -0
  23. package/dist/booking/live-status.js +17 -0
  24. package/dist/booking/login-panel.d.ts +108 -0
  25. package/dist/booking/login-panel.js +239 -0
  26. package/dist/booking/login-sheet.d.ts +41 -0
  27. package/dist/booking/login-sheet.js +49 -0
  28. package/dist/booking/manage-screen.d.ts +113 -0
  29. package/dist/booking/manage-screen.js +372 -0
  30. package/dist/booking/otp-slots.d.ts +42 -0
  31. package/dist/booking/otp-slots.js +109 -0
  32. package/dist/booking/portal/account-card.d.ts +24 -0
  33. package/dist/booking/portal/account-card.js +18 -0
  34. package/dist/booking/portal/action-result.d.ts +30 -0
  35. package/dist/booking/portal/action-result.js +18 -0
  36. package/dist/booking/portal/age-confirm-card.d.ts +39 -0
  37. package/dist/booking/portal/age-confirm-card.js +42 -0
  38. package/dist/booking/portal/child-cards.d.ts +65 -0
  39. package/dist/booking/portal/child-cards.js +59 -0
  40. package/dist/booking/portal/data-controls.d.ts +48 -0
  41. package/dist/booking/portal/data-controls.js +104 -0
  42. package/dist/booking/portal/family-editor.d.ts +141 -0
  43. package/dist/booking/portal/family-editor.js +422 -0
  44. package/dist/booking/portal/logout-button.d.ts +21 -0
  45. package/dist/booking/portal/logout-button.js +27 -0
  46. package/dist/booking/portal/portal-shell.d.ts +71 -0
  47. package/dist/booking/portal/portal-shell.js +38 -0
  48. package/dist/booking/portal/portal-unreachable.d.ts +31 -0
  49. package/dist/booking/portal/portal-unreachable.js +19 -0
  50. package/dist/booking/portal/profile-form.d.ts +75 -0
  51. package/dist/booking/portal/profile-form.js +144 -0
  52. package/dist/booking/portal/rebook-cards.d.ts +44 -0
  53. package/dist/booking/portal/rebook-cards.js +35 -0
  54. package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
  55. package/dist/booking/portal/upcoming-bookings.js +84 -0
  56. package/dist/booking/portal/vipps-link-row.d.ts +54 -0
  57. package/dist/booking/portal/vipps-link-row.js +80 -0
  58. package/dist/booking/portal/visit-history.d.ts +63 -0
  59. package/dist/booking/portal/visit-history.js +73 -0
  60. package/dist/booking/service-screen.d.ts +140 -0
  61. package/dist/booking/service-screen.js +235 -0
  62. package/dist/booking/slots.d.ts +12 -0
  63. package/dist/booking/slots.js +5 -0
  64. package/dist/booking/stylist-screen.d.ts +128 -0
  65. package/dist/booking/stylist-screen.js +229 -0
  66. package/dist/booking/summary-bar.d.ts +26 -0
  67. package/dist/booking/summary-bar.js +32 -0
  68. package/dist/booking/time-screen.d.ts +191 -0
  69. package/dist/booking/time-screen.js +419 -0
  70. package/dist/booking/types.d.ts +284 -0
  71. package/dist/booking/types.js +15 -0
  72. package/dist/booking/vipps-button.d.ts +49 -0
  73. package/dist/booking/vipps-button.js +33 -0
  74. package/dist/booking/who-screen.d.ts +121 -0
  75. package/dist/booking/who-screen.js +164 -0
  76. package/dist/primitives/checkbox.d.ts +23 -0
  77. package/dist/primitives/checkbox.js +20 -0
  78. package/dist/primitives/field.d.ts +47 -0
  79. package/dist/primitives/field.js +48 -0
  80. package/dist/primitives/index.d.ts +8 -0
  81. package/dist/primitives/index.js +4 -0
  82. package/dist/primitives/sheet.d.ts +68 -0
  83. package/dist/primitives/sheet.js +303 -0
  84. package/dist/primitives/textarea.d.ts +14 -0
  85. package/dist/primitives/textarea.js +13 -0
  86. package/dist/styles/booking.css +10 -0
  87. package/package.json +15 -8
  88. package/skills/components/SKILL.md +53 -0
@@ -0,0 +1,422 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useId, useOptimistic, useState, useTransition } from 'react';
4
+ import { cn } from '../../lib/utils.js';
5
+ import { Field } from '../../primitives/field.js';
6
+ import { Input } from '../../primitives/input.js';
7
+ import { Textarea } from '../../primitives/textarea.js';
8
+ import { BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton } from '../internal/ui.js';
9
+ import { fillLabel } from '../labels.js';
10
+ import { slotClass } from '../slots.js';
11
+ import { settle } from './action-result.js';
12
+ import { AGE_CONFIRM_CARD_LABEL_KEYS, AgeConfirmCard, } from './age-confirm-card.js';
13
+ /**
14
+ * The children a parent books for, ONE CHILD AT A TIME.
15
+ *
16
+ * Each row saves on its own, through `onSavePerson` / `onRemovePerson`, which
17
+ * edit one stored person in place: the id the booking system keeps for the
18
+ * child survives a rename, and that id is what a booking points at. A backend
19
+ * without per-person endpoints may fall back to patching the whole list
20
+ * (`fallback: true` in the answer) and the row says what could not be kept.
21
+ *
22
+ * Beside name and birth year, three fields the backend keeps where it can
23
+ * (`personDetails`): the birth MONTH (optional — it makes an age exact across
24
+ * the birthday; never a full date), a preferred stylist (a preference the
25
+ * wizard preselects, not a booking rule) and a note for the stylist.
26
+ *
27
+ * OPTIMISTIC: the tap shows the outcome at once — the row as it will be
28
+ * stored and «saved» — with every row locked (`disabled` on the fieldset)
29
+ * until the callback answers. On success the rows are rebuilt from what was
30
+ * stored, keeping any OTHER row's unsaved typing; on failure the optimistic
31
+ * view ends, which puts the parent's edits back exactly as they were, with
32
+ * the reason in the live region.
33
+ *
34
+ * Fixed row shapes, so nothing below a row moves when a save lands: a row's
35
+ * status line is always there, empty until it has something to say.
36
+ *
37
+ * AGE PROMPT (`agePrompt`): above the rows, one `AgeConfirmCard` for the
38
+ * first child the backend can address whose birth month it does not know. It
39
+ * saves through the same `onSavePerson` and the same rebuild, so the row below
40
+ * shows what the card stored. Its patch is the minimum — name, year and, when
41
+ * chosen, month — so notes and preferred stylist are left alone. Confirmed or
42
+ * waved away, the child's id is reported through `onAgeAnswered` (the caller
43
+ * remembers it, e.g. in a cookie) and the card is not drawn for them again.
44
+ */
45
+ const MAX_MEMBERS = 10;
46
+ const OLDEST_YEARS_BACK = 18;
47
+ const NOTES_MAX = 500;
48
+ export const FAMILY_EDITOR_OWN_LABEL_KEYS = [
49
+ 'familyEditor.heading',
50
+ 'familyEditor.lead',
51
+ 'familyEditor.empty',
52
+ 'familyEditor.legend',
53
+ 'familyEditor.add',
54
+ 'familyEditor.name',
55
+ 'familyEditor.birthYear',
56
+ 'familyEditor.birthYearPlaceholder',
57
+ 'familyEditor.birthMonth',
58
+ 'familyEditor.birthMonthUnknown',
59
+ 'familyEditor.stylist',
60
+ 'familyEditor.stylistNone',
61
+ 'familyEditor.stylistFormer',
62
+ 'familyEditor.notes',
63
+ 'familyEditor.notesPlaceholder',
64
+ 'familyEditor.newRow',
65
+ 'familyEditor.save',
66
+ 'familyEditor.remove',
67
+ 'familyEditor.removeNamed',
68
+ 'familyEditor.removeRow',
69
+ 'familyEditor.saved',
70
+ 'familyEditor.savedNoDetails',
71
+ 'familyEditor.partial',
72
+ 'familyEditor.removed',
73
+ 'familyEditor.unreachable',
74
+ 'familyEditor.ageConfirmed',
75
+ 'familyEditor.ageSaved',
76
+ ];
77
+ export const FAMILY_EDITOR_LABEL_KEYS = [
78
+ ...FAMILY_EDITOR_OWN_LABEL_KEYS,
79
+ ...AGE_CONFIRM_CARD_LABEL_KEYS,
80
+ ];
81
+ function toRow(member, key, index) {
82
+ return {
83
+ key,
84
+ personId: member.personId,
85
+ index,
86
+ name: member.name,
87
+ birthYear: String(member.birthYear),
88
+ birthMonth: member.birthMonth === null ? '' : String(member.birthMonth),
89
+ notes: member.notes ?? '',
90
+ stylist: member.preferredResourceId ?? '',
91
+ };
92
+ }
93
+ function same(a, b) {
94
+ return (a.name === b.name &&
95
+ a.birthYear === b.birthYear &&
96
+ a.birthMonth === b.birthMonth &&
97
+ a.notes === b.notes &&
98
+ a.stylist === b.stylist);
99
+ }
100
+ /** A row typed here and never saved: nothing on the backend's side to address. */
101
+ function isNewRow(row) {
102
+ return row.personId === null && row.index === null;
103
+ }
104
+ function target(row) {
105
+ return {
106
+ personId: row.personId,
107
+ ...(row.personId === null && row.index !== null ? { index: row.index } : {}),
108
+ };
109
+ }
110
+ /** The three detail fields; `null` clears. */
111
+ function details(row) {
112
+ const notes = row.notes.trim();
113
+ return {
114
+ birthMonth: row.birthMonth === '' ? null : Number(row.birthMonth),
115
+ notes: notes === '' ? null : notes,
116
+ preferredResourceId: row.stylist === '' ? null : row.stylist,
117
+ };
118
+ }
119
+ /**
120
+ * What one existing row becomes once the backend has answered: what was
121
+ * stored, unless the parent has unsaved typing in it — then their draft,
122
+ * re-indexed. The row just saved from the editor always takes the answer; the
123
+ * row the age card saved (`submitted`) keeps its draft and takes only those
124
+ * fields.
125
+ */
126
+ function keptRow(previous, storedRow, lastStored, isSaved, submitted) {
127
+ const draftSurvives = !isSaved || submitted !== undefined;
128
+ if (!draftSurvives || lastStored === undefined || same(previous, lastStored))
129
+ return storedRow;
130
+ const merged = { ...previous, index: storedRow.index };
131
+ if (isSaved) {
132
+ for (const field of submitted ?? [])
133
+ merged[field] = storedRow[field];
134
+ }
135
+ return merged;
136
+ }
137
+ function hasDetails(row) {
138
+ return row.birthMonth !== '' || row.notes.trim() !== '' || row.stylist !== '';
139
+ }
140
+ function yearOptions(currentYear, extra) {
141
+ const years = new Set();
142
+ for (let year = currentYear; year >= currentYear - OLDEST_YEARS_BACK; year -= 1) {
143
+ years.add(year);
144
+ }
145
+ // A child who has aged out of the range keeps their own year on the list
146
+ // rather than being silently moved to the newest one the select allows.
147
+ for (const year of extra)
148
+ years.add(year);
149
+ return [...years].sort((a, b) => b - a);
150
+ }
151
+ /**
152
+ * The index of the child the age prompt asks about, or `null`: one the
153
+ * backend can address, with no birth month, not already answered, on a
154
+ * backend that keeps months.
155
+ */
156
+ function agePromptIndex(family, dismissed, personDetails) {
157
+ if (!personDetails)
158
+ return null;
159
+ const index = family.findIndex((member) => member.personId !== null && member.birthMonth === null && !dismissed.includes(member.personId));
160
+ return index === -1 ? null : index;
161
+ }
162
+ 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';
163
+ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(), currentYear = Number(format.clock.dayKey(now).slice(0, 4)), personDetails = false, stylists = [], agePrompt, onSavePerson, onRemovePerson, onSessionExpired, onAgeAnswered, classNames, components, }) {
164
+ const FamilyMemberCard = components?.FamilyMemberCard ?? DefaultFamilyMemberCard;
165
+ const id = useId();
166
+ const [rows, setRows] = useState(() => family.map((member, index) => toRow(member, index, index)));
167
+ /** What each row last was on the backend's side — what «dirty» is measured from. */
168
+ const [stored, setStored] = useState(() => Object.fromEntries(family.map((member, index) => [index, toRow(member, index, index)])));
169
+ const [nextKey, setNextKey] = useState(family.length);
170
+ const [statuses, setStatuses] = useState({});
171
+ const [listStatus, setListStatus] = useState(null);
172
+ const [pending, startTransition] = useTransition();
173
+ const [ageDismissed, setAgeDismissed] = useState(() => agePrompt?.dismissed ?? []);
174
+ const [ageError, setAgeError] = useState(null);
175
+ const [shown, showSaving] = useOptimistic({ rows, statuses, listStatus }, (_current, saving) => saving);
176
+ const detailsKnown = personDetails;
177
+ const months = Array.from({ length: 12 }, (_, index) => format.clock.monthName(index + 1));
178
+ const years = yearOptions(currentYear, rows.map((row) => Number(row.birthYear)).filter((year) => Number.isInteger(year) && year > 0));
179
+ /**
180
+ * The row the age card asks about: by what the backend HOLDS (`stored`),
181
+ * not what the parent may be typing below — a month chosen but not yet
182
+ * saved in the editor is not a month the backend knows.
183
+ */
184
+ const agePromptRow = (() => {
185
+ if (agePrompt === undefined)
186
+ return undefined;
187
+ const candidates = rows.filter((row) => stored[row.key] !== undefined);
188
+ const index = agePromptIndex(candidates.map((row) => ({
189
+ personId: row.personId,
190
+ birthMonth: stored[row.key].birthMonth === '' ? null : Number(stored[row.key].birthMonth),
191
+ })), ageDismissed, detailsKnown);
192
+ return index === null ? undefined : stored[candidates[index].key];
193
+ })();
194
+ function failed(failure) {
195
+ if (failure.kind === 'session') {
196
+ onSessionExpired?.();
197
+ return null;
198
+ }
199
+ return failure.message ?? labels['familyEditor.unreachable'];
200
+ }
201
+ /** Never ask about this child again. */
202
+ function rememberAgeAnswer(personId) {
203
+ const next = [...ageDismissed, personId];
204
+ setAgeDismissed(next);
205
+ setAgeError(null);
206
+ onAgeAnswered?.(next);
207
+ }
208
+ function confirmAge(row, answer) {
209
+ const personId = row.personId;
210
+ if (personId === null)
211
+ return;
212
+ const name = row.name.trim();
213
+ // Nothing to store: the year stands and the parent does not know (or
214
+ // will not say) the month. Still an answer, and remembered as one.
215
+ if (answer.birthYear === row.birthYear && answer.birthMonth === '') {
216
+ rememberAgeAnswer(personId);
217
+ setListStatus({
218
+ kind: 'saved',
219
+ note: fillLabel(labels['familyEditor.ageConfirmed'], { name }),
220
+ });
221
+ return;
222
+ }
223
+ setAgeError(null);
224
+ startTransition(async () => {
225
+ const result = await settle(() => onSavePerson({
226
+ create: false,
227
+ target: { personId },
228
+ person: {
229
+ name,
230
+ birthYear: Number(answer.birthYear),
231
+ ...(answer.birthMonth === '' ? {} : { birthMonth: Number(answer.birthMonth) }),
232
+ },
233
+ }));
234
+ if (result.ok) {
235
+ // Only the fields the card sent come from the answer: anything the
236
+ // parent is still typing in this child's editor row stays theirs.
237
+ adopt(result.family, row.key, null, answer.birthMonth === '' ? ['birthYear'] : ['birthYear', 'birthMonth']);
238
+ rememberAgeAnswer(personId);
239
+ // No «saved» on the row itself: it may still hold unsaved typing.
240
+ setListStatus({
241
+ kind: 'saved',
242
+ note: fillLabel(labels['familyEditor.ageSaved'], { name }),
243
+ });
244
+ return;
245
+ }
246
+ const message = failed(result);
247
+ if (message !== null)
248
+ setAgeError(message);
249
+ });
250
+ }
251
+ function patchRow(key, patch) {
252
+ setRows((current) => current.map((row) => (row.key === key ? { ...row, ...patch } : row)));
253
+ setStatuses(({ [key]: _dropped, ...rest }) => rest);
254
+ setListStatus(null);
255
+ }
256
+ function addRow() {
257
+ if (rows.length >= MAX_MEMBERS)
258
+ return;
259
+ const row = {
260
+ key: nextKey,
261
+ personId: null,
262
+ index: null,
263
+ name: '',
264
+ birthYear: '',
265
+ birthMonth: '',
266
+ notes: '',
267
+ stylist: '',
268
+ };
269
+ setRows((current) => [...current, row]);
270
+ setNextKey((key) => key + 1);
271
+ setListStatus(null);
272
+ }
273
+ /**
274
+ * Rebuild the rows from what the backend now holds. Rows the parent is
275
+ * still typing in — changed and not the one just saved — keep their typing:
276
+ * saving one child must not throw away another's half-finished edit.
277
+ * Unsaved NEW rows stay at the end.
278
+ */
279
+ function adopt(fresh, savedKey, createdId,
280
+ /**
281
+ * The age card's save: the saved row is NOT the one the parent submitted
282
+ * from the editor, so any unsaved typing in it survives — only the fields
283
+ * the card sent are taken from the answer.
284
+ */
285
+ submitted) {
286
+ let key = nextKey;
287
+ const savedRow = rows.find((row) => row.key === savedKey);
288
+ let claimed = false;
289
+ // The fresh entry the new row became: by the id it was given, or — from
290
+ // the list fallback, which names no id — by the name and year just typed.
291
+ const isCreated = (member) => {
292
+ if (claimed || savedRow === undefined || !isNewRow(savedRow))
293
+ return false;
294
+ const match = createdId !== null
295
+ ? member.personId === createdId
296
+ : member.name === savedRow.name.trim() && String(member.birthYear) === savedRow.birthYear;
297
+ claimed = match;
298
+ return match;
299
+ };
300
+ const kept = [];
301
+ const nextStored = {};
302
+ for (const [index, member] of fresh.entries()) {
303
+ const previous = member.personId === null ? undefined : rows.find((row) => row.personId === member.personId);
304
+ // The child just created keeps the key of the row it was typed in, so
305
+ // its «saved» stays where the parent is looking.
306
+ const rowKey = previous?.key ?? (isCreated(member) ? savedKey : key++);
307
+ const storedRow = toRow(member, rowKey, index);
308
+ nextStored[rowKey] = storedRow;
309
+ kept.push(previous === undefined
310
+ ? storedRow
311
+ : keptRow(previous, storedRow, stored[previous.key], previous.key === savedKey, submitted));
312
+ }
313
+ // The row just created has no id in `rows` yet; it is in `fresh` now, so
314
+ // it goes. Every other new row stays.
315
+ const drafts = rows.filter((row) => row.personId === null && row.index === null && row.key !== savedKey);
316
+ setRows([...kept, ...drafts]);
317
+ setStored(nextStored);
318
+ setNextKey(Math.max(key, nextKey));
319
+ }
320
+ function fail(failure, key) {
321
+ const message = failed(failure);
322
+ if (message === null)
323
+ return;
324
+ const status = { kind: 'error', message };
325
+ if (key === null)
326
+ setListStatus(status);
327
+ else
328
+ setStatuses((current) => ({ ...current, [key]: status }));
329
+ }
330
+ function save(row) {
331
+ const name = row.name.trim();
332
+ if (name === '' || row.birthYear === '') {
333
+ setStatuses((current) => ({ ...current, [row.key]: { kind: 'partial' } }));
334
+ return;
335
+ }
336
+ const isNew = isNewRow(row);
337
+ const offerDetails = detailsKnown || (isNew && family.length === 0);
338
+ const person = {
339
+ name,
340
+ birthYear: Number(row.birthYear),
341
+ ...(offerDetails ? details(row) : {}),
342
+ };
343
+ const gaveDetails = offerDetails && hasDetails(row);
344
+ startTransition(async () => {
345
+ showSaving({
346
+ rows: rows.map((current) => (current.key === row.key ? { ...row, name } : current)),
347
+ statuses: { ...statuses, [row.key]: { kind: 'saved' } },
348
+ listStatus: null,
349
+ });
350
+ const result = await settle(() => onSavePerson({ create: isNew, target: target(row), person }));
351
+ if (result.ok) {
352
+ adopt(result.family, row.key, isNew ? result.personId : null);
353
+ setStatuses({
354
+ [row.key]: result.fallback && gaveDetails
355
+ ? { kind: 'saved', note: labels['familyEditor.savedNoDetails'] }
356
+ : { kind: 'saved' },
357
+ });
358
+ setListStatus(isNew ? { kind: 'saved' } : null);
359
+ return;
360
+ }
361
+ fail(result, row.key);
362
+ });
363
+ }
364
+ function remove(row) {
365
+ // A row that was never saved has nothing on the backend's side to remove.
366
+ if (isNewRow(row)) {
367
+ setRows((current) => current.filter((other) => other.key !== row.key));
368
+ return;
369
+ }
370
+ startTransition(async () => {
371
+ showSaving({
372
+ rows: rows.filter((current) => current.key !== row.key),
373
+ statuses,
374
+ listStatus: { kind: 'saved', note: labels['familyEditor.removed'] },
375
+ });
376
+ const result = await settle(() => onRemovePerson(target(row)));
377
+ if (result.ok) {
378
+ adopt(result.family, row.key, null);
379
+ setStatuses({});
380
+ setListStatus({ kind: 'saved', note: labels['familyEditor.removed'] });
381
+ return;
382
+ }
383
+ fail(result, null);
384
+ });
385
+ }
386
+ const listStatusId = `${id}-list-status`;
387
+ return (_jsxs("section", { "aria-labelledby": "portal-family-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h2", { id: "portal-family-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: nouns?.persons ?? labels['familyEditor.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: labels['familyEditor.lead'] })] }), agePromptRow !== undefined && (_jsx(AgeConfirmCard
388
+ // A fresh card per child: the next child's must not inherit this one's answer.
389
+ , { labels: labels, name: agePromptRow.name, birthYear: agePromptRow.birthYear, years: years, months: months, busy: pending, error: ageError, classNames: classNames?.ageCard ? { root: classNames.ageCard } : undefined, onConfirm: (answer) => confirmAge(agePromptRow, answer), onDismiss: () => agePromptRow.personId && rememberAgeAnswer(agePromptRow.personId) }, agePromptRow.personId)), shown.rows.length === 0 && (_jsx("p", { className: "text-muted-foreground", children: labels['familyEditor.empty'] })), _jsxs("fieldset", {
390
+ // Locked while saving: an edit typed mid-save would be shown through
391
+ // the optimistic snapshot and then overwritten by the answer.
392
+ disabled: pending, className: "min-w-0 space-y-3", "aria-describedby": shown.listStatus?.kind === 'error' ? listStatusId : undefined, children: [_jsx("legend", { className: "sr-only", children: labels['familyEditor.legend'] }), _jsx("ul", { className: slotClass(classNames, 'list', 'space-y-3'), children: shown.rows.map((row) => {
393
+ const offerDetails = detailsKnown || (isNewRow(row) && family.length === 0);
394
+ return (_jsx("li", { children: _jsx(FamilyMemberCard, { id: `${id}-${row.key}`, row: row, years: years, months: months, offerDetails: offerDetails, stylists: stylists, status: shown.statuses[row.key] ?? null, labels: labels, className: classNames?.card, onPatch: (patch) => patchRow(row.key, patch), onSave: () => save(row), onRemove: () => remove(row) }) }, row.key));
395
+ }) })] }), _jsxs("div", { "aria-live": "polite", className: slotClass(classNames, 'listStatus', 'min-h-5'), children: [shown.listStatus?.kind === 'saved' && shown.listStatus.note && (_jsx("p", { id: listStatusId, className: "text-sm text-muted-foreground", children: shown.listStatus.note })), shown.listStatus?.kind === 'error' && (_jsx(Field.Error, { id: listStatusId, children: shown.listStatus.message }))] }), shown.rows.length < MAX_MEMBERS && (_jsx(BookingButton, { variant: "outline", onClick: addRow, disabled: pending, className: slotClass(classNames, 'add'), children: labels['familyEditor.add'] }))] }));
396
+ }
397
+ /** The default row: name + year, the three details where offered, save / remove and a status line. */
398
+ export function DefaultFamilyMemberCard({ id, row, years, months, offerDetails, stylists, status, labels, className, onPatch, onSave, onRemove, }) {
399
+ const statusId = `${id}-status`;
400
+ const partial = status?.kind === 'partial';
401
+ const nameMissing = partial && row.name.trim() === '';
402
+ const yearMissing = partial && row.birthYear === '';
403
+ const name = row.name.trim();
404
+ // A stylist no longer on the roster keeps their option rather than the
405
+ // select silently showing «none» for a preference the backend still holds.
406
+ const stylistOptions = row.stylist !== '' && !stylists.some((stylist) => stylist.id === row.stylist)
407
+ ? [...stylists, { id: row.stylist, name: labels['familyEditor.stylistFormer'] }]
408
+ : stylists;
409
+ return (_jsxs("form", { onSubmit: (event) => {
410
+ event.preventDefault();
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
413
+ ? fillLabel(labels['familyEditor.removeNamed'], { name })
414
+ : labels['familyEditor.removeRow'], onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
415
+ }
416
+ /** Month, preferred stylist and a note for the stylist — where the backend keeps them. */
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 })] })] }));
419
+ }
420
+ function RowStatusLine({ id, status, labels, }) {
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 })] }));
422
+ }
@@ -0,0 +1,21 @@
1
+ import { type SlotClassNames } from '../slots.js';
2
+ import type { SaveResult } from '../types.js';
3
+ export declare const LOGOUT_BUTTON_LABEL_KEYS: readonly ["logout.button", "logout.unreachable"];
4
+ export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number], string>;
5
+ /** `root` (the form), `button`, `error` (the live line under it). */
6
+ export type LogoutButtonSlot = 'root' | 'button' | 'error';
7
+ export interface LogoutButtonProps {
8
+ labels: LogoutButtonLabels;
9
+ /**
10
+ * Ends the session. On `{ ok: true }` the caller navigates away itself
11
+ * (one full document load, so nothing rendered under the old session
12
+ * survives in a router cache). Anything else keeps the visitor here with a
13
+ * message: navigating away would SAY logged out while the session may
14
+ * still be live. A failure's `message` is shown when given, else
15
+ * `logout.unreachable`.
16
+ */
17
+ onLogout: () => Promise<SaveResult>;
18
+ classNames?: SlotClassNames<LogoutButtonSlot>;
19
+ }
20
+ /** «Log out», locked while the callback runs. */
21
+ export declare function LogoutButton({ labels, onLogout, classNames }: LogoutButtonProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState, useTransition } from 'react';
4
+ import { BookingButton } from '../internal/ui.js';
5
+ import { slotClass } from '../slots.js';
6
+ export const LOGOUT_BUTTON_LABEL_KEYS = ['logout.button', 'logout.unreachable'];
7
+ /** «Log out», locked while the callback runs. */
8
+ export function LogoutButton({ labels, onLogout, classNames }) {
9
+ const [pending, startTransition] = useTransition();
10
+ const [error, setError] = useState(null);
11
+ function logout(event) {
12
+ event.preventDefault();
13
+ setError(null);
14
+ startTransition(async () => {
15
+ let result;
16
+ try {
17
+ result = await onLogout();
18
+ }
19
+ catch {
20
+ result = { ok: false, message: '' };
21
+ }
22
+ if (!result.ok)
23
+ setError(result.message || labels['logout.unreachable']);
24
+ });
25
+ }
26
+ 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 })] }));
27
+ }
@@ -0,0 +1,71 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ import { type SlotClassNames } from '../slots.js';
3
+ export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "portalShell.navBar"];
4
+ /**
5
+ * `portalShell.nav` names the desktop rail, `portalShell.navBar` the phone's
6
+ * bottom bar. Only one of the two is displayed at a time; a pack may give both
7
+ * the same name.
8
+ */
9
+ export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number], string>;
10
+ /** An icon the shell draws beside a tab label (e.g. a lucide icon). */
11
+ export type PortalShellIcon = ComponentType<{
12
+ className?: string;
13
+ 'aria-hidden'?: boolean | 'true';
14
+ }>;
15
+ export interface PortalShellTab<Id extends string = string> {
16
+ id: Id;
17
+ /** The rail's label, and the section's accessible name. */
18
+ label: string;
19
+ /** The bottom bar's label, where there is less room. Defaults to `label`. */
20
+ short?: string;
21
+ icon?: PortalShellIcon;
22
+ /** The bottom bar's icon. Defaults to `icon`. */
23
+ shortIcon?: PortalShellIcon;
24
+ /** The section's content. */
25
+ content: ReactNode;
26
+ }
27
+ /**
28
+ * - `root`: the whole shell.
29
+ * - `rail`: the desktop sidebar; `railItem` / `railItemActive`: its buttons.
30
+ * - `section`: every section.
31
+ * - `bar`: the phone's bottom tab bar; `barItem` / `barItemActive`: its buttons.
32
+ */
33
+ export type PortalShellSlot = 'root' | 'rail' | 'railItem' | 'railItemActive' | 'section' | 'bar' | 'barItem' | 'barItemActive';
34
+ export interface PortalShellProps<Id extends string = string> {
35
+ labels: PortalShellLabels;
36
+ /** The sections, in order. */
37
+ tabs: ReadonlyArray<PortalShellTab<Id>>;
38
+ /** The open section (controlled). Pair with `onTabChange`. */
39
+ tab?: Id;
40
+ /** The open section when uncontrolled. Defaults to the first tab. */
41
+ defaultTab?: Id;
42
+ /** Called with the tab a user picked — e.g. to write it to the URL. */
43
+ onTabChange?: (tab: Id) => void;
44
+ /** The page header (greeting, «book» button). */
45
+ header?: ReactNode;
46
+ /** Who is logged in, drawn at the top of the rail. */
47
+ account?: ReactNode;
48
+ /**
49
+ * The log-out control: last in the rail, and repeated under the
50
+ * `logoutTab` section below `lg`, where there is no rail. Rendered twice,
51
+ * so it should hold no state worth keeping.
52
+ */
53
+ logout?: ReactNode;
54
+ /** The section that carries the logout on a phone. Defaults to the last tab. */
55
+ logoutTab?: Id;
56
+ classNames?: SlotClassNames<PortalShellSlot>;
57
+ }
58
+ /**
59
+ * The portal's chrome: a sidebar on a desktop, a bottom tab bar on a phone,
60
+ * and the sections both of them switch between — one piece of state read by
61
+ * both, so rotating a tablet does not move the user.
62
+ *
63
+ * EVERY SECTION IS IN THE DOM, always, and only the current one is visible:
64
+ * sections hold half-edited forms, and unmounting one on a stray tap would
65
+ * throw that work away. `hidden` rather than a class, so an inactive section
66
+ * is out of the accessibility tree and the tab order too.
67
+ *
68
+ * A `nav` with `aria-current`, not a `tablist`: two tablists driving one set
69
+ * of panels is not a shape the pattern has, and these are page sections.
70
+ */
71
+ export declare function PortalShell<Id extends string = string>({ labels, tabs, tab: tabProp, defaultTab, onTabChange, header, account, logout, logoutTab, classNames, }: PortalShellProps<Id>): import("react").JSX.Element;
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { cn } from '../../lib/utils.js';
5
+ import { slotClass } from '../slots.js';
6
+ export const PORTAL_SHELL_LABEL_KEYS = ['portalShell.nav', 'portalShell.navBar'];
7
+ /**
8
+ * The portal's chrome: a sidebar on a desktop, a bottom tab bar on a phone,
9
+ * and the sections both of them switch between — one piece of state read by
10
+ * both, so rotating a tablet does not move the user.
11
+ *
12
+ * EVERY SECTION IS IN THE DOM, always, and only the current one is visible:
13
+ * sections hold half-edited forms, and unmounting one on a stray tap would
14
+ * throw that work away. `hidden` rather than a class, so an inactive section
15
+ * is out of the accessibility tree and the tab order too.
16
+ *
17
+ * A `nav` with `aria-current`, not a `tablist`: two tablists driving one set
18
+ * of panels is not a shape the pattern has, and these are page sections.
19
+ */
20
+ export function PortalShell({ labels, tabs, tab: tabProp, defaultTab, onTabChange, header, account, logout, logoutTab, classNames, }) {
21
+ const [uncontrolled, setUncontrolled] = useState(defaultTab ?? tabs[0]?.id);
22
+ const tab = tabProp ?? uncontrolled;
23
+ const logoutIn = logoutTab ?? tabs[tabs.length - 1]?.id;
24
+ function select(next) {
25
+ if (tabProp === undefined)
26
+ setUncontrolled(next);
27
+ onTabChange?.(next);
28
+ }
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
+ ? 'bg-foreground text-background'
31
+ : '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
+ // `space-y` on the section rather than a wrapper, so a hidden one
33
+ // 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
+ const ShortIcon = shortIcon ?? icon;
36
+ 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
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { BookingFormat } from '../format.js';
3
+ import { type SlotClassNames } from '../slots.js';
4
+ export declare const PORTAL_UNREACHABLE_LABEL_KEYS: readonly ["portalUnreachable.heading", "portalUnreachable.body", "portalUnreachable.retry", "portalUnreachable.call"];
5
+ /** `portalUnreachable.call` takes `{phone}`. */
6
+ export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number], string>;
7
+ /** - `root`: the section. - `heading`. - `actions`: the row of links. - `link`: each link. */
8
+ export type PortalUnreachableSlot = 'root' | 'heading' | 'actions' | 'link';
9
+ export interface PortalUnreachableProps {
10
+ labels: PortalUnreachableLabels;
11
+ format: Pick<BookingFormat, 'telHref'>;
12
+ /** The business's number, or `null` (then there is no call link). */
13
+ phone: string | null;
14
+ /** «Try again» — the portal page itself. */
15
+ retryHref: string;
16
+ /**
17
+ * The log-out control. This is the one screen a user could otherwise be held
18
+ * on, so the way out must not depend on the thing that is down.
19
+ */
20
+ logout?: ReactNode;
21
+ /** `id` of the heading (for `aria-labelledby`). Default `portal-unreachable-heading`. */
22
+ headingId?: string;
23
+ classNames?: SlotClassNames<PortalUnreachableSlot>;
24
+ }
25
+ /**
26
+ * What the portal shows when the booking system did not answer. The user's
27
+ * bookings are all still there — it is this page that could not read them —
28
+ * so the honest offer is «try again in a moment», pointed back at the same
29
+ * page, and a phone number. Says nothing about WHAT failed.
30
+ */
31
+ export declare function PortalUnreachable({ labels, format, phone, retryHref, logout, headingId, classNames, }: PortalUnreachableProps): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { renderLabel } from '../internal/label-parts.js';
3
+ import { slotClass } from '../slots.js';
4
+ export const PORTAL_UNREACHABLE_LABEL_KEYS = [
5
+ 'portalUnreachable.heading',
6
+ 'portalUnreachable.body',
7
+ 'portalUnreachable.retry',
8
+ 'portalUnreachable.call',
9
+ ];
10
+ /**
11
+ * What the portal shows when the booking system did not answer. The user's
12
+ * bookings are all still there — it is this page that could not read them —
13
+ * so the honest offer is «try again in a moment», pointed back at the same
14
+ * page, and a phone number. Says nothing about WHAT failed.
15
+ */
16
+ export function PortalUnreachable({ labels, format, phone, retryHref, logout, headingId = 'portal-unreachable-heading', classNames, }) {
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: renderLabel(labels['portalUnreachable.call'], { phone }) })), logout] })] }));
19
+ }