@medalsocial/meda 3.0.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/dist/booking/add-child-sheet.d.ts +38 -0
- package/dist/booking/add-child-sheet.js +88 -0
- package/dist/booking/booking-skeleton.d.ts +24 -0
- package/dist/booking/booking-skeleton.js +17 -0
- package/dist/booking/confirmation.d.ts +85 -0
- package/dist/booking/confirmation.js +102 -0
- package/dist/booking/details-screen.d.ts +118 -0
- package/dist/booking/details-screen.js +316 -0
- package/dist/booking/format.d.ts +64 -0
- package/dist/booking/format.js +1 -0
- package/dist/booking/index.d.ts +36 -0
- package/dist/booking/index.js +41 -0
- package/dist/booking/internal/label-parts.d.ts +28 -0
- package/dist/booking/internal/label-parts.js +57 -0
- package/dist/booking/internal/ui.d.ts +33 -0
- package/dist/booking/internal/ui.js +35 -0
- package/dist/booking/label-keys.d.ts +34 -0
- package/dist/booking/label-keys.js +56 -0
- package/dist/booking/labels.d.ts +15 -0
- package/dist/booking/labels.js +17 -0
- package/dist/booking/live-status.d.ts +14 -0
- package/dist/booking/live-status.js +17 -0
- package/dist/booking/login-panel.d.ts +108 -0
- package/dist/booking/login-panel.js +239 -0
- package/dist/booking/login-sheet.d.ts +41 -0
- package/dist/booking/login-sheet.js +49 -0
- package/dist/booking/manage-screen.d.ts +113 -0
- package/dist/booking/manage-screen.js +372 -0
- package/dist/booking/otp-slots.d.ts +42 -0
- package/dist/booking/otp-slots.js +109 -0
- package/dist/booking/portal/account-card.d.ts +24 -0
- package/dist/booking/portal/account-card.js +18 -0
- package/dist/booking/portal/action-result.d.ts +30 -0
- package/dist/booking/portal/action-result.js +18 -0
- package/dist/booking/portal/age-confirm-card.d.ts +39 -0
- package/dist/booking/portal/age-confirm-card.js +42 -0
- package/dist/booking/portal/child-cards.d.ts +65 -0
- package/dist/booking/portal/child-cards.js +59 -0
- package/dist/booking/portal/data-controls.d.ts +48 -0
- package/dist/booking/portal/data-controls.js +104 -0
- package/dist/booking/portal/family-editor.d.ts +141 -0
- package/dist/booking/portal/family-editor.js +422 -0
- package/dist/booking/portal/logout-button.d.ts +21 -0
- package/dist/booking/portal/logout-button.js +27 -0
- package/dist/booking/portal/portal-shell.d.ts +71 -0
- package/dist/booking/portal/portal-shell.js +38 -0
- package/dist/booking/portal/portal-unreachable.d.ts +31 -0
- package/dist/booking/portal/portal-unreachable.js +19 -0
- package/dist/booking/portal/profile-form.d.ts +75 -0
- package/dist/booking/portal/profile-form.js +144 -0
- package/dist/booking/portal/rebook-cards.d.ts +44 -0
- package/dist/booking/portal/rebook-cards.js +35 -0
- package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
- package/dist/booking/portal/upcoming-bookings.js +84 -0
- package/dist/booking/portal/vipps-link-row.d.ts +54 -0
- package/dist/booking/portal/vipps-link-row.js +80 -0
- package/dist/booking/portal/visit-history.d.ts +63 -0
- package/dist/booking/portal/visit-history.js +73 -0
- package/dist/booking/service-screen.d.ts +140 -0
- package/dist/booking/service-screen.js +235 -0
- package/dist/booking/slots.d.ts +12 -0
- package/dist/booking/slots.js +5 -0
- package/dist/booking/stylist-screen.d.ts +128 -0
- package/dist/booking/stylist-screen.js +229 -0
- package/dist/booking/summary-bar.d.ts +26 -0
- package/dist/booking/summary-bar.js +32 -0
- package/dist/booking/time-screen.d.ts +191 -0
- package/dist/booking/time-screen.js +419 -0
- package/dist/booking/types.d.ts +284 -0
- package/dist/booking/types.js +15 -0
- package/dist/booking/vipps-button.d.ts +49 -0
- package/dist/booking/vipps-button.js +33 -0
- package/dist/booking/who-screen.d.ts +121 -0
- package/dist/booking/who-screen.js +164 -0
- package/dist/primitives/checkbox.d.ts +23 -0
- package/dist/primitives/checkbox.js +20 -0
- package/dist/primitives/field.d.ts +47 -0
- package/dist/primitives/field.js +48 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/sheet.d.ts +68 -0
- package/dist/primitives/sheet.js +303 -0
- package/dist/primitives/textarea.d.ts +14 -0
- package/dist/primitives/textarea.js +13 -0
- package/dist/styles/booking.css +10 -0
- package/package.json +15 -8
- package/skills/components/SKILL.md +53 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { BookingDaypart, BookingFormat } from './format.js';
|
|
2
|
+
import { type SlotClassNames } from './slots.js';
|
|
3
|
+
import { type TimeScreenComponents, type TimeScreenLabels, type TimeScreenSlot } from './time-screen.js';
|
|
4
|
+
import type { BookingDayDto, BookingManageDto, BookingSlotDto } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* The page at the end of the «manage your booking» link.
|
|
7
|
+
*
|
|
8
|
+
* Presentational: it never sees the manage token and never fetches. Loading
|
|
9
|
+
* openings, cancelling and moving are callbacks; the screen owns only the view
|
|
10
|
+
* it is on, the chosen reason and new slot, and the pending state of the one
|
|
11
|
+
* mutation in flight.
|
|
12
|
+
*
|
|
13
|
+
* The change windows are NOT decided here. The engine answers `canCancel` /
|
|
14
|
+
* `canReschedule` with the same comparison it enforces on the write, so this
|
|
15
|
+
* screen relays those two booleans and never recomputes them. The only thing it
|
|
16
|
+
* derives is the *sentence* about when free changes end.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MANAGE_SCREEN_LABEL_KEYS: readonly ["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"];
|
|
19
|
+
/**
|
|
20
|
+
* Copy for the manage screen, plus the embedded time step's (`TimeScreenLabels`).
|
|
21
|
+
*
|
|
22
|
+
* Placeholders: `card.named` `{name}` `{service}`; `card.when*` `{dateTime}` `{stylist}` `{price}`;
|
|
23
|
+
* `appointment.named*` `{name}` (`namedSibilant` is used for names ending in s, x or z);
|
|
24
|
+
* `ringUsOn` / `callUsOn` `{phone}`; `error.windowPassed|conflict|unavailable` `{ringUs}`;
|
|
25
|
+
* `error.notFound` `{callUs}`; `freeUntil` `{dateTime}`; `remaining.in` `{amount}`;
|
|
26
|
+
* `remaining.daysAndHours` `{days}` `{hours}`; `remaining.*.one|other` `{count}`;
|
|
27
|
+
* `callToChange` / `callToCancel` / `blocked` `{hours}`; `closed.pendingCall` `{phone}`;
|
|
28
|
+
* `party.heading` / `party.justThisOne` `{who}`; `reschedule.lead*` `{service}` `{stylist}`;
|
|
29
|
+
* `move.question*` `{appointment}` `{from}` `{to}` `{stylist}`;
|
|
30
|
+
* `move.surcharge` `{weekday}` `{price}` `{pct}`; `cancel.question` `{appointment}` `{dateTime}` `{hours}`;
|
|
31
|
+
* `movedTo` `{dateTime}`.
|
|
32
|
+
*
|
|
33
|
+
* The three `cancel.reason.*` strings are both the chips' text and the value
|
|
34
|
+
* `onCancel` receives.
|
|
35
|
+
*/
|
|
36
|
+
export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number], string> & TimeScreenLabels;
|
|
37
|
+
/** Why a mutation failed. Map anything the route did not name to `unavailable`. */
|
|
38
|
+
export type ManageErrorCode = 'windowPassed' | 'slotTaken' | 'notFound' | 'conflict' | 'invalidInput' | 'unavailable';
|
|
39
|
+
/**
|
|
40
|
+
* What a cancel or a move answers. After a move, `manageHref` is the link built
|
|
41
|
+
* from the freshly minted manage token — `null`/absent when the engine did not
|
|
42
|
+
* return one (an idempotent replay), in which case no link is drawn. Putting
|
|
43
|
+
* that href in the address bar (`history.replaceState`) is the caller's job.
|
|
44
|
+
*/
|
|
45
|
+
export type ManageResult = {
|
|
46
|
+
ok: true;
|
|
47
|
+
manageHref?: string | null;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
code: ManageErrorCode;
|
|
51
|
+
};
|
|
52
|
+
/** The openings around the appointment, for the reschedule step. */
|
|
53
|
+
export interface ManageReschedule {
|
|
54
|
+
/** `null` until they have been loaded. */
|
|
55
|
+
slots: BookingSlotDto[] | null;
|
|
56
|
+
/** The last load failed. Takes precedence over `slots`. */
|
|
57
|
+
failed?: boolean;
|
|
58
|
+
/** Forwarded to the time step. */
|
|
59
|
+
days?: number[];
|
|
60
|
+
openDays?: readonly BookingDayDto[] | null;
|
|
61
|
+
}
|
|
62
|
+
/** Today's prices for the booked service, for the weekend note on a new slot. */
|
|
63
|
+
export interface ManagePricing {
|
|
64
|
+
/** The service's price for a visit starting at `ts`, in minor units. */
|
|
65
|
+
priceAt: (ts: number) => number;
|
|
66
|
+
weekendSurchargePct: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `classNames` slots: `root` the section · `heading` the h1 · `alert` the error ·
|
|
70
|
+
* `card` the booking card · `actions` the overview's button row · `panel` each
|
|
71
|
+
* confirm / party / result panel · `footer` the portal-link line.
|
|
72
|
+
*/
|
|
73
|
+
export type ManageScreenSlot = 'root' | 'heading' | 'alert' | 'card' | 'actions' | 'panel' | 'footer';
|
|
74
|
+
export interface ManageScreenProps {
|
|
75
|
+
labels: ManageScreenLabels;
|
|
76
|
+
format: BookingFormat;
|
|
77
|
+
dayparts: readonly BookingDaypart[];
|
|
78
|
+
booking: BookingManageDto;
|
|
79
|
+
/** The business's number; `null` renders every phone sentence unlinked. */
|
|
80
|
+
phone?: string | null;
|
|
81
|
+
address?: string | null;
|
|
82
|
+
/** Where «book a new time» goes. */
|
|
83
|
+
bookingHref: string;
|
|
84
|
+
/** The portal («all your appointments») link at the foot of every view; `null` hides it. */
|
|
85
|
+
portalHref?: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* The «add to calendar» href (e.g. an ICS `data:` URL, built by the caller so
|
|
88
|
+
* it can carry this page's manage link); `null` hides the link.
|
|
89
|
+
*/
|
|
90
|
+
icsHref?: string | null;
|
|
91
|
+
/** The `download` name for `icsHref` (browsers refuse a top-level `data:` navigation without it). */
|
|
92
|
+
icsFileName?: string;
|
|
93
|
+
/** Absent — the catalogue did not load — means no weekend note rather than a guessed one. */
|
|
94
|
+
pricing?: ManagePricing | null;
|
|
95
|
+
reschedule: ManageReschedule;
|
|
96
|
+
/**
|
|
97
|
+
* Load (or reload) the openings. Called when the visitor opens the time step
|
|
98
|
+
* with nothing loaded or after a failure, and after a move lost its slot.
|
|
99
|
+
*/
|
|
100
|
+
onRequestSlots: () => void;
|
|
101
|
+
/** Cancel, with the chosen reason chip's text or `null`. Held pending until it settles. */
|
|
102
|
+
onCancel: (reason: string | null) => Promise<ManageResult>;
|
|
103
|
+
/** Move to `slot`. Held pending until it settles. */
|
|
104
|
+
onReschedule: (slot: BookingSlotDto) => Promise<ManageResult>;
|
|
105
|
+
/** «Now», for the time left to change for free. Defaults to `Date.now()` (read after mount). */
|
|
106
|
+
now?: number;
|
|
107
|
+
classNames?: SlotClassNames<ManageScreenSlot>;
|
|
108
|
+
/** Slot overrides for the embedded time step. */
|
|
109
|
+
timeClassNames?: SlotClassNames<TimeScreenSlot>;
|
|
110
|
+
/** Chip overrides for the embedded time step. */
|
|
111
|
+
components?: TimeScreenComponents;
|
|
112
|
+
}
|
|
113
|
+
export declare function ManageScreen({ labels, format, dayparts, booking, phone, address, bookingHref, portalHref, icsHref, icsFileName, pricing, reschedule, onRequestSlots, onCancel, onReschedule, now: nowProp, classNames, timeClassNames, components, }: ManageScreenProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { BookingButton } from './internal/ui.js';
|
|
7
|
+
import { fillLabel } from './labels.js';
|
|
8
|
+
import { slotClass } from './slots.js';
|
|
9
|
+
import { TimeScreen, } from './time-screen.js';
|
|
10
|
+
/**
|
|
11
|
+
* The page at the end of the «manage your booking» link.
|
|
12
|
+
*
|
|
13
|
+
* Presentational: it never sees the manage token and never fetches. Loading
|
|
14
|
+
* openings, cancelling and moving are callbacks; the screen owns only the view
|
|
15
|
+
* it is on, the chosen reason and new slot, and the pending state of the one
|
|
16
|
+
* mutation in flight.
|
|
17
|
+
*
|
|
18
|
+
* The change windows are NOT decided here. The engine answers `canCancel` /
|
|
19
|
+
* `canReschedule` with the same comparison it enforces on the write, so this
|
|
20
|
+
* screen relays those two booleans and never recomputes them. The only thing it
|
|
21
|
+
* derives is the *sentence* about when free changes end.
|
|
22
|
+
*/
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Labels
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export const MANAGE_SCREEN_LABEL_KEYS = [
|
|
27
|
+
'manage.heading',
|
|
28
|
+
'manage.serviceFallback',
|
|
29
|
+
'manage.card.named',
|
|
30
|
+
'manage.card.when',
|
|
31
|
+
'manage.card.whenWithStylist',
|
|
32
|
+
'manage.appointment.named',
|
|
33
|
+
'manage.appointment.namedSibilant',
|
|
34
|
+
'manage.appointment.unnamed',
|
|
35
|
+
'manage.ringUs',
|
|
36
|
+
'manage.ringUsOn',
|
|
37
|
+
'manage.callUs',
|
|
38
|
+
'manage.callUsOn',
|
|
39
|
+
'manage.error.windowPassed',
|
|
40
|
+
'manage.error.slotTaken',
|
|
41
|
+
'manage.error.notFound',
|
|
42
|
+
'manage.error.conflict',
|
|
43
|
+
'manage.error.invalidInput',
|
|
44
|
+
'manage.error.unavailable',
|
|
45
|
+
'manage.freeUntil',
|
|
46
|
+
'manage.remaining.in',
|
|
47
|
+
'manage.remaining.daysAndHours',
|
|
48
|
+
'manage.remaining.underMinute',
|
|
49
|
+
'manage.remaining.day.one',
|
|
50
|
+
'manage.remaining.day.other',
|
|
51
|
+
'manage.remaining.hour.one',
|
|
52
|
+
'manage.remaining.hour.other',
|
|
53
|
+
'manage.remaining.minute.one',
|
|
54
|
+
'manage.remaining.minute.other',
|
|
55
|
+
'manage.change',
|
|
56
|
+
'manage.cancel',
|
|
57
|
+
'manage.callToChange',
|
|
58
|
+
'manage.callToCancel',
|
|
59
|
+
'manage.blocked',
|
|
60
|
+
'manage.blocked.suffix',
|
|
61
|
+
'manage.blocked.plain',
|
|
62
|
+
'manage.calendar',
|
|
63
|
+
'manage.closed.cancelled',
|
|
64
|
+
'manage.closed.completed',
|
|
65
|
+
'manage.closed.noShow',
|
|
66
|
+
'manage.closed.pending',
|
|
67
|
+
'manage.closed.pendingCall',
|
|
68
|
+
'manage.closed.pendingCallPlain',
|
|
69
|
+
'manage.bookNew',
|
|
70
|
+
'manage.party.heading',
|
|
71
|
+
'manage.party.justThisOne',
|
|
72
|
+
'manage.party.unnamed',
|
|
73
|
+
'manage.party.note',
|
|
74
|
+
'manage.party.noteSuffix',
|
|
75
|
+
'manage.back',
|
|
76
|
+
'manage.reschedule.lead',
|
|
77
|
+
'manage.reschedule.leadWithStylist',
|
|
78
|
+
'manage.reschedule.failed',
|
|
79
|
+
'manage.reschedule.failedSuffix',
|
|
80
|
+
'manage.reschedule.loading',
|
|
81
|
+
'manage.move.question',
|
|
82
|
+
'manage.move.questionWithStylist',
|
|
83
|
+
'manage.move.surcharge',
|
|
84
|
+
'manage.move.confirm',
|
|
85
|
+
'manage.move.pickAnother',
|
|
86
|
+
'manage.cancel.question',
|
|
87
|
+
'manage.cancel.reasonLegend',
|
|
88
|
+
'manage.cancel.reason.illness',
|
|
89
|
+
'manage.cancel.reason.noLongerSuits',
|
|
90
|
+
'manage.cancel.reason.other',
|
|
91
|
+
'manage.cancel.confirm',
|
|
92
|
+
'manage.cancel.keep',
|
|
93
|
+
'manage.cancelled',
|
|
94
|
+
'manage.cancelled.findNew',
|
|
95
|
+
'manage.moved',
|
|
96
|
+
'manage.movedTo',
|
|
97
|
+
'manage.moved.view',
|
|
98
|
+
'manage.moved.noLink',
|
|
99
|
+
'manage.moved.noLinkSuffix',
|
|
100
|
+
'manage.portal',
|
|
101
|
+
];
|
|
102
|
+
const HOUR_MS = 3_600_000;
|
|
103
|
+
const DAY_MS = 86_400_000;
|
|
104
|
+
/** The names the engine substitutes for a deleted catalogue row. Never printed. */
|
|
105
|
+
const UNKNOWN_SERVICE = 'Unknown service';
|
|
106
|
+
const UNKNOWN_RESOURCE = 'Unknown resource';
|
|
107
|
+
const CANCEL_REASON_KEYS = [
|
|
108
|
+
'manage.cancel.reason.illness',
|
|
109
|
+
'manage.cancel.reason.noLongerSuits',
|
|
110
|
+
'manage.cancel.reason.other',
|
|
111
|
+
];
|
|
112
|
+
const CLOSED_SENTENCE = {
|
|
113
|
+
cancelled: 'manage.closed.cancelled',
|
|
114
|
+
completed: 'manage.closed.completed',
|
|
115
|
+
no_show: 'manage.closed.noShow',
|
|
116
|
+
};
|
|
117
|
+
const LINK_CLASS = 'font-semibold text-primary underline underline-offset-4';
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Sentences
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
/** «Name's appointment» when a name was given, the bare noun when not. */
|
|
122
|
+
function theirAppointment(labels, name) {
|
|
123
|
+
if (!name)
|
|
124
|
+
return labels['manage.appointment.unnamed'];
|
|
125
|
+
return fillLabel(labels[/[sxz]$/i.test(name) ? 'manage.appointment.namedSibilant' : 'manage.appointment.named'], { name });
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* «in 1 day and 3 hours» — whole days and hours, because it is read as
|
|
129
|
+
* reassurance rather than a countdown; minutes only under an hour.
|
|
130
|
+
*/
|
|
131
|
+
function remainingLabel(labels, ms) {
|
|
132
|
+
const days = Math.floor(ms / DAY_MS);
|
|
133
|
+
const hours = Math.floor((ms % DAY_MS) / HOUR_MS);
|
|
134
|
+
const dayPart = fillLabel(labels[days === 1 ? 'manage.remaining.day.one' : 'manage.remaining.day.other'], { count: days });
|
|
135
|
+
const hourPart = fillLabel(labels[hours === 1 ? 'manage.remaining.hour.one' : 'manage.remaining.hour.other'], { count: hours });
|
|
136
|
+
if (days > 0) {
|
|
137
|
+
return hours > 0
|
|
138
|
+
? fillLabel(labels['manage.remaining.daysAndHours'], { days: dayPart, hours: hourPart })
|
|
139
|
+
: fillLabel(labels['manage.remaining.in'], { amount: dayPart });
|
|
140
|
+
}
|
|
141
|
+
if (hours > 0)
|
|
142
|
+
return fillLabel(labels['manage.remaining.in'], { amount: hourPart });
|
|
143
|
+
const minutes = Math.floor((ms % HOUR_MS) / 60_000);
|
|
144
|
+
if (minutes < 1)
|
|
145
|
+
return labels['manage.remaining.underMinute'];
|
|
146
|
+
return fillLabel(labels['manage.remaining.in'], {
|
|
147
|
+
amount: fillLabel(labels[minutes === 1 ? 'manage.remaining.minute.one' : 'manage.remaining.minute.other'], { count: minutes }),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function ringUs(labels, phone) {
|
|
151
|
+
return phone ? fillLabel(labels['manage.ringUsOn'], { phone }) : labels['manage.ringUs'];
|
|
152
|
+
}
|
|
153
|
+
function callUs(labels, phone) {
|
|
154
|
+
return phone ? fillLabel(labels['manage.callUsOn'], { phone }) : labels['manage.callUs'];
|
|
155
|
+
}
|
|
156
|
+
/** What the visitor is told went wrong — never the upstream's own words. */
|
|
157
|
+
function errorSentence(labels, code, phone) {
|
|
158
|
+
switch (code) {
|
|
159
|
+
case 'windowPassed':
|
|
160
|
+
return fillLabel(labels['manage.error.windowPassed'], { ringUs: ringUs(labels, phone) });
|
|
161
|
+
case 'slotTaken':
|
|
162
|
+
return labels['manage.error.slotTaken'];
|
|
163
|
+
case 'notFound':
|
|
164
|
+
return fillLabel(labels['manage.error.notFound'], { callUs: callUs(labels, phone) });
|
|
165
|
+
case 'conflict':
|
|
166
|
+
return fillLabel(labels['manage.error.conflict'], { ringUs: ringUs(labels, phone) });
|
|
167
|
+
case 'invalidInput':
|
|
168
|
+
return labels['manage.error.invalidInput'];
|
|
169
|
+
default:
|
|
170
|
+
return fillLabel(labels['manage.error.unavailable'], { ringUs: ringUs(labels, phone) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** A sentence that hands the visitor to the telephone: linked with a number, plain without. */
|
|
174
|
+
function PhoneSentence({ format, phone, children, className, }) {
|
|
175
|
+
if (phone === null)
|
|
176
|
+
return _jsx("span", { className: className, children: children });
|
|
177
|
+
return (_jsx("a", { href: format.telHref(phone), className: cn(LINK_CLASS, className), children: children }));
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// ManageScreen
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
export function ManageScreen({ labels, format, dayparts, booking, phone = null, address = null, bookingHref, portalHref = null, icsHref = null, icsFileName, pricing = null, reschedule, onRequestSlots, onCancel, onReschedule, now: nowProp, classNames, timeClassNames, components, }) {
|
|
183
|
+
const [view, setView] = useState('overview');
|
|
184
|
+
const [pending, setPending] = useState(null);
|
|
185
|
+
const [reason, setReason] = useState(null);
|
|
186
|
+
const [busy, setBusy] = useState(false);
|
|
187
|
+
const [error, setError] = useState(null);
|
|
188
|
+
const [movedTo, setMovedTo] = useState(null);
|
|
189
|
+
const [newManageHref, setNewManageHref] = useState(null);
|
|
190
|
+
/**
|
|
191
|
+
* The openings read BEFORE a move lost its slot. Until the caller hands in a
|
|
192
|
+
* fresh array they are not shown: the rejected instant is still on them,
|
|
193
|
+
* still tappable, and still the nearest thing to what the visitor wanted.
|
|
194
|
+
*/
|
|
195
|
+
const [staleSlots, setStaleSlots] = useState(null);
|
|
196
|
+
const slots = reschedule.slots !== null && reschedule.slots === staleSlots ? null : reschedule.slots;
|
|
197
|
+
const slotsFailed = reschedule.failed ?? false;
|
|
198
|
+
const confirmed = booking.status === 'confirmed';
|
|
199
|
+
const stylist = booking.resourceName === UNKNOWN_RESOURCE
|
|
200
|
+
? null
|
|
201
|
+
: format.stylistName(booking.resourceName) || null;
|
|
202
|
+
const serviceLabel = booking.serviceName === UNKNOWN_SERVICE
|
|
203
|
+
? labels['manage.serviceFallback']
|
|
204
|
+
: booking.serviceName;
|
|
205
|
+
// The LARGER window wins: the sentence promises both actions, and the earlier
|
|
206
|
+
// of the two deadlines is the one true of both.
|
|
207
|
+
const windowHours = Math.max(booking.cancelWindowHours, booking.rescheduleWindowHours);
|
|
208
|
+
const freeUntilTs = booking.startTs - windowHours * HOUR_MS;
|
|
209
|
+
// Filled in after mount: the one line whose value depends on the millisecond
|
|
210
|
+
// it was produced, so computing it during a server render would mismatch.
|
|
211
|
+
const [remaining, setRemaining] = useState(null);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const left = freeUntilTs - (nowProp ?? Date.now());
|
|
214
|
+
setRemaining(left > 0 ? remainingLabel(labels, left) : null);
|
|
215
|
+
}, [freeUntilTs, nowProp, labels]);
|
|
216
|
+
/**
|
|
217
|
+
* One mutation, with its buttons held down for the whole round trip. `busy`
|
|
218
|
+
* is enough because each mutation is reachable from exactly one button and
|
|
219
|
+
* both carry `disabled={busy}` — the second tap of a double tap lands on a
|
|
220
|
+
* disabled button. A cancel has no idempotency key, so this matters.
|
|
221
|
+
*/
|
|
222
|
+
async function run(action) {
|
|
223
|
+
setBusy(true);
|
|
224
|
+
setError(null);
|
|
225
|
+
let result;
|
|
226
|
+
try {
|
|
227
|
+
result = await action();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
result = { ok: false, code: 'unavailable' };
|
|
231
|
+
}
|
|
232
|
+
setBusy(false);
|
|
233
|
+
if (!result.ok)
|
|
234
|
+
setError(result.code);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
const confirmCancel = async () => {
|
|
238
|
+
const result = await run(() => onCancel(reason));
|
|
239
|
+
if (result.ok)
|
|
240
|
+
setView('cancelled');
|
|
241
|
+
};
|
|
242
|
+
const confirmMove = async () => {
|
|
243
|
+
if (pending === null)
|
|
244
|
+
return;
|
|
245
|
+
const target = pending;
|
|
246
|
+
const result = await run(() => onReschedule(target));
|
|
247
|
+
if (!result.ok) {
|
|
248
|
+
// A slot that went while the visitor read the confirmation is not an
|
|
249
|
+
// error to sit on — the time step is where the answer is, reloaded.
|
|
250
|
+
if (result.code === 'slotTaken' || result.code === 'invalidInput') {
|
|
251
|
+
setPending(null);
|
|
252
|
+
setStaleSlots(reschedule.slots);
|
|
253
|
+
setView('time');
|
|
254
|
+
onRequestSlots();
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
setMovedTo(target.startTs);
|
|
259
|
+
// The token the visitor arrived with belongs to the row the move
|
|
260
|
+
// cancelled; only a link built from the new one still works.
|
|
261
|
+
setNewManageHref(result.manageHref ?? null);
|
|
262
|
+
setView('moved');
|
|
263
|
+
};
|
|
264
|
+
const goToTime = () => {
|
|
265
|
+
setError(null);
|
|
266
|
+
setView('time');
|
|
267
|
+
// A failure as well as «never loaded», or a transient failure after a
|
|
268
|
+
// successful load would stick until the whole page was reloaded.
|
|
269
|
+
if (reschedule.slots === null || slotsFailed)
|
|
270
|
+
onRequestSlots();
|
|
271
|
+
};
|
|
272
|
+
const panelClass = (base) => slotClass(classNames, 'panel', base);
|
|
273
|
+
return (_jsxs("section", { "aria-labelledby": "booking-manage-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: "booking-manage-heading", className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['manage.heading'] }), error !== null && (_jsx("p", { role: "alert", className: slotClass(classNames, 'alert', 'rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-3 text-sm'), children: errorSentence(labels, error, phone) })), view === 'cancelled' && (_jsxs("div", { className: panelClass('space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-medium", children: labels['manage.cancelled'] }), _jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.cancelled.findNew'] })] })), view === 'moved' && (_jsxs("div", { className: panelClass('space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-medium", children: movedTo === null
|
|
274
|
+
? labels['manage.moved']
|
|
275
|
+
: renderLabel(labels['manage.movedTo'], {
|
|
276
|
+
dateTime: format.clock.dateTime(movedTo),
|
|
277
|
+
}) }), newManageHref ? (_jsx("a", { href: newManageHref, className: LINK_CLASS, children: labels['manage.moved.view'] })) : (_jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.moved.noLink'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: callUs(labels, phone) }), ' ', labels['manage.moved.noLinkSuffix']] }))] })), view !== 'cancelled' && view !== 'moved' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: slotClass(classNames, 'card', 'space-y-1 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: booking.bookedForName
|
|
278
|
+
? renderLabel(labels['manage.card.named'], {
|
|
279
|
+
name: booking.bookedForName,
|
|
280
|
+
service: serviceLabel,
|
|
281
|
+
})
|
|
282
|
+
: serviceLabel }), _jsx("p", { className: "tabular-nums", children: renderLabel(labels[stylist ? 'manage.card.whenWithStylist' : 'manage.card.when'], {
|
|
283
|
+
dateTime: format.clock.dateTime(booking.startTs),
|
|
284
|
+
stylist: stylist ?? '',
|
|
285
|
+
price: format.price(booking.amountOre),
|
|
286
|
+
}) }), address && _jsx("p", { className: "text-sm text-muted-foreground", children: address })] }), view === 'overview' &&
|
|
287
|
+
(confirmed ? (_jsx(Overview, { labels: labels, format: format, booking: booking, freeUntilTs: freeUntilTs, remaining: remaining, phone: phone, icsHref: icsHref, icsFileName: icsFileName, actionsClassName: classNames?.actions, onChange: () => (booking.partySequenceId ? setView('party') : goToTime()), onCancel: () => {
|
|
288
|
+
setError(null);
|
|
289
|
+
setView('cancel');
|
|
290
|
+
} })) : (_jsx(ClosedBooking, { labels: labels, format: format, status: booking.status, bookingHref: bookingHref, phone: phone, className: panelClass('space-y-3 rounded-lg border border-border bg-card px-5 py-4') }))), view === 'party' && (_jsx(PartyChoice, { labels: labels, format: format, bookedForName: booking.bookedForName, phone: phone, onJustThisOne: goToTime, onBack: () => setView('overview'), className: panelClass('space-y-4 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4') })), view === 'time' && (_jsxs("div", { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: stylist
|
|
291
|
+
? renderLabel(labels['manage.reschedule.leadWithStylist'], {
|
|
292
|
+
service: serviceLabel,
|
|
293
|
+
stylist,
|
|
294
|
+
})
|
|
295
|
+
: renderLabel(labels['manage.reschedule.lead'], { service: serviceLabel }) }), slotsFailed && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [labels['manage.reschedule.failed'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.reschedule.failedSuffix']] })), !slotsFailed && slots === null && (_jsx("p", { className: "text-sm text-muted-foreground", children: labels['manage.reschedule.loading'] })), !slotsFailed && slots !== null && (_jsx(TimeScreen, { labels: labels, format: format, dayparts: dayparts, slots: slots, days: reschedule.days, openDays: reschedule.openDays ?? null, currentSlotTs: booking.startTs, weekendNote: weekendNoteFor(pricing), phone: phone, now: nowProp, classNames: timeClassNames, components: components, onPick: (slot) => {
|
|
296
|
+
setPending(slot);
|
|
297
|
+
setError(null);
|
|
298
|
+
setView('move');
|
|
299
|
+
} })), _jsx(BookingButton, { variant: "outline", onClick: () => setView('overview'), children: labels['manage.back'] })] })), view === 'move' && pending !== null && (_jsx(ConfirmMove, { labels: labels, format: format, booking: booking, pricing: pricing, stylist: stylist, pending: pending, busy: busy, onConfirm: confirmMove, onBack: () => setView('time'), className: panelClass('space-y-4 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4') })), view === 'cancel' && (_jsx(ConfirmCancel, { labels: labels, format: format, booking: booking, reason: reason, busy: busy, onPickReason: setReason, onConfirm: confirmCancel, onBack: () => setView('overview'), className: panelClass('space-y-4 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4') }))] })), portalHref !== null && (_jsx("p", { className: slotClass(classNames, 'footer', 'text-sm'), children: _jsx("a", { href: portalHref, className: LINK_CLASS, children: labels['manage.portal'] }) }))] }));
|
|
300
|
+
}
|
|
301
|
+
function weekendNoteFor(pricing) {
|
|
302
|
+
if (pricing === null || pricing.weekendSurchargePct <= 0)
|
|
303
|
+
return undefined;
|
|
304
|
+
return (dayTs) => ({ pct: pricing.weekendSurchargePct, priceOre: pricing.priceAt(dayTs) });
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The three actions, and what replaces the ones the engine has already closed.
|
|
308
|
+
* `canReschedule` / `canCancel` are read one for one — never `startTs` and a
|
|
309
|
+
* policy number — so this agrees with the write by construction.
|
|
310
|
+
*/
|
|
311
|
+
function Overview({ labels, format, booking, freeUntilTs, remaining, phone, icsHref, icsFileName, actionsClassName, onChange, onCancel, }) {
|
|
312
|
+
// The largest window that has already closed — the number the sentence below
|
|
313
|
+
// quotes. Zero when both are open, and then it is not rendered.
|
|
314
|
+
const blockedHours = Math.max(booking.canCancel ? 0 : booking.cancelWindowHours, booking.canReschedule ? 0 : booking.rescheduleWindowHours);
|
|
315
|
+
return (_jsxs(_Fragment, { children: [booking.canCancel && booking.canReschedule && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [renderLabel(labels['manage.freeUntil'], {
|
|
316
|
+
dateTime: format.clock.dateTime(freeUntilTs),
|
|
317
|
+
}), remaining !== null && _jsxs("span", { children: [" (", remaining, ")"] })] })), _jsxs("div", { className: cn('flex flex-wrap items-center gap-4', actionsClassName), children: [booking.canReschedule ? (_jsx(BookingButton, { size: "lg", onClick: onChange, children: labels['manage.change'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.callToChange'], { hours: booking.rescheduleWindowHours }) })), booking.canCancel ? (_jsx(BookingButton, { size: "lg", variant: "outline", onClick: onCancel, children: labels['manage.cancel'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.callToCancel'], { hours: booking.cancelWindowHours }) })), icsHref !== null && (_jsx("a", { href: icsHref, download: icsFileName ?? true, className: LINK_CLASS, children: labels['manage.calendar'] }))] }), blockedHours > 0 && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [renderLabel(labels['manage.blocked'], { hours: blockedHours }), ' ', phone ? (_jsxs(_Fragment, { children: [_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.ringUsOn'], { phone }) }), labels['manage.blocked.suffix']] })) : (labels['manage.blocked.plain'])] }))] }));
|
|
318
|
+
}
|
|
319
|
+
/** A booking no longer open to either action, saying which kind it is. */
|
|
320
|
+
function ClosedBooking({ labels, format, status, bookingHref, phone, className, }) {
|
|
321
|
+
const sentence = labels[CLOSED_SENTENCE[status] ?? 'manage.closed.pending'];
|
|
322
|
+
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "text-sm", children: sentence }), status === 'pending' ? (_jsx("p", { className: "text-sm", children: _jsx(PhoneSentence, { format: format, phone: phone, children: phone
|
|
323
|
+
? renderLabel(labels['manage.closed.pendingCall'], { phone })
|
|
324
|
+
: labels['manage.closed.pendingCallPlain'] }) })) : (_jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.bookNew'] }))] }));
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* «Move the whole visit, or just this one?» A manage link authorises exactly
|
|
328
|
+
* ONE booking, so the whole visit is a telephone call and says so plainly.
|
|
329
|
+
* «Just this one» keeps the family visit a family visit; it simply stops being
|
|
330
|
+
* contiguous, which the sub-line says.
|
|
331
|
+
*/
|
|
332
|
+
function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
|
|
333
|
+
const who = bookedForName ?? labels['manage.party.unnamed'];
|
|
334
|
+
return (_jsxs("div", { className: className, children: [_jsx("h2", { className: "font-sans text-lg font-bold", children: renderLabel(labels['manage.party.heading'], { who }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", onClick: onJustThisOne, children: renderLabel(labels['manage.party.justThisOne'], { who }) }), _jsx(BookingButton, { size: "lg", variant: "outline", onClick: onBack, children: labels['manage.back'] })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.party.note'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.party.noteSuffix']] })] }));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The single confirm for a move. The weekend note appears only when the new
|
|
338
|
+
* hour costs MORE — both sides priced off today's catalogue, never against the
|
|
339
|
+
* amount charged weeks ago.
|
|
340
|
+
*/
|
|
341
|
+
function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy, onConfirm, onBack, className, }) {
|
|
342
|
+
const { clock } = format;
|
|
343
|
+
const nextOre = pricing ? pricing.priceAt(pending.startTs) : 0;
|
|
344
|
+
const nowOre = pricing ? pricing.priceAt(booking.startTs) : 0;
|
|
345
|
+
const dearer = pricing !== null && nextOre > nowOre;
|
|
346
|
+
const question = renderLabel(labels[stylist ? 'manage.move.questionWithStylist' : 'manage.move.question'], {
|
|
347
|
+
appointment: theirAppointment(labels, booking.bookedForName),
|
|
348
|
+
from: `${clock.date(booking.startTs)} ${clock.formatTime(booking.startTs)}`,
|
|
349
|
+
to: `${clock.date(pending.startTs)} ${clock.formatTime(pending.startTs)}`,
|
|
350
|
+
stylist: stylist ?? '',
|
|
351
|
+
});
|
|
352
|
+
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: question }), dearer && pricing && (_jsx("p", { className: "text-sm", children: renderLabel(labels['manage.move.surcharge'], {
|
|
353
|
+
weekday: clock.weekday(pending.startTs),
|
|
354
|
+
price: format.price(nextOre),
|
|
355
|
+
pct: pricing.weekendSurchargePct,
|
|
356
|
+
}) })), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", disabled: busy, onClick: onConfirm, children: labels['manage.move.confirm'] }), _jsx(BookingButton, { variant: "outline", disabled: busy, onClick: onBack, children: labels['manage.move.pickAnother'] })] })] }));
|
|
357
|
+
}
|
|
358
|
+
/** The single confirm for a cancellation, with the reason asked and never required. */
|
|
359
|
+
function ConfirmCancel({ labels, format, booking, reason, busy, onPickReason, onConfirm, onBack, className, }) {
|
|
360
|
+
return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: renderLabel(labels['manage.cancel.question'], {
|
|
361
|
+
appointment: theirAppointment(labels, booking.bookedForName),
|
|
362
|
+
dateTime: format.clock.dateTime(booking.startTs),
|
|
363
|
+
hours: booking.cancelWindowHours,
|
|
364
|
+
}) }), _jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['manage.cancel.reasonLegend'] }), CANCEL_REASON_KEYS.map((key) => {
|
|
365
|
+
const option = labels[key];
|
|
366
|
+
const picked = reason === option;
|
|
367
|
+
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": picked,
|
|
368
|
+
// Tapping the chosen chip again clears it: the answer is
|
|
369
|
+
// optional, so there has to be a way back to having given none.
|
|
370
|
+
onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children: option }, key));
|
|
371
|
+
})] }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", disabled: busy, onClick: onConfirm, children: labels['manage.cancel.confirm'] }), _jsx(BookingButton, { size: "lg", variant: "outline", disabled: busy, onClick: onBack, children: labels['manage.cancel.keep'] })] })] }));
|
|
372
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type SlotClassNames } from './slots.js';
|
|
2
|
+
export declare const OTP_SLOTS_LABEL_KEYS: readonly ["otp.label"];
|
|
3
|
+
export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number], string>;
|
|
4
|
+
/**
|
|
5
|
+
* - `root`: the positioned wrapper.
|
|
6
|
+
* - `row`: the six-box grid.
|
|
7
|
+
* - `slot`: every box.
|
|
8
|
+
* - `slotActive`: the box the caret is on.
|
|
9
|
+
* - `slotFilled`: a box holding a digit (not active).
|
|
10
|
+
*/
|
|
11
|
+
export type OtpSlotsSlot = 'root' | 'row' | 'slot' | 'slotActive' | 'slotFilled';
|
|
12
|
+
export interface OtpSlotsProps {
|
|
13
|
+
/** The hidden input's id — point a `<label htmlFor>` at it. */
|
|
14
|
+
id?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
/** The code so far; anything but digits is dropped, and it is capped at six. */
|
|
17
|
+
value: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Holds the value still without letting go of focus — for while a code is
|
|
21
|
+
* being checked. `disabled` would blur the field, and inside a modal dialog
|
|
22
|
+
* a blurred field is focus the trap has to put somewhere.
|
|
23
|
+
*/
|
|
24
|
+
readOnly?: boolean;
|
|
25
|
+
invalid?: boolean;
|
|
26
|
+
describedBy?: string;
|
|
27
|
+
labels: OtpSlotsLabels;
|
|
28
|
+
classNames?: SlotClassNames<OtpSlotsSlot>;
|
|
29
|
+
onChange: (next: string) => void;
|
|
30
|
+
/** Called with the six digits the moment the sixth lands. */
|
|
31
|
+
onComplete?: (code: string) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Six digit boxes for a one-time code, with one hidden `one-time-code` input
|
|
35
|
+
* so iOS / Android / a password manager can still paste or autofill the whole
|
|
36
|
+
* code in one go.
|
|
37
|
+
*
|
|
38
|
+
* The visible boxes are not six independent form fields. They are a display
|
|
39
|
+
* over a single value: arrow keys, Backspace, and a paste of «492 155» all
|
|
40
|
+
* edit that value. Submitting still posts six digits with no spaces.
|
|
41
|
+
*/
|
|
42
|
+
export declare function OtpSlots({ id, name, value, disabled, readOnly, invalid, describedBy, labels, classNames, onChange, onComplete, }: OtpSlotsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
import { slotClass } from './slots.js';
|
|
6
|
+
const LENGTH = 6;
|
|
7
|
+
function digitsOnly(value) {
|
|
8
|
+
return value.replace(/\D/g, '').slice(0, LENGTH);
|
|
9
|
+
}
|
|
10
|
+
/** A box's border and text colour: invalid, then active, then filled, then empty. */
|
|
11
|
+
function slotTone(invalid, active, filled) {
|
|
12
|
+
if (invalid)
|
|
13
|
+
return 'border-destructive text-foreground';
|
|
14
|
+
if (active)
|
|
15
|
+
return 'border-primary text-foreground';
|
|
16
|
+
if (filled)
|
|
17
|
+
return 'border-primary/40 text-foreground';
|
|
18
|
+
return 'border-input text-muted-foreground';
|
|
19
|
+
}
|
|
20
|
+
export const OTP_SLOTS_LABEL_KEYS = ['otp.label'];
|
|
21
|
+
/**
|
|
22
|
+
* Six digit boxes for a one-time code, with one hidden `one-time-code` input
|
|
23
|
+
* so iOS / Android / a password manager can still paste or autofill the whole
|
|
24
|
+
* code in one go.
|
|
25
|
+
*
|
|
26
|
+
* The visible boxes are not six independent form fields. They are a display
|
|
27
|
+
* over a single value: arrow keys, Backspace, and a paste of «492 155» all
|
|
28
|
+
* edit that value. Submitting still posts six digits with no spaces.
|
|
29
|
+
*/
|
|
30
|
+
export function OtpSlots({ id, name = 'code', value, disabled = false, readOnly = false, invalid = false, describedBy, labels, classNames, onChange, onComplete, }) {
|
|
31
|
+
const generatedId = useId();
|
|
32
|
+
const fieldId = id ?? generatedId;
|
|
33
|
+
const hiddenRef = useRef(null);
|
|
34
|
+
const digits = digitsOnly(value);
|
|
35
|
+
// Where the hidden input's caret is, so the highlighted box follows arrow
|
|
36
|
+
// keys and pointer placement instead of always sitting after the last digit.
|
|
37
|
+
// `null` until the caret has moved: then the box after the last digit is
|
|
38
|
+
// active, exactly as with plain sequential typing.
|
|
39
|
+
const [caret, setCaret] = useState(null);
|
|
40
|
+
const active = Math.min(caret ?? digits.length, digits.length, LENGTH - 1);
|
|
41
|
+
// Backspace is handled by hand (preventDefault), so the browser never moves
|
|
42
|
+
// the real caret. Park the target position here and apply it once React has
|
|
43
|
+
// rendered the new value; otherwise the keyup that follows would read the
|
|
44
|
+
// stale DOM selection and drag the highlight back.
|
|
45
|
+
const pendingCaret = useRef(null);
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
const position = pendingCaret.current;
|
|
48
|
+
const input = hiddenRef.current;
|
|
49
|
+
if (position === null || !input)
|
|
50
|
+
return;
|
|
51
|
+
pendingCaret.current = null;
|
|
52
|
+
const clamped = Math.min(position, input.value.length);
|
|
53
|
+
input.setSelectionRange(clamped, clamped);
|
|
54
|
+
});
|
|
55
|
+
function commit(next) {
|
|
56
|
+
if (readOnly)
|
|
57
|
+
return;
|
|
58
|
+
const cleaned = digitsOnly(next);
|
|
59
|
+
onChange(cleaned);
|
|
60
|
+
if (cleaned.length === LENGTH)
|
|
61
|
+
onComplete?.(cleaned);
|
|
62
|
+
}
|
|
63
|
+
function syncCaret(input) {
|
|
64
|
+
setCaret(input.selectionStart ?? null);
|
|
65
|
+
}
|
|
66
|
+
function focusHidden() {
|
|
67
|
+
hiddenRef.current?.focus();
|
|
68
|
+
}
|
|
69
|
+
function handleKeyDown(event) {
|
|
70
|
+
if (event.key !== 'Backspace' || digits.length === 0)
|
|
71
|
+
return;
|
|
72
|
+
const start = event.currentTarget.selectionStart ?? digits.length;
|
|
73
|
+
const end = event.currentTarget.selectionEnd ?? start;
|
|
74
|
+
if (start === 0 && end === 0)
|
|
75
|
+
return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
const next = start === end
|
|
78
|
+
? digits.slice(0, start - 1) + digits.slice(start)
|
|
79
|
+
: digits.slice(0, start) + digits.slice(end);
|
|
80
|
+
const after = start === end ? start - 1 : start;
|
|
81
|
+
pendingCaret.current = after;
|
|
82
|
+
setCaret(after);
|
|
83
|
+
commit(next);
|
|
84
|
+
}
|
|
85
|
+
function handlePaste(event) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
setCaret(null);
|
|
88
|
+
commit(event.clipboardData.getData('text'));
|
|
89
|
+
}
|
|
90
|
+
function handleChange(event) {
|
|
91
|
+
const raw = event.target.value;
|
|
92
|
+
const start = event.target.selectionStart ?? raw.length;
|
|
93
|
+
// Count only the digits that survive cleaning before the caret, so a
|
|
94
|
+
// digit typed in the middle of the code highlights the box after it.
|
|
95
|
+
setCaret(digitsOnly(raw.slice(0, start)).length);
|
|
96
|
+
commit(raw);
|
|
97
|
+
}
|
|
98
|
+
return (_jsxs("div", { className: slotClass(classNames, 'root', 'relative'), children: [_jsx("input", { ref: hiddenRef, id: fieldId, name: name, inputMode: "numeric", autoComplete: "one-time-code", autoCorrect: "off", spellCheck: false,
|
|
99
|
+
// `\d*` rather than `[0-9]{6}`: it is the spelling iOS reads as «show the
|
|
100
|
+
// number pad», and the length is `minLength`/`maxLength`'s job.
|
|
101
|
+
pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labels['otp.label'], onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
focusHidden();
|
|
104
|
+
}, children: Array.from({ length: LENGTH }, (_, index) => {
|
|
105
|
+
const filled = digits[index] ?? '';
|
|
106
|
+
const isActive = !disabled && index === active;
|
|
107
|
+
return (_jsx("span", { "data-slot": "otp-slot", "data-active": isActive || undefined, className: cn(slotClass(classNames, 'slot', 'flex aspect-square min-w-0 w-full items-center justify-center rounded-[4px] border-[1.5px] font-display text-xl font-bold tabular-nums sm:text-2xl', slotTone(invalid, isActive, filled !== '')), isActive && classNames?.slotActive, !isActive && filled && classNames?.slotFilled), children: filled }, index));
|
|
108
|
+
}) })] }));
|
|
109
|
+
}
|