@medalsocial/meda 3.5.0 → 3.6.1
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/dist/booking/details-screen.d.ts +4 -2
- package/dist/booking/details-screen.js +6 -2
- package/dist/booking/internal/service-multi.js +3 -2
- package/dist/booking/label-keys.d.ts +1 -1
- package/dist/booking/login-panel.d.ts +9 -5
- package/dist/booking/login-panel.js +26 -5
- package/dist/booking/login-sheet.d.ts +1 -1
- package/dist/booking/service-screen.d.ts +6 -0
- package/dist/booking/service-screen.js +20 -3
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from
|
|
|
13
13
|
* booking — the keystrokes behind the per-child fields, which field has focus,
|
|
14
14
|
* whether submit has been pressed, and a fallback submission nonce.
|
|
15
15
|
*/
|
|
16
|
-
export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["details.heading", "details.phone.label", "details.phone.prefix", "details.phone.error", "details.name.label", "details.known.label", "details.known.for", "details.known.self", "details.known.adult", "details.known.child", "details.child.name", "details.child.year", "details.child.yearPlaceholder", "details.email.label", "details.email.help", "details.email.error", "details.notes.label", "details.notes.placeholder", "details.terms.text", "details.terms.link", "details.marketing.text", "details.error.maxParty", "details.error.slotTaken", "details.error.conflict", "details.error.invalidInput", "details.error.unconfigured", "details.error.upstreamError", "details.error.inProgress", "details.call.link", "details.call.none", "details.submit"];
|
|
16
|
+
export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["details.heading", "details.phone.label", "details.phone.prefix", "details.phone.error", "details.name.label", "details.known.label", "details.known.for", "details.known.self", "details.known.adult", "details.known.child", "details.child.name", "details.child.year", "details.child.yearPlaceholder", "details.email.label", "details.email.help", "details.email.lockedHelp", "details.email.error", "details.notes.label", "details.notes.placeholder", "details.terms.text", "details.terms.link", "details.marketing.text", "details.error.maxParty", "details.error.slotTaken", "details.error.conflict", "details.error.invalidInput", "details.error.unconfigured", "details.error.upstreamError", "details.error.inProgress", "details.call.link", "details.call.none", "details.submit"];
|
|
17
17
|
export type DetailsScreenLabelKey = (typeof DETAILS_SCREEN_LABEL_KEYS)[number];
|
|
18
18
|
export type DetailsScreenLabels = Record<DetailsScreenLabelKey, BookingLabel>;
|
|
19
19
|
/**
|
|
@@ -110,6 +110,8 @@ export interface DetailsScreenProps {
|
|
|
110
110
|
guardianPhone?: string | null;
|
|
111
111
|
/** When set, a link to the full terms is drawn after the terms sentence. */
|
|
112
112
|
termsHref?: string | null;
|
|
113
|
+
/** The e-mail is the address the guardian logged in with: shown, not editable. */
|
|
114
|
+
emailReadOnly?: boolean;
|
|
113
115
|
/** Draw the (optional, unticked) marketing opt-in. */
|
|
114
116
|
marketingConsent?: boolean;
|
|
115
117
|
format: BookingFormat;
|
|
@@ -119,4 +121,4 @@ export interface DetailsScreenProps {
|
|
|
119
121
|
}
|
|
120
122
|
/** Blank is fine (the field is optional). Anything else has to be reachable. */
|
|
121
123
|
export declare function looksLikeEmail(value: string): boolean;
|
|
122
|
-
export declare function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting, phone, submissionNonce: nonceProp, family, guardianPhone, termsHref, marketingConsent, format, labels, classNames, components, }: DetailsScreenProps): import("react").JSX.Element;
|
|
124
|
+
export declare function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting, phone, submissionNonce: nonceProp, family, guardianPhone, termsHref, emailReadOnly, marketingConsent, format, labels, classNames, components, }: DetailsScreenProps): import("react").JSX.Element;
|
|
@@ -42,6 +42,7 @@ export const DETAILS_SCREEN_LABEL_KEYS = [
|
|
|
42
42
|
'details.child.yearPlaceholder',
|
|
43
43
|
'details.email.label',
|
|
44
44
|
'details.email.help',
|
|
45
|
+
'details.email.lockedHelp',
|
|
45
46
|
'details.email.error',
|
|
46
47
|
'details.notes.label',
|
|
47
48
|
'details.notes.placeholder',
|
|
@@ -171,7 +172,7 @@ function isKnownChair(state, index) {
|
|
|
171
172
|
// ---------------------------------------------------------------------------
|
|
172
173
|
// Screen
|
|
173
174
|
// ---------------------------------------------------------------------------
|
|
174
|
-
export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting = false, phone = null, submissionNonce: nonceProp, family, guardianPhone = null, termsHref = null, marketingConsent = false, format, labels, classNames, components, }) {
|
|
175
|
+
export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting = false, phone = null, submissionNonce: nonceProp, family, guardianPhone = null, termsHref = null, emailReadOnly = false, marketingConsent = false, format, labels, classNames, components, }) {
|
|
175
176
|
const ids = useId();
|
|
176
177
|
const field = (name) => `${ids}-${name}`;
|
|
177
178
|
const phoneRef = useRef(null);
|
|
@@ -197,6 +198,9 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
197
198
|
const phoneTyped = state.contact.phone.trim().length > 0;
|
|
198
199
|
const phoneValid = phoneLooksValid(state.contact.phone);
|
|
199
200
|
const emailValid = looksLikeEmail(state.contact.email);
|
|
201
|
+
// Only lock an address that is present and well-formed; a blank or malformed
|
|
202
|
+
// one stays editable so the parent is never stuck with an unfixable field.
|
|
203
|
+
const emailLocked = emailReadOnly && state.contact.email.trim().length > 0 && emailValid;
|
|
200
204
|
const showPhoneError = !phoneValid && (attempted || (phoneTyped && focused !== 'phone'));
|
|
201
205
|
const showEmailError = !emailValid && (attempted || focused !== 'email');
|
|
202
206
|
const showTermsError = attempted && !state.consentTerms;
|
|
@@ -295,7 +299,7 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
295
299
|
// A seat step 1 already named is not asked again: a second answer could disagree.
|
|
296
300
|
isKnownChair(state, index) ? null : (_jsx(ChildFields
|
|
297
301
|
// biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
|
|
298
|
-
, { legend: state.items.length > 1 ? visitName(item) : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labelText(labels['details.notes.placeholder']), value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: renderLabel(labels['details.submit'], { price }) })] }));
|
|
302
|
+
, { legend: state.items.length > 1 ? visitName(item) : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, readOnly: emailLocked || undefined, "aria-readonly": emailLocked || undefined, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: emailLocked ? labels['details.email.lockedHelp'] : labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labelText(labels['details.notes.placeholder']), value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: renderLabel(labels['details.submit'], { price }) })] }));
|
|
299
303
|
}
|
|
300
304
|
/**
|
|
301
305
|
* The name and birth year for one line. Wrapped in a fieldset only when there
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Check } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../lib/utils.js';
|
|
5
|
+
import { labelText } from '../labels.js';
|
|
5
6
|
import { LiveStatus } from '../live-status.js';
|
|
6
7
|
import { slotClass } from '../slots.js';
|
|
7
8
|
import { renderLabel } from './label-parts.js';
|
|
@@ -38,13 +39,13 @@ export function PersonTabs({ people, active, done, onSelect, labels, labelledBy,
|
|
|
38
39
|
return (_jsx("div", { role: "tablist", "aria-labelledby": labelledBy, "aria-describedby": describedBy, onKeyDown: onKeyDown, className: "-mx-1 flex gap-2 overflow-x-auto px-1 py-1", children: people.map((person, index) => {
|
|
39
40
|
const selected = index === active;
|
|
40
41
|
const finished = done(index);
|
|
41
|
-
return (_jsxs("button", { type: "button", role: "tab", id: serviceTabId(index), "aria-selected": selected, "aria-controls": selected ? serviceTabPanelId(index) : undefined, tabIndex: selected ? 0 : -1, onClick: () => onSelect(index),
|
|
42
|
+
return (_jsxs("button", { type: "button", role: "tab", id: serviceTabId(index), "aria-selected": selected, "aria-label": finished ? `${person.label}, ${labelText(labels['service.tabDone'])}` : undefined, "aria-controls": selected ? serviceTabPanelId(index) : undefined, tabIndex: selected ? 0 : -1, onClick: () => onSelect(index),
|
|
42
43
|
// Weight and ring carry the open tab alongside the colour, as the
|
|
43
44
|
// category pills do. Focus is an outline, so it stacks on the
|
|
44
45
|
// selection ring instead of replacing it with a fainter one.
|
|
45
46
|
className: cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-full border-[1.5px] px-4 text-sm whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring', selected
|
|
46
47
|
? 'border-primary bg-primary/10 font-bold text-foreground ring-2 ring-primary'
|
|
47
|
-
: 'border-primary/35 font-normal text-primary hover:border-primary hover:bg-primary/5', classNames?.tab, selected && classNames?.tabSelected), children: [finished && _jsx(Check, { "aria-hidden": "true", className: "size-4 shrink-0" }), person.label
|
|
48
|
+
: 'border-primary/35 font-normal text-primary hover:border-primary hover:bg-primary/5', classNames?.tab, selected && classNames?.tabSelected), children: [finished && _jsx(Check, { "aria-hidden": "true", className: "size-4 shrink-0" }), person.label] }, person.key));
|
|
48
49
|
}) }));
|
|
49
50
|
}
|
|
50
51
|
/**
|
|
@@ -29,7 +29,7 @@ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
|
|
|
29
29
|
* (Screens that embed another screen already include its keys, e.g. the
|
|
30
30
|
* login sheet carries the login panel's, OTP slots' and Vipps button's.)
|
|
31
31
|
*/
|
|
32
|
-
export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "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" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.multiHint" | "service.total" | "service.chooseFirst" | "service.continue" | "service.party.nothingBookable" | "service.tabDone" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
|
|
32
|
+
export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.lockedHelp" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.continueEmail" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "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" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.multiHint" | "service.total" | "service.chooseFirst" | "service.continue" | "service.party.nothingBookable" | "service.tabDone" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
|
|
33
33
|
export type BookingLabelKey = BookingRequiredLabelKey | BookingOptionalLabelKey;
|
|
34
34
|
/**
|
|
35
35
|
* Keys added after a screen first shipped, for a feature a caller opts into.
|
|
@@ -4,8 +4,8 @@ import { type OtpSlotsLabels, type OtpSlotsSlot } from './otp-slots.js';
|
|
|
4
4
|
import { type SlotClassNames } from './slots.js';
|
|
5
5
|
import type { BookingGuardian } from './types.js';
|
|
6
6
|
import { type VippsButtonLabels, type VippsButtonSlot, type VippsStartAction } from './vipps-button.js';
|
|
7
|
-
export declare const LOGIN_PANEL_OWN_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict"];
|
|
8
|
-
export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
7
|
+
export declare const LOGIN_PANEL_OWN_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.continueEmail", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict"];
|
|
8
|
+
export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.continueEmail", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
9
9
|
/**
|
|
10
10
|
* Placeholders: `login.codeHelp` `{email}`, `login.vippsSentTo` `{to}`,
|
|
11
11
|
* `login.cooldownWait` `{seconds}`, `login.resendIn` `{time}` («0:30»).
|
|
@@ -20,13 +20,13 @@ export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number
|
|
|
20
20
|
* - `divider`: the «or» rule between Vipps and e-mail.
|
|
21
21
|
* - `links`: the row under the code form; `link`: each of its buttons.
|
|
22
22
|
*/
|
|
23
|
-
export type LoginPanelSlot = 'root' | 'notice' | 'form' | 'input' | 'submit' | 'divider' | 'links' | 'link';
|
|
23
|
+
export type LoginPanelSlot = 'root' | 'notice' | 'form' | 'input' | 'submit' | 'divider' | 'emailButton' | 'links' | 'link';
|
|
24
24
|
/** What asking for a code answered. `invalidEmail`: the address was refused. */
|
|
25
25
|
export type LoginStartResult = {
|
|
26
26
|
ok: true;
|
|
27
27
|
} | {
|
|
28
28
|
ok: false;
|
|
29
|
-
reason: 'unreachable' | 'invalidEmail';
|
|
29
|
+
reason: 'unreachable' | 'invalidEmail' | 'throttled';
|
|
30
30
|
};
|
|
31
31
|
/** What checking a code answered. `conflict`: the Vipps account belongs to another profile. */
|
|
32
32
|
export type LoginVerifyResult = {
|
|
@@ -53,6 +53,8 @@ export interface LoginPanelMemory {
|
|
|
53
53
|
sentTo: string;
|
|
54
54
|
/** When «send a new code» opens again (epoch ms); `0` before any code went out. */
|
|
55
55
|
resendAt: number;
|
|
56
|
+
/** `emailCollapsed` only: the e-mail form has been opened. */
|
|
57
|
+
emailOpen?: boolean;
|
|
56
58
|
}
|
|
57
59
|
export declare const FRESH_LOGIN_MEMORY: LoginPanelMemory;
|
|
58
60
|
/** Where a Vipps return that asks for the code starts: on the code, for `to`. */
|
|
@@ -75,6 +77,8 @@ export interface LoginPanelProps {
|
|
|
75
77
|
onVipps?: VippsStartAction;
|
|
76
78
|
/** Where a Vipps login should land. */
|
|
77
79
|
vippsNext?: string | null;
|
|
80
|
+
/** Show e-mail as a second button under Vipps; the form opens on tap. Ignored without `onVipps`. */
|
|
81
|
+
emailCollapsed?: boolean;
|
|
78
82
|
/** Replaces the Vipps button's label. */
|
|
79
83
|
vippsLabel?: string;
|
|
80
84
|
/** Start on the Vipps confirm code screen (uncontrolled memory only). */
|
|
@@ -106,4 +110,4 @@ export interface LoginPanelProps {
|
|
|
106
110
|
* waits between asks, so an impatient tap does not spend the rate-limit
|
|
107
111
|
* allowance on a code that is still valid.
|
|
108
112
|
*/
|
|
109
|
-
export declare function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext, vippsLabel, vippsConfirm, memory: memoryProp, onMemoryChange, heading, intro, resendCooldownMs, classNames, otpClassNames, vippsClassNames, }: LoginPanelProps): import("react").JSX.Element;
|
|
113
|
+
export declare function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext, vippsLabel, emailCollapsed, vippsConfirm, memory: memoryProp, onMemoryChange, heading, intro, resendCooldownMs, classNames, otpClassNames, vippsClassNames, }: LoginPanelProps): import("react").JSX.Element;
|
|
@@ -15,6 +15,7 @@ export const LOGIN_PANEL_OWN_LABEL_KEYS = [
|
|
|
15
15
|
'login.heading',
|
|
16
16
|
'login.codeHeading',
|
|
17
17
|
'login.or',
|
|
18
|
+
'login.continueEmail',
|
|
18
19
|
'login.emailIntro',
|
|
19
20
|
'login.emailLabel',
|
|
20
21
|
'login.sendCode',
|
|
@@ -56,6 +57,12 @@ export function loginMemoryFor(vippsConfirm) {
|
|
|
56
57
|
? { mode: 'vipps', email: '', sentTo: vippsConfirm.to ?? '', resendAt: 0 }
|
|
57
58
|
: FRESH_LOGIN_MEMORY;
|
|
58
59
|
}
|
|
60
|
+
/** Which notice each refused send shows. */
|
|
61
|
+
const START_NOTICE = {
|
|
62
|
+
invalidEmail: 'badEmail',
|
|
63
|
+
throttled: 'throttled',
|
|
64
|
+
unreachable: 'unreachable',
|
|
65
|
+
};
|
|
59
66
|
/** The notices that describe a mistake in the field they sit under. */
|
|
60
67
|
const FIELD_ERRORS = new Set([
|
|
61
68
|
'badEmail',
|
|
@@ -92,11 +99,11 @@ function verifyNotice(reason, vipps) {
|
|
|
92
99
|
* waits between asks, so an impatient tap does not spend the rate-limit
|
|
93
100
|
* allowance on a code that is still valid.
|
|
94
101
|
*/
|
|
95
|
-
export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext = null, vippsLabel, vippsConfirm = null, memory: memoryProp, onMemoryChange, heading = () => null, intro, resendCooldownMs = 30_000, classNames, otpClassNames, vippsClassNames, }) {
|
|
102
|
+
export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext = null, vippsLabel, emailCollapsed = false, vippsConfirm = null, memory: memoryProp, onMemoryChange, heading = () => null, intro, resendCooldownMs = 30_000, classNames, otpClassNames, vippsClassNames, }) {
|
|
96
103
|
const id = useId();
|
|
97
104
|
const [heldHere, setHeldHere] = useState(() => loginMemoryFor(vippsConfirm));
|
|
98
105
|
const controlled = memoryProp !== undefined && onMemoryChange !== undefined;
|
|
99
|
-
const { mode, email, sentTo, resendAt } = controlled ? memoryProp : heldHere;
|
|
106
|
+
const { mode, email, sentTo, resendAt, emailOpen = false } = controlled ? memoryProp : heldHere;
|
|
100
107
|
const setMemory = controlled ? onMemoryChange : setHeldHere;
|
|
101
108
|
const remember = (patch) => setMemory((previous) => ({ ...previous, ...patch }));
|
|
102
109
|
const setEmail = (next) => remember({ email: next });
|
|
@@ -112,6 +119,10 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
112
119
|
const verifying = useRef(false);
|
|
113
120
|
const [checking, setChecking] = useState(false);
|
|
114
121
|
const codeRef = useRef(null);
|
|
122
|
+
const emailRef = useRef(null);
|
|
123
|
+
const collapsed = emailCollapsed && !!onVipps && !emailOpen;
|
|
124
|
+
/** Focus only after a tap opened the form, not when memory restores it. */
|
|
125
|
+
const focusEmail = useRef(false);
|
|
115
126
|
const emailId = `${id}-email`;
|
|
116
127
|
const codeId = `${id}-code`;
|
|
117
128
|
const noticeId = `${id}-notice`;
|
|
@@ -136,6 +147,12 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
136
147
|
setFocusCode(false);
|
|
137
148
|
codeRef.current?.querySelector('input')?.focus();
|
|
138
149
|
}, [focusCode, pending, mode]);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!focusEmail.current || collapsed)
|
|
152
|
+
return;
|
|
153
|
+
focusEmail.current = false;
|
|
154
|
+
emailRef.current?.focus();
|
|
155
|
+
}, [collapsed]);
|
|
139
156
|
function requestCode(address, resend) {
|
|
140
157
|
startTransition(async () => {
|
|
141
158
|
let result;
|
|
@@ -146,7 +163,7 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
146
163
|
result = { ok: false, reason: 'unreachable' };
|
|
147
164
|
}
|
|
148
165
|
if (!result.ok) {
|
|
149
|
-
setNotice(result.reason
|
|
166
|
+
setNotice(START_NOTICE[result.reason]);
|
|
150
167
|
return;
|
|
151
168
|
}
|
|
152
169
|
const sentAt = Date.now();
|
|
@@ -206,7 +223,8 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
206
223
|
verify(code);
|
|
207
224
|
}
|
|
208
225
|
function switchEmail() {
|
|
209
|
-
|
|
226
|
+
// They already chose e-mail: back to the open form, not the two buttons.
|
|
227
|
+
remember({ mode: 'start', emailOpen: true });
|
|
210
228
|
setCode('');
|
|
211
229
|
setNotice(null);
|
|
212
230
|
}
|
|
@@ -228,7 +246,10 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
228
246
|
const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
|
|
229
247
|
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(renderLabel(labels[mode === 'start' ? 'login.heading' : 'login.codeHeading'])), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
|
|
230
248
|
? labels['login.vippsSentToUnknown']
|
|
231
|
-
: renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })
|
|
249
|
+
: renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [!collapsed && intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), !emailCollapsed && (_jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] }))] })), collapsed ? (_jsx(BookingButton, { type: "button", variant: "outline", size: "lg", className: slotClass(classNames, 'emailButton', 'w-full'), onClick: () => {
|
|
250
|
+
focusEmail.current = true;
|
|
251
|
+
remember({ emailOpen: true });
|
|
252
|
+
}, children: labels['login.continueEmail'] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { ref: emailRef, id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": (isFieldError && notice !== 'throttled') || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] }))] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
|
|
232
253
|
// Read-only rather than disabled while a request is out, so
|
|
233
254
|
// the caret stays in the field instead of falling out of a
|
|
234
255
|
// dialog; a wrong code then only has to empty it.
|
|
@@ -3,7 +3,7 @@ import { type LoginPanelLabels, type LoginPanelProps } from './login-panel.js';
|
|
|
3
3
|
import { type SlotClassNames } from './slots.js';
|
|
4
4
|
import type { BookingGuardian } from './types.js';
|
|
5
5
|
export declare const LOGIN_SHEET_OWN_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close"];
|
|
6
|
-
export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
6
|
+
export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.continueEmail", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
7
7
|
/** `loginSheet.prompt` holds a `{trigger}` placeholder where the trigger button goes. */
|
|
8
8
|
export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], BookingLabel> & LoginPanelLabels;
|
|
9
9
|
/**
|
|
@@ -51,6 +51,12 @@ export interface ServiceScreenCategory {
|
|
|
51
51
|
key: string;
|
|
52
52
|
/** The pill's and the section heading's text. */
|
|
53
53
|
label: string;
|
|
54
|
+
/**
|
|
55
|
+
* Who the group is for. In a family, a child's menu is `childCategory` plus
|
|
56
|
+
* every group marked `'any'` (an ear piercing, say) — never a grown-ups'
|
|
57
|
+
* group. Absent: the group is a grown-ups' one, as before.
|
|
58
|
+
*/
|
|
59
|
+
audience?: 'child' | 'adult' | 'any';
|
|
54
60
|
}
|
|
55
61
|
/** A person's «same as last time», and the one-line note when it was swapped (e.g. for age). */
|
|
56
62
|
export interface ServiceSuggestion {
|
|
@@ -193,12 +193,19 @@ function defaultCategoryOf(categories) {
|
|
|
193
193
|
* What a person in a party can have is what the business takes online and —
|
|
194
194
|
* like the booking engine — nothing whose `maxPerBooking` is below the party's
|
|
195
195
|
* size: the strictest service in a request sets the limit for all of it. With
|
|
196
|
-
* a `childCategory`, a child gets that menu
|
|
197
|
-
*
|
|
196
|
+
* a `childCategory`, a child gets that menu plus the groups meant for anyone
|
|
197
|
+
* (`audience: 'any'`), and an adult everything but the children's — which is
|
|
198
|
+
* often nothing, and they are told so.
|
|
198
199
|
*/
|
|
199
200
|
function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, selection, shared, }) {
|
|
200
201
|
const { Card, labels, classNames } = shared;
|
|
201
202
|
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
203
|
+
const keysFor = (audience) => new Set(categories.filter((group) => group.audience === audience).map((group) => group.key));
|
|
204
|
+
// Children's groups: the configured one plus any group marked 'child'.
|
|
205
|
+
const forChildren = keysFor('child');
|
|
206
|
+
if (childCategory !== undefined)
|
|
207
|
+
forChildren.add(childCategory);
|
|
208
|
+
const forAnyone = keysFor('any');
|
|
202
209
|
const size = party.people.length;
|
|
203
210
|
const [active, setActive] = useState(0);
|
|
204
211
|
// Multi-select: the engine books a grown-up's visit alongside the
|
|
@@ -212,7 +219,17 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
|
|
|
212
219
|
// note below that books the wrong thing.
|
|
213
220
|
childCategory === undefined
|
|
214
221
|
? fits
|
|
215
|
-
: fits.filter((service) =>
|
|
222
|
+
: fits.filter((service) => {
|
|
223
|
+
const key = fileUnder(service);
|
|
224
|
+
if (forChildren.has(key))
|
|
225
|
+
return !person.adult;
|
|
226
|
+
// A child gets an «anyone» group only through an EXPLICIT mapping:
|
|
227
|
+
// the caller's own `categoryOf` when it passes one (its mapping is
|
|
228
|
+
// its decision), else the service's own category — never the
|
|
229
|
+
// built-in display fallback, which files an unlisted (possibly
|
|
230
|
+
// grown-up) category under the last group.
|
|
231
|
+
return person.adult || forAnyone.has(categoryOf ? key : service.category);
|
|
232
|
+
}), (service) => serviceFits(service, index));
|
|
216
233
|
const ticked = selection ? tickedBy(selection, index) : null;
|
|
217
234
|
const chosen = party.choices[index]?.id ?? null;
|
|
218
235
|
const suggestion = person.suggestion ?? null;
|