@medalsocial/meda 2.8.1 → 3.2.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 (103) hide show
  1. package/README.md +130 -1
  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 +314 -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 +35 -0
  13. package/dist/booking/index.js +40 -0
  14. package/dist/booking/internal/label-parts.d.ts +8 -0
  15. package/dist/booking/internal/label-parts.js +19 -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 +369 -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 +103 -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 +88 -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 +137 -0
  61. package/dist/booking/service-screen.js +233 -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 +117 -0
  65. package/dist/booking/stylist-screen.js +226 -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 +418 -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/avatar.d.ts +24 -0
  77. package/dist/primitives/avatar.js +44 -0
  78. package/dist/primitives/button.d.ts +21 -0
  79. package/dist/primitives/button.js +51 -0
  80. package/dist/primitives/checkbox.d.ts +23 -0
  81. package/dist/primitives/checkbox.js +20 -0
  82. package/dist/primitives/field.d.ts +47 -0
  83. package/dist/primitives/field.js +48 -0
  84. package/dist/primitives/index.d.ts +16 -0
  85. package/dist/primitives/index.js +8 -0
  86. package/dist/primitives/input.d.ts +10 -0
  87. package/dist/primitives/input.js +9 -0
  88. package/dist/primitives/sheet.d.ts +68 -0
  89. package/dist/primitives/sheet.js +303 -0
  90. package/dist/primitives/textarea.d.ts +14 -0
  91. package/dist/primitives/textarea.js +13 -0
  92. package/dist/primitives/toggle-group.d.ts +46 -0
  93. package/dist/primitives/toggle-group.js +148 -0
  94. package/dist/styles/base.css +40 -0
  95. package/dist/styles/booking.css +10 -0
  96. package/dist/styles/bridge.css +159 -0
  97. package/dist/styles/calendar.css +8 -0
  98. package/dist/styles/primitives.css +10 -0
  99. package/dist/styles/theme.css +12 -162
  100. package/dist/styles/vendor/xyflow.css +626 -0
  101. package/dist/styles/workflow-builder.css +17 -0
  102. package/package.json +45 -9
  103. package/skills/components/SKILL.md +53 -0
