@medalsocial/meda 3.0.0 → 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.
- 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 +314 -0
- package/dist/booking/format.d.ts +64 -0
- package/dist/booking/format.js +1 -0
- package/dist/booking/index.d.ts +35 -0
- package/dist/booking/index.js +40 -0
- package/dist/booking/internal/label-parts.d.ts +8 -0
- package/dist/booking/internal/label-parts.js +19 -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 +369 -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 +103 -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 +88 -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 +137 -0
- package/dist/booking/service-screen.js +233 -0
- package/dist/booking/slots.d.ts +12 -0
- package/dist/booking/slots.js +5 -0
- package/dist/booking/stylist-screen.d.ts +117 -0
- package/dist/booking/stylist-screen.js +226 -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 +418 -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 +8 -1
- 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
|
+
}
|