@@ -0,0 +1,75 @@
1
+ import { type SlotClassNames } from '../slots.js';
2
+ import type { PortalProfileDto } from '../types.js';
3
+ import { type PortalActionFailure } from './action-result.js';
4
+ 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
+ /** `marketing` is the consent sentence and usually names the business. */
6
+ export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number], string>;
7
+ /**
8
+ * `root` (the section), `heading`, `form`, `status` (the save notice's live
9
+ * region), `save` (the submit button), `consent` (the marketing box's field).
10
+ */
11
+ export type ProfileFormSlot = 'root' | 'heading' | 'form' | 'status' | 'save' | 'consent';
12
+ /**
13
+ * What a save sends. A name is present only when non-empty (a cleared field
14
+ * is not an instruction to file a blank name); the phone always travels, and
15
+ * `''` is how «no phone» is spelled.
16
+ */
17
+ export interface ProfilePatch {
18
+ firstName?: string;
19
+ lastName?: string;
20
+ phone: string;
21
+ }
22
+ /** The fields of the profile the backend stored. */
23
+ export interface StoredProfile {
24
+ firstName: string | null;
25
+ lastName: string | null;
26
+ phone: string | null;
27
+ }
28
+ export type ProfileSaveResult = {
29
+ ok: true;
30
+ profile: StoredProfile;
31
+ } | {
32
+ ok: false;
33
+ kind: 'session';
34
+ } | {
35
+ ok: false;
36
+ kind: 'error';
37
+ message?: string;
38
+ /** The field the rejection is about, when it is about one (only the phone can be). */
39
+ field?: 'phone';
40
+ };
41
+ export type ConsentResult = {
42
+ ok: true;
43
+ marketingConsent: boolean;
44
+ } | PortalActionFailure;
45
+ export interface ProfileFormProps {
46
+ labels: ProfileFormLabels;
47
+ profile: Pick<PortalProfileDto, 'email' | 'firstName' | 'lastName' | 'phone' | 'marketingConsent'>;
48
+ /** Saves names and phone. */
49
+ onSave: (patch: ProfilePatch) => Promise<ProfileSaveResult>;
50
+ /** Stores the marketing choice. The box then shows what was stored. */
51
+ onConsentChange: (accepted: boolean) => Promise<ConsentResult>;
52
+ /** A callback answered `{ kind: 'session' }`: send the visitor to the login. */
53
+ onSessionExpired?: () => void;
54
+ classNames?: SlotClassNames<ProfileFormSlot>;
55
+ }
56
+ /**
57
+ * «About you» — name and phone, the e-mail the login is bound to (read-only:
58
+ * changing it would change who can log in), and the marketing box.
59
+ *
60
+ * When a name WAS cleared, the save re-fills it from the stored profile and
61
+ * the notice says so rather than «saved». The marketing box is its own
62
+ * callback: it flips at once, and flips back if the change was not taken, so
63
+ * the box shows what is stored and not what the browser hoped for.
64
+ *
65
+ * Every message is wired to the input it is about (`aria-describedby`, and
66
+ * `aria-invalid` when it is a mistake). A failure about the REQUEST rather
67
+ * than a field is announced by the live region alone.
68
+ *
69
+ * OPTIMISTIC: the tap shows the outcome at once through `useOptimistic` —
70
+ * names trimmed, a cleared name back to the one on file, and «saved» or the
71
+ * «kept your name» notice — with the fields locked until the callback
72
+ * answers. On failure the optimistic view ends, which rolls the form back to
73
+ * what the parent typed, with the reason.
74
+ */
75
+ export declare function ProfileForm({ labels, profile, onSave, onConsentChange, onSessionExpired, classNames, }: ProfileFormProps): import("react").JSX.Element;
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useOptimistic, useState, useTransition } from 'react';
4
+ import { Checkbox } from '../../primitives/checkbox.js';
5
+ import { Field } from '../../primitives/field.js';
6
+ import { Input } from '../../primitives/input.js';
7
+ import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
8
+ import { slotClass } from '../slots.js';
9
+ import { settle } from './action-result.js';
10
+ export const PROFILE_FORM_LABEL_KEYS = [
11
+ 'profileForm.heading',
12
+ 'profileForm.firstName',
13
+ 'profileForm.lastName',
14
+ 'profileForm.phone',
15
+ 'profileForm.email',
16
+ 'profileForm.emailHelp',
17
+ 'profileForm.save',
18
+ 'profileForm.saved',
19
+ 'profileForm.restored',
20
+ 'profileForm.marketing',
21
+ 'profileForm.unreachable',
22
+ ];
23
+ /**
24
+ * What to say after an accepted save. A name the parent emptied that came
25
+ * back non-empty was kept, not saved — say which; otherwise plain «saved».
26
+ */
27
+ function afterSave(sent, saved) {
28
+ const first = sent.first === '' && (saved.firstName ?? '') !== '';
29
+ const last = sent.last === '' && (saved.lastName ?? '') !== '';
30
+ return first || last ? { kind: 'restored', first, last } : { kind: 'saved' };
31
+ }
32
+ /**
33
+ * What the form will show once the patch is taken: a cleared name back to
34
+ * the one on file, and the notice `afterSave` would give.
35
+ */
36
+ function predictSaved(sent, onFile) {
37
+ return {
38
+ firstName: sent.first || (onFile.firstName ?? ''),
39
+ lastName: sent.last || (onFile.lastName ?? ''),
40
+ phone: sent.phone,
41
+ status: afterSave(sent, onFile),
42
+ };
43
+ }
44
+ /**
45
+ * «About you» — name and phone, the e-mail the login is bound to (read-only:
46
+ * changing it would change who can log in), and the marketing box.
47
+ *
48
+ * When a name WAS cleared, the save re-fills it from the stored profile and
49
+ * the notice says so rather than «saved». The marketing box is its own
50
+ * callback: it flips at once, and flips back if the change was not taken, so
51
+ * the box shows what is stored and not what the browser hoped for.
52
+ *
53
+ * Every message is wired to the input it is about (`aria-describedby`, and
54
+ * `aria-invalid` when it is a mistake). A failure about the REQUEST rather
55
+ * than a field is announced by the live region alone.
56
+ *
57
+ * OPTIMISTIC: the tap shows the outcome at once through `useOptimistic` —
58
+ * names trimmed, a cleared name back to the one on file, and «saved» or the
59
+ * «kept your name» notice — with the fields locked until the callback
60
+ * answers. On failure the optimistic view ends, which rolls the form back to
61
+ * what the parent typed, with the reason.
62
+ */
63
+ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessionExpired, classNames, }) {
64
+ const id = useId();
65
+ const [firstName, setFirstName] = useState(profile.firstName ?? '');
66
+ const [lastName, setLastName] = useState(profile.lastName ?? '');
67
+ const [phone, setPhone] = useState(profile.phone ?? '');
68
+ const [consent, setConsent] = useState(profile.marketingConsent);
69
+ const [status, setStatus] = useState(null);
70
+ const [consentError, setConsentError] = useState(null);
71
+ const [pending, startTransition] = useTransition();
72
+ const [consentPending, startConsent] = useTransition();
73
+ /** What was last stored — the basis for predicting a kept name. */
74
+ const [onFile, setOnFile] = useState(profile);
75
+ // The fields are locked while a save is in flight (`disabled` on the
76
+ // fieldset), so the optimistic view is simply the snapshot the tap took.
77
+ const [shown, showSaving] = useOptimistic({ firstName, lastName, phone, status }, (_current, saving) => saving);
78
+ const statusId = `${id}-status`;
79
+ function save(event) {
80
+ event.preventDefault();
81
+ const first = firstName.trim();
82
+ const last = lastName.trim();
83
+ const patch = {
84
+ ...(first ? { firstName: first } : {}),
85
+ ...(last ? { lastName: last } : {}),
86
+ phone,
87
+ };
88
+ startTransition(async () => {
89
+ showSaving(predictSaved({ first, last, phone }, onFile));
90
+ const result = await settle(() => onSave(patch));
91
+ if (result.ok) {
92
+ const saved = result.profile;
93
+ setOnFile(saved);
94
+ setFirstName(saved.firstName ?? '');
95
+ setLastName(saved.lastName ?? '');
96
+ setPhone(saved.phone ?? '');
97
+ setStatus(afterSave({ first, last }, saved));
98
+ return;
99
+ }
100
+ if (result.kind === 'session') {
101
+ onSessionExpired?.();
102
+ return;
103
+ }
104
+ setStatus({
105
+ kind: 'error',
106
+ message: result.message ?? labels['profileForm.unreachable'],
107
+ field: 'field' in result && result.field === 'phone' ? 'phone' : null,
108
+ });
109
+ });
110
+ }
111
+ function toggleConsent(granted) {
112
+ const previous = consent;
113
+ setConsent(granted);
114
+ setConsentError(null);
115
+ startConsent(async () => {
116
+ const result = await settle(() => onConsentChange(granted));
117
+ if (result.ok) {
118
+ setConsent(result.marketingConsent);
119
+ return;
120
+ }
121
+ // Nothing was saved either way, so the box goes back first.
122
+ setConsent(previous);
123
+ if (result.kind === 'session') {
124
+ onSessionExpired?.();
125
+ return;
126
+ }
127
+ setConsentError(labels['profileForm.unreachable']);
128
+ });
129
+ }
130
+ const view = shown.status;
131
+ const phoneInvalid = view?.kind === 'error' && view.field === 'phone';
132
+ const firstRestored = view?.kind === 'restored' && view.first;
133
+ const lastRestored = view?.kind === 'restored' && view.last;
134
+ return (_jsxs("section", { "aria-labelledby": "portal-profile-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: "portal-profile-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['profileForm.heading'] }), _jsxs("form", { onSubmit: save, className: slotClass(classNames, 'form', 'space-y-4'), children: [_jsxs("fieldset", { disabled: pending, className: "min-w-0 space-y-4", children: [_jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-first`, children: labels['profileForm.firstName'] }), _jsx(Input, { id: `${id}-first`, autoComplete: "given-name", maxLength: 60, value: shown.firstName, "aria-describedby": firstRestored ? statusId : undefined, onChange: (event) => {
135
+ setFirstName(event.target.value);
136
+ setStatus(null);
137
+ }, className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-last`, children: labels['profileForm.lastName'] }), _jsx(Input, { id: `${id}-last`, autoComplete: "family-name", maxLength: 60, value: shown.lastName, "aria-describedby": lastRestored ? statusId : undefined, onChange: (event) => {
138
+ setLastName(event.target.value);
139
+ setStatus(null);
140
+ }, className: BOOKING_INPUT_CLASS })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-phone`, children: labels['profileForm.phone'] }), _jsx(Input, { id: `${id}-phone`, type: "tel", inputMode: "tel", autoComplete: "tel-national", value: shown.phone, "aria-invalid": phoneInvalid || undefined, "aria-describedby": phoneInvalid ? statusId : undefined, onChange: (event) => {
141
+ setPhone(event.target.value);
142
+ setStatus(null);
143
+ }, className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-email`, children: labels['profileForm.email'] }), _jsx(Input, { id: `${id}-email`, type: "email", value: profile.email, readOnly: true, "aria-describedby": `${id}-email-help`, className: BOOKING_INPUT_CLASS }), _jsx(Field.Description, { id: `${id}-email-help`, children: labels['profileForm.emailHelp'] })] })] }), _jsxs("div", { "aria-live": "polite", className: slotClass(classNames, 'status'), children: [view?.kind === 'saved' && (_jsx("p", { id: statusId, className: "text-sm text-muted-foreground", children: labels['profileForm.saved'] })), view?.kind === 'restored' && (_jsx("p", { id: statusId, className: "text-sm text-muted-foreground", children: labels['profileForm.restored'] })), view?.kind === 'error' && _jsx(Field.Error, { id: statusId, children: view.message })] }), _jsx(BookingButton, { type: "submit", disabled: pending, className: slotClass(classNames, 'save'), children: labels['profileForm.save'] })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: `${id}-marketing`, checked: consent, disabled: consentPending, onCheckedChange: toggleConsent, className: BOOKING_CHECKBOX_CLASS }), _jsx(Field.Label, { htmlFor: `${id}-marketing`, className: "cursor-pointer font-normal", children: labels['profileForm.marketing'] })] }), _jsx("div", { "aria-live": "polite", children: consentError && _jsx(Field.Error, { children: consentError }) })] }));
144
+ }
@@ -0,0 +1,44 @@
1
+ import type { ComponentType } from 'react';
2
+ import { type SlotClassNames } from '../slots.js';
3
+ import type { RebookSuggestion } from '../types.js';
4
+ 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], string>;
6
+ /** - `root`: the section. - `heading`. - `empty`. - `list`. - `card`. - `link`: the book link. */
7
+ export type RebookCardsSlot = 'root' | 'heading' | 'empty' | 'list' | 'card' | 'link';
8
+ export interface RebookCardProps {
9
+ suggestion: RebookSuggestion;
10
+ href: string;
11
+ onRebook?: (suggestion: RebookSuggestion) => void;
12
+ labels: RebookCardsLabels;
13
+ classNames?: SlotClassNames<RebookCardsSlot>;
14
+ }
15
+ export interface RebookCardsComponents {
16
+ RebookCard?: ComponentType<RebookCardProps>;
17
+ }
18
+ export interface RebookCardsProps {
19
+ /** Already deduped and ordered by the caller. */
20
+ suggestions: RebookSuggestion[];
21
+ /** The booking link for a suggestion (the catalogue ids the booking flow reads). */
22
+ hrefFor: (suggestion: RebookSuggestion) => string;
23
+ /**
24
+ * Called on the tap, before the link navigates — e.g. to stash the person's
25
+ * name somewhere other than the URL (a minor's name has no business in a
26
+ * query string, which lands in history, access logs and analytics).
27
+ */
28
+ onRebook?: (suggestion: RebookSuggestion) => void;
29
+ labels: RebookCardsLabels;
30
+ /** `id` of the heading. Default `portal-rebook-heading`. */
31
+ headingId?: string;
32
+ classNames?: SlotClassNames<RebookCardsSlot>;
33
+ components?: RebookCardsComponents;
34
+ }
35
+ /**
36
+ * «Book again» — the visits this user has had before, one tap from the
37
+ * booking flow with its questions already answered. A plain `<a>` does the
38
+ * navigating, so the card works as a link; the tap only calls `onRebook`
39
+ * first. Given nothing, it keeps its heading and one sentence, so the
40
+ * overview keeps the same shape for a new user.
41
+ */
42
+ export declare function RebookCards({ suggestions, hrefFor, onRebook, labels, headingId, classNames, components, }: RebookCardsProps): import("react").JSX.Element;
43
+ /** One suggestion (an `<li>`): what · who for · with whom, and the book link. */
44
+ export declare function DefaultRebookCard({ suggestion, href, onRebook, labels, classNames, }: RebookCardProps): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { slotClass } from '../slots.js';
4
+ export const REBOOK_CARDS_LABEL_KEYS = [
5
+ 'rebook.heading',
6
+ 'rebook.empty',
7
+ 'rebook.serviceFallback',
8
+ 'rebook.anyStylist',
9
+ 'rebook.cta',
10
+ ];
11
+ const SEPARATOR = ' · ';
12
+ /**
13
+ * «Book again» — the visits this user has had before, one tap from the
14
+ * booking flow with its questions already answered. A plain `<a>` does the
15
+ * navigating, so the card works as a link; the tap only calls `onRebook`
16
+ * first. Given nothing, it keeps its heading and one sentence, so the
17
+ * overview keeps the same shape for a new user.
18
+ */
19
+ export function RebookCards({ suggestions, hrefFor, onRebook, labels, headingId = 'portal-rebook-heading', classNames, components, }) {
20
+ const Card = components?.RebookCard ?? DefaultRebookCard;
21
+ return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['rebook.heading'] }), suggestions.length === 0 ? (_jsx("p", { className: slotClass(classNames, 'empty', 'text-muted-foreground'), children: labels['rebook.empty'] })) : (_jsx("ul", { className: slotClass(classNames, 'list', 'grid gap-3 sm:grid-cols-2'), children: suggestions.map((suggestion) => {
22
+ const href = hrefFor(suggestion);
23
+ return (_jsx(Card, { suggestion: suggestion, href: href, onRebook: onRebook, labels: labels, classNames: classNames }, `${href}#${suggestion.bookedForName ?? ''}`));
24
+ }) }))] }));
25
+ }
26
+ /** One suggestion (an `<li>`): what · who for · with whom, and the book link. */
27
+ export function DefaultRebookCard({ suggestion, href, onRebook, labels, classNames, }) {
28
+ 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.bookedForName,
31
+ suggestion.resourceName ?? labels['rebook.anyStylist'],
32
+ ]
33
+ .filter(Boolean)
34
+ .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'] })] }));
35
+ }
@@ -0,0 +1,65 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { BookingFormat } from '../format.js';
3
+ import { type SlotClassNames } from '../slots.js';
4
+ import type { PortalBookingDto } from '../types.js';
5
+ 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"];
6
+ /**
7
+ * Placeholders: `upcoming.next` `{when}` (the `today` / `tomorrow` / `inDays`
8
+ * phrase), `upcoming.inDays` `{count}`, `upcoming.when` `{day}` `{time}`,
9
+ * `upcoming.noManageWithPhone` `{call}` (where the `upcoming.call` link goes),
10
+ * `upcoming.call` `{phone}`.
11
+ */
12
+ export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number], string>;
13
+ /**
14
+ * - `root`: the section. - `heading`. - `empty`: the no-bookings box.
15
+ * - `list`. - `card`: a plain card; `cardHero`: the first (next) one.
16
+ * - `badge`: the hero's «next appointment» pill. - `manage`: the manage link.
17
+ * - `heroMuted`: the hero's softened lines (when, and the no-manage sentence).
18
+ * The defaults tint `primary-foreground` down; a brand whose `--primary` is
19
+ * mid-luminance may need full strength here (and a plain `badge`) to keep
20
+ * 4.5:1 contrast.
21
+ */
22
+ export type UpcomingBookingsSlot = 'root' | 'heading' | 'empty' | 'list' | 'card' | 'cardHero' | 'badge' | 'manage' | 'heroMuted';
23
+ export interface BookingCardProps {
24
+ booking: PortalBookingDto;
25
+ phone: string | null;
26
+ now: number;
27
+ /** The first card: the next appointment, drawn as the hero panel. */
28
+ hero: boolean;
29
+ labels: UpcomingBookingsLabels;
30
+ format: BookingFormat;
31
+ classNames?: SlotClassNames<UpcomingBookingsSlot>;
32
+ }
33
+ export interface UpcomingBookingsComponents {
34
+ BookingCard?: ComponentType<BookingCardProps>;
35
+ }
36
+ export interface UpcomingBookingsProps {
37
+ /** Soonest first. */
38
+ bookings: PortalBookingDto[];
39
+ /** The business's number, or `null` (then «contact us» is unlinked). */
40
+ phone: string | null;
41
+ labels: UpcomingBookingsLabels;
42
+ format: BookingFormat;
43
+ /** Where booking happens (the empty state's button). */
44
+ bookingHref: string;
45
+ /** Default `Date.now()`. */
46
+ now?: number;
47
+ /** `id` of the heading. Default `portal-upcoming-heading`. */
48
+ headingId?: string;
49
+ classNames?: SlotClassNames<UpcomingBookingsSlot>;
50
+ components?: UpcomingBookingsComponents;
51
+ }
52
+ /**
53
+ * «Upcoming appointments» — one card per appointment still ahead.
54
+ *
55
+ * The one action per card is the manage link minted for that booking; a row
56
+ * with no `managePath` (made over the counter, say) is sent to the telephone
57
+ * instead, because a link to nowhere is worse than none.
58
+ *
59
+ * THE FIRST CARD IS THE HERO: the next appointment is the one fact a user
60
+ * opens this page for. Everything after it stays a plain card — a list of
61
+ * heroes is a list of nothing.
62
+ */
63
+ export declare function UpcomingBookings({ bookings, phone, labels, format, bookingHref, now, headingId, classNames, components, }: UpcomingBookingsProps): import("react").JSX.Element;
64
+ /** One appointment (an `<li>`), drawn as the hero panel or as a plain card. */
65
+ export declare function DefaultBookingCard({ booking, phone, now, hero, labels, format, classNames, }: BookingCardProps): import("react").JSX.Element;
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from '../../lib/utils.js';
4
+ import { labelParts } from '../internal/label-parts.js';
5
+ import { bookingButtonClass } from '../internal/ui.js';
6
+ import { fillLabel } from '../labels.js';
7
+ import { slotClass } from '../slots.js';
8
+ export const UPCOMING_BOOKINGS_LABEL_KEYS = [
9
+ 'upcoming.heading',
10
+ 'upcoming.empty',
11
+ 'upcoming.book',
12
+ 'upcoming.next',
13
+ 'upcoming.today',
14
+ 'upcoming.tomorrow',
15
+ 'upcoming.inDays',
16
+ 'upcoming.when',
17
+ 'upcoming.serviceFallback',
18
+ 'upcoming.anyStylist',
19
+ 'upcoming.manage',
20
+ 'upcoming.noManage',
21
+ 'upcoming.noManageWithPhone',
22
+ 'upcoming.call',
23
+ ];
24
+ const SEPARATOR = ' · ';
25
+ /**
26
+ * How far off the next appointment is, on the BUSINESS's calendar — so
27
+ * «tomorrow» means the next date rather than «in 24 hours».
28
+ */
29
+ function howFarOff(startTs, now, labels, format) {
30
+ const days = format.clock.daysBetween(now, startTs);
31
+ if (days <= 0)
32
+ return labels['upcoming.today'];
33
+ if (days === 1)
34
+ return labels['upcoming.tomorrow'];
35
+ return fillLabel(labels['upcoming.inDays'], { count: days });
36
+ }
37
+ /** What is booked and who for. */
38
+ function whatLine(booking, labels) {
39
+ return [booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName]
40
+ .filter(Boolean)
41
+ .join(SEPARATOR);
42
+ }
43
+ /** When, with whom, and what it costs. */
44
+ function whenLine(booking, now, labels, format) {
45
+ return [
46
+ fillLabel(labels['upcoming.when'], {
47
+ day: format.clock.dayLabel(booking.startTs, now),
48
+ time: format.clock.formatTime(booking.startTs),
49
+ }),
50
+ booking.resourceName ?? labels['upcoming.anyStylist'],
51
+ booking.amountOre === null ? null : format.price(booking.amountOre),
52
+ ]
53
+ .filter(Boolean)
54
+ .join(SEPARATOR);
55
+ }
56
+ /**
57
+ * «Upcoming appointments» — one card per appointment still ahead.
58
+ *
59
+ * The one action per card is the manage link minted for that booking; a row
60
+ * with no `managePath` (made over the counter, say) is sent to the telephone
61
+ * instead, because a link to nowhere is worse than none.
62
+ *
63
+ * THE FIRST CARD IS THE HERO: the next appointment is the one fact a user
64
+ * opens this page for. Everything after it stays a plain card — a list of
65
+ * heroes is a list of nothing.
66
+ */
67
+ export function UpcomingBookings({ bookings, phone, labels, format, bookingHref, now = Date.now(), headingId = 'portal-upcoming-heading', classNames, components, }) {
68
+ const Card = components?.BookingCard ?? DefaultBookingCard;
69
+ return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['upcoming.heading'] }), bookings.length === 0 ? (_jsxs("div", { className: slotClass(classNames, 'empty', 'space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "text-muted-foreground", children: labels['upcoming.empty'] }), _jsx("a", { href: bookingHref, className: bookingButtonClass({ size: 'lg' }), children: labels['upcoming.book'] })] })) : (_jsx("ul", { className: slotClass(classNames, 'list', 'space-y-3'), children: bookings.map((booking, index) => (_jsx(Card, { booking: booking, phone: phone, now: now, hero: index === 0, labels: labels, format: format, classNames: classNames }, booking.bookingId))) }))] }));
70
+ }
71
+ /** One appointment (an `<li>`), drawn as the hero panel or as a plain card. */
72
+ export function DefaultBookingCard({ booking, phone, now, hero, labels, format, classNames, }) {
73
+ return (_jsxs("li", { className: cn('space-y-2 px-5 py-4', hero
74
+ ? 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" }), fillLabel(labels['upcoming.next'], {
76
+ when: howFarOff(booking.startTs, now, labels, format),
77
+ })] })), _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
+ ? 'inline-block rounded-lg bg-background px-4 py-2 text-foreground no-underline'
79
+ : 'text-primary'), children: labels['upcoming.manage'] })) : (_jsx("p", { className: cn('text-sm', hero
80
+ ? slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')
81
+ : 'text-muted-foreground'), children: phone
82
+ ? 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: fillLabel(labels['upcoming.call'], { phone }) })),
84
+ })
85
+ : // No number on file: the sentence without a link, because a `tel:`
86
+ // that dials nothing fails in the hand of someone already stuck.
87
+ labels['upcoming.noManage'] }))] }));
88
+ }
@@ -0,0 +1,54 @@
1
+ import { type SlotClassNames } from '../slots.js';
2
+ 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], string>;
4
+ /** `root` (the section), `card` (the bordered row), `heading`, `button`, `notice` (the live line). */
5
+ export type VippsLinkRowSlot = 'root' | 'card' | 'heading' | 'button' | 'notice';
6
+ /** The outcome of a link attempt that just returned, as the server recorded it. */
7
+ export type VippsLinkFlash = 'linked' | 'link_conflict' | 'link_failed';
8
+ /**
9
+ * What starting a link answers when it did NOT navigate to Vipps:
10
+ * `missing` — the backend has no link route (the row takes itself away);
11
+ * `session` — the session is dead; `unavailable` / `throttled` — say why.
12
+ * `null` = nothing to report (e.g. the navigation is under way).
13
+ */
14
+ export type VippsLinkStartResult = {
15
+ ok: false;
16
+ reason: 'missing' | 'session' | 'unavailable' | 'throttled';
17
+ } | null;
18
+ export interface VippsLinkRowProps {
19
+ labels: VippsLinkRowLabels;
20
+ /** Whether the profile says Vipps is linked — the ONLY source of the row's state. */
21
+ linked: boolean;
22
+ /** The server-recorded outcome of the link attempt that just returned, if any. */
23
+ flash: VippsLinkFlash | null;
24
+ /**
25
+ * Starts linking. Runs as the form's action (so `useFormStatus` pending
26
+ * applies); on success it navigates to Vipps and its answer is not seen.
27
+ */
28
+ onStart: () => Promise<VippsLinkStartResult>;
29
+ /** A start answered `session`: send the visitor to the login. */
30
+ onSessionExpired?: () => void;
31
+ /**
32
+ * Called once on mount with a non-null `flash`: the caller shows its own
33
+ * toast for `linked` and spends the flash so a reload says nothing again.
34
+ */
35
+ onFlash?: (flash: VippsLinkFlash) => void;
36
+ /** Also say `vippsLink.success` in the notice line for a `linked` flash (for callers without a toast). */
37
+ announceSuccess?: boolean;
38
+ classNames?: SlotClassNames<VippsLinkRowSlot>;
39
+ }
40
+ /**
41
+ * «Vipps» on the profile: «linked», or a button that links the visitor's
42
+ * Vipps account to the profile they are logged in to, so the next login is
43
+ * one tap.
44
+ *
45
+ * A real `<form action>`: the start ends in a navigation to Vipps, and the
46
+ * form's pending state covers it. On the way back the caller hands this row
47
+ * the `flash` the server recorded: `linked` is the caller's toast
48
+ * (`onFlash`), a conflict or a failure is a sentence under the row. Whether
49
+ * the row reads «linked» comes ONLY from `linked`, the profile's own answer —
50
+ * no flash can flip it.
51
+ *
52
+ * Layout-stable: the notice line is always mounted.
53
+ */
54
+ export declare function VippsLinkRow({ labels, linked, flash, onStart, onSessionExpired, onFlash, announceSuccess, classNames, }: VippsLinkRowProps): import("react").JSX.Element | null;
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useActionState, useEffect, useId, useRef } from 'react';
4
+ import { useFormStatus } from 'react-dom';
5
+ import { cn } from '../../lib/utils.js';
6
+ import { BookingButton } from '../internal/ui.js';
7
+ import { slotClass } from '../slots.js';
8
+ export const VIPPS_LINK_ROW_LABEL_KEYS = [
9
+ 'vippsLink.heading',
10
+ 'vippsLink.linked',
11
+ 'vippsLink.pitch',
12
+ 'vippsLink.button',
13
+ 'vippsLink.success',
14
+ 'vippsLink.conflict',
15
+ 'vippsLink.failed',
16
+ 'vippsLink.unavailable',
17
+ 'vippsLink.throttled',
18
+ ];
19
+ function LinkButton({ label, className }) {
20
+ const { pending } = useFormStatus();
21
+ return (_jsx(BookingButton, { type: "submit", size: "sm", disabled: pending, "aria-busy": pending || undefined, className: cn('rounded-full bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/40', className), children: label }));
22
+ }
23
+ /** The sentence under the row: a failed start first, else what the return said. */
24
+ function noticeFor(state, isLinked, flash, labels, announceSuccess) {
25
+ if (state?.reason === 'unavailable')
26
+ return labels['vippsLink.unavailable'];
27
+ if (state?.reason === 'throttled')
28
+ return labels['vippsLink.throttled'];
29
+ if (flash === 'linked' && announceSuccess)
30
+ return labels['vippsLink.success'];
31
+ if (isLinked)
32
+ return null;
33
+ if (flash === 'link_conflict')
34
+ return labels['vippsLink.conflict'];
35
+ if (flash === 'link_failed')
36
+ return labels['vippsLink.failed'];
37
+ return null;
38
+ }
39
+ /**
40
+ * «Vipps» on the profile: «linked», or a button that links the visitor's
41
+ * Vipps account to the profile they are logged in to, so the next login is
42
+ * one tap.
43
+ *
44
+ * A real `<form action>`: the start ends in a navigation to Vipps, and the
45
+ * form's pending state covers it. On the way back the caller hands this row
46
+ * the `flash` the server recorded: `linked` is the caller's toast
47
+ * (`onFlash`), a conflict or a failure is a sentence under the row. Whether
48
+ * the row reads «linked» comes ONLY from `linked`, the profile's own answer —
49
+ * no flash can flip it.
50
+ *
51
+ * Layout-stable: the notice line is always mounted.
52
+ */
53
+ export function VippsLinkRow({ labels, linked, flash, onStart, onSessionExpired, onFlash, announceSuccess = false, classNames, }) {
54
+ const id = useId();
55
+ const [state, formAction] = useActionState(async () => {
56
+ try {
57
+ return await onStart();
58
+ }
59
+ catch {
60
+ return { ok: false, reason: 'unavailable' };
61
+ }
62
+ }, null);
63
+ const announced = useRef(false);
64
+ useEffect(() => {
65
+ if (announced.current || flash === null)
66
+ return;
67
+ announced.current = true;
68
+ onFlash?.(flash);
69
+ }, [flash, onFlash]);
70
+ useEffect(() => {
71
+ if (state?.reason === 'session')
72
+ onSessionExpired?.();
73
+ }, [state, onSessionExpired]);
74
+ if (state?.reason === 'missing')
75
+ return null;
76
+ const sentence = noticeFor(state, linked, flash, labels, announceSuccess);
77
+ const headingId = `${id}-heading`;
78
+ 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
+ }