@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,316 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useRef, useState } from 'react';
|
|
4
|
+
import { Checkbox } from '../primitives/checkbox.js';
|
|
5
|
+
import { Field } from '../primitives/field.js';
|
|
6
|
+
import { Input } from '../primitives/input.js';
|
|
7
|
+
import { Textarea } from '../primitives/textarea.js';
|
|
8
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
9
|
+
import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
|
|
10
|
+
import { fillLabel } from './labels.js';
|
|
11
|
+
import { slotClass } from './slots.js';
|
|
12
|
+
/**
|
|
13
|
+
* The last step of the booking wizard: contact details, who each seat is for,
|
|
14
|
+
* the consents, and the button that books.
|
|
15
|
+
*
|
|
16
|
+
* Presentational, with the same bargain the other steps make: every answer
|
|
17
|
+
* leaves as an action (`onChange`) and the caller's machine decides what it
|
|
18
|
+
* means. What stays here is local UI state that is not an answer about the
|
|
19
|
+
* booking — the keystrokes behind the per-child fields, which field has focus,
|
|
20
|
+
* whether submit has been pressed, and a fallback submission nonce.
|
|
21
|
+
*/
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Labels
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const DETAILS_SCREEN_LABEL_KEYS = [
|
|
26
|
+
'details.heading',
|
|
27
|
+
'details.phone.label',
|
|
28
|
+
/** The country code shown (not typed) before the number. Empty string hides it. */
|
|
29
|
+
'details.phone.prefix',
|
|
30
|
+
'details.phone.error',
|
|
31
|
+
'details.name.label',
|
|
32
|
+
/** Accessible name of the list of seats step 1 already named. */
|
|
33
|
+
'details.known.label',
|
|
34
|
+
/** `{who}` follows the bold service name: «for {who}». */
|
|
35
|
+
'details.known.for',
|
|
36
|
+
'details.known.self',
|
|
37
|
+
'details.known.adult',
|
|
38
|
+
'details.known.child',
|
|
39
|
+
'details.child.name',
|
|
40
|
+
'details.child.year',
|
|
41
|
+
'details.child.yearPlaceholder',
|
|
42
|
+
'details.email.label',
|
|
43
|
+
'details.email.help',
|
|
44
|
+
'details.email.error',
|
|
45
|
+
'details.notes.label',
|
|
46
|
+
'details.notes.placeholder',
|
|
47
|
+
'details.terms.text',
|
|
48
|
+
/** Link text after the terms sentence; only drawn when `termsHref` is set. */
|
|
49
|
+
'details.terms.link',
|
|
50
|
+
'details.marketing.text',
|
|
51
|
+
'details.error.maxParty',
|
|
52
|
+
'details.error.slotTaken',
|
|
53
|
+
'details.error.conflict',
|
|
54
|
+
'details.error.invalidInput',
|
|
55
|
+
'details.error.unconfigured',
|
|
56
|
+
'details.error.upstreamError',
|
|
57
|
+
'details.error.inProgress',
|
|
58
|
+
/** `{phone}` — visible text and accessible name of the call link. */
|
|
59
|
+
'details.call.link',
|
|
60
|
+
/** The rescue sentence when there is no phone number to link. */
|
|
61
|
+
'details.call.none',
|
|
62
|
+
/** `{price}` — the total, already formatted. */
|
|
63
|
+
'details.submit',
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* The label key for each failure the wizard can carry. Exported because some
|
|
67
|
+
* of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
|
|
68
|
+
* the same sentence — one map, so one refusal is never explained two ways.
|
|
69
|
+
*/
|
|
70
|
+
export const DETAILS_ERROR_LABEL_KEYS = {
|
|
71
|
+
maxParty: 'details.error.maxParty',
|
|
72
|
+
slotTaken: 'details.error.slotTaken',
|
|
73
|
+
conflict: 'details.error.conflict',
|
|
74
|
+
invalidInput: 'details.error.invalidInput',
|
|
75
|
+
unconfigured: 'details.error.unconfigured',
|
|
76
|
+
upstreamError: 'details.error.upstreamError',
|
|
77
|
+
inProgress: 'details.error.inProgress',
|
|
78
|
+
};
|
|
79
|
+
/** One saved child offered as a single tap above the child fields. */
|
|
80
|
+
export function DefaultFamilyChip({ member, pressed, onPick }) {
|
|
81
|
+
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": pressed, className: "rounded-full", onClick: onPick, children: member.name }));
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Deliberately loose: one `@`, something on each side, a dot in the domain,
|
|
88
|
+
* no spaces. The field is optional, so the only job is to catch an address
|
|
89
|
+
* that cannot receive anything. Not RFC 5322 — a stricter pattern rejects real
|
|
90
|
+
* addresses.
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* A random submission id. `crypto.randomUUID` only exists in secure contexts
|
|
94
|
+
* (https, localhost); a plain-http origin falls back to `getRandomValues`,
|
|
95
|
+
* which is available everywhere, shaped as a v4 UUID.
|
|
96
|
+
*/
|
|
97
|
+
function mintNonce() {
|
|
98
|
+
if (typeof crypto.randomUUID === 'function')
|
|
99
|
+
return crypto.randomUUID();
|
|
100
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
101
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
102
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
103
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
104
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
105
|
+
}
|
|
106
|
+
/** Blank is fine (the field is optional). Anything else has to be reachable. */
|
|
107
|
+
export function looksLikeEmail(value) {
|
|
108
|
+
const trimmed = value.trim();
|
|
109
|
+
if (trimmed.length === 0)
|
|
110
|
+
return true;
|
|
111
|
+
// Hand-rolled rather than a regex: linear on any input, same rule as
|
|
112
|
+
// /^[^\s@]+@[^\s@]+\.[^\s@]+$/.
|
|
113
|
+
if (/\s/.test(trimmed))
|
|
114
|
+
return false;
|
|
115
|
+
const at = trimmed.indexOf('@');
|
|
116
|
+
if (at < 1 || at !== trimmed.lastIndexOf('@'))
|
|
117
|
+
return false;
|
|
118
|
+
const domain = trimmed.slice(at + 1);
|
|
119
|
+
for (let index = 1; index < domain.length - 1; index += 1) {
|
|
120
|
+
if (domain[index] === '.')
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const NO_CHILD = { name: '', birthYear: '' };
|
|
126
|
+
function seedChildren(items) {
|
|
127
|
+
return Object.fromEntries(items.map((item, index) => [
|
|
128
|
+
index,
|
|
129
|
+
{
|
|
130
|
+
name: item.bookedForName ?? '',
|
|
131
|
+
birthYear: item.bookedForBirthYear === undefined ? '' : String(item.bookedForBirthYear),
|
|
132
|
+
},
|
|
133
|
+
]));
|
|
134
|
+
}
|
|
135
|
+
/** Four digits and nothing else — `parseInt` would accept a numeric prefix. */
|
|
136
|
+
const FOUR_DIGITS = /^\d{4}$/;
|
|
137
|
+
const EARLIEST_BIRTH_YEAR = 1900;
|
|
138
|
+
const LATEST_BIRTH_YEAR = 2200;
|
|
139
|
+
/** The year as a number, or `null` for «nothing usable typed». */
|
|
140
|
+
function parseBirthYear(raw) {
|
|
141
|
+
const trimmed = raw.trim();
|
|
142
|
+
if (!FOUR_DIGITS.test(trimmed))
|
|
143
|
+
return null;
|
|
144
|
+
const year = Number(trimmed);
|
|
145
|
+
return year >= EARLIEST_BIRTH_YEAR && year <= LATEST_BIRTH_YEAR ? year : null;
|
|
146
|
+
}
|
|
147
|
+
/** Spread into an object literal, so «nothing typed» is an absent key, not `''`. */
|
|
148
|
+
function present(key, value) {
|
|
149
|
+
const trimmed = value.trim();
|
|
150
|
+
return trimmed.length > 0 ? { [key]: trimmed } : undefined;
|
|
151
|
+
}
|
|
152
|
+
/** Who a seat step 1 already named is for, in words. */
|
|
153
|
+
function knownWho(state, index, labels) {
|
|
154
|
+
const item = state.items[index];
|
|
155
|
+
if (item?.adult) {
|
|
156
|
+
return state.people[index]?.key === 'self'
|
|
157
|
+
? labels['details.known.self']
|
|
158
|
+
: labels['details.known.adult'];
|
|
159
|
+
}
|
|
160
|
+
return item?.bookedForName ?? labels['details.known.child'];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether step 1 already said who sits in this seat: a saved person's card
|
|
164
|
+
* (`p:` / `n:`), the logged-in guardian (`self`) or a guest adult (`adult`).
|
|
165
|
+
*/
|
|
166
|
+
function isKnownChair(state, index) {
|
|
167
|
+
const key = state.people[index]?.key ?? '';
|
|
168
|
+
return key.startsWith('p:') || key.startsWith('n:') || key === 'self' || key === 'adult';
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Screen
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
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, }) {
|
|
174
|
+
const ids = useId();
|
|
175
|
+
const field = (name) => `${ids}-${name}`;
|
|
176
|
+
const phoneRef = useRef(null);
|
|
177
|
+
const emailRef = useRef(null);
|
|
178
|
+
const termsRef = useRef(null);
|
|
179
|
+
// The text on the way to the machine's answers. Seeded from the items so a
|
|
180
|
+
// visitor who steps back and returns finds the names still in the boxes;
|
|
181
|
+
// keyed by index because two lines can share a service.
|
|
182
|
+
const [children, setChildren] = useState(() => seedChildren(state.items));
|
|
183
|
+
const childAt = (index) => children[index] ?? NO_CHILD;
|
|
184
|
+
// Minted once per mount (a lazy initialiser), never per press: a
|
|
185
|
+
// double-tapped submit must carry the same nonce twice.
|
|
186
|
+
// Only minted when the caller does not pass one (lazily, once per mount).
|
|
187
|
+
const ownNonce = useRef(null);
|
|
188
|
+
if (nonceProp === undefined && ownNonce.current === null)
|
|
189
|
+
ownNonce.current = mintNonce();
|
|
190
|
+
const submissionNonce = nonceProp ?? ownNonce.current ?? '';
|
|
191
|
+
// Which field has focus: the phone error is held back while the visitor is
|
|
192
|
+
// still typing and appears the moment the field is left.
|
|
193
|
+
const [focused, setFocused] = useState(null);
|
|
194
|
+
// Submit is never disabled for validation, so pressing it is what explains a refusal.
|
|
195
|
+
const [attempted, setAttempted] = useState(false);
|
|
196
|
+
const phoneTyped = state.contact.phone.trim().length > 0;
|
|
197
|
+
const phoneValid = phoneLooksValid(state.contact.phone);
|
|
198
|
+
const emailValid = looksLikeEmail(state.contact.email);
|
|
199
|
+
const showPhoneError = !phoneValid && (attempted || (phoneTyped && focused !== 'phone'));
|
|
200
|
+
const showEmailError = !emailValid && (attempted || focused !== 'email');
|
|
201
|
+
const showTermsError = attempted && !state.consentTerms;
|
|
202
|
+
const price = format.price(totalOre);
|
|
203
|
+
const phonePrefix = labels['details.phone.prefix'];
|
|
204
|
+
const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
|
|
205
|
+
function setContact(name, value) {
|
|
206
|
+
onChange({ type: 'setContact', field: name, value });
|
|
207
|
+
}
|
|
208
|
+
/** Written to the buffer (what the field shows) and to the machine (the answer). */
|
|
209
|
+
function setChild(index, patch, personId) {
|
|
210
|
+
setChildren((previous) => ({
|
|
211
|
+
...previous,
|
|
212
|
+
[index]: { ...(previous[index] ?? NO_CHILD), ...patch },
|
|
213
|
+
}));
|
|
214
|
+
if (patch.name !== undefined) {
|
|
215
|
+
onChange({ type: 'setItemField', index, field: 'bookedForName', value: patch.name });
|
|
216
|
+
// A family chip names a saved person; a name typed by hand may be someone
|
|
217
|
+
// the business does not know.
|
|
218
|
+
onChange({
|
|
219
|
+
type: 'setItemField',
|
|
220
|
+
index,
|
|
221
|
+
field: 'bookedForPersonId',
|
|
222
|
+
value: personId ?? null,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (patch.birthYear !== undefined) {
|
|
226
|
+
onChange({
|
|
227
|
+
type: 'setItemField',
|
|
228
|
+
index,
|
|
229
|
+
field: 'bookedForBirthYear',
|
|
230
|
+
value: parseBirthYear(patch.birthYear),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function submissionItems(startTs) {
|
|
235
|
+
// A saved person's id is only valid under the number of the guardian who owns them.
|
|
236
|
+
const ownPhone = guardianPhone !== null &&
|
|
237
|
+
normalisePhone(guardianPhone) === normalisePhone(state.contact.phone);
|
|
238
|
+
return state.items.map((item, index) => {
|
|
239
|
+
const child = childAt(index);
|
|
240
|
+
const birthYear = parseBirthYear(child.birthYear);
|
|
241
|
+
const line = lines[index];
|
|
242
|
+
const resourceId = line?.resourceId ?? null;
|
|
243
|
+
return {
|
|
244
|
+
serviceId: item.service.id,
|
|
245
|
+
...(resourceId === null ? {} : { resourceId }),
|
|
246
|
+
startTs: line?.startTs ?? startTs,
|
|
247
|
+
...present('bookedForName', isKnownChair(state, index) ? (item.bookedForName ?? '') : child.name),
|
|
248
|
+
...(birthYear === null ? {} : { bookedForBirthYear: birthYear }),
|
|
249
|
+
...(ownPhone && item.bookedForPersonId
|
|
250
|
+
? { bookedForPersonId: item.bookedForPersonId }
|
|
251
|
+
: {}),
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function handleSubmit() {
|
|
256
|
+
setAttempted(true);
|
|
257
|
+
// Unreachable through the wizard, but submitting without a slot would book 1970.
|
|
258
|
+
if (state.startTs === null)
|
|
259
|
+
return;
|
|
260
|
+
// Focus rather than scroll: it moves the viewport, puts the caret where the
|
|
261
|
+
// fix is needed, and works for whoever is not looking at the screen.
|
|
262
|
+
if (!phoneValid) {
|
|
263
|
+
phoneRef.current?.focus();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!emailValid) {
|
|
267
|
+
emailRef.current?.focus();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!state.consentTerms) {
|
|
271
|
+
termsRef.current?.focus();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
onSubmit({
|
|
275
|
+
items: submissionItems(state.startTs),
|
|
276
|
+
contact: {
|
|
277
|
+
phone: normalisePhone(state.contact.phone),
|
|
278
|
+
...present('name', state.contact.name),
|
|
279
|
+
...present('email', state.contact.email),
|
|
280
|
+
},
|
|
281
|
+
...present('notes', state.notes),
|
|
282
|
+
consentTerms: state.consentTerms,
|
|
283
|
+
consentMarketing: state.consentMarketing,
|
|
284
|
+
submissionNonce,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
|
|
288
|
+
const fieldClass = slotClass(classNames, 'field');
|
|
289
|
+
return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labels['details.known.label'], className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: item.service.name }), ' ', renderLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
|
|
290
|
+
// A seat step 1 already named is not asked again: a second answer could disagree.
|
|
291
|
+
isKnownChair(state, index) ? null : (_jsx(ChildFields
|
|
292
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
|
|
293
|
+
, { legend: state.items.length > 1 ? item.service.name : 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: 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 }) })] }));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The name and birth year for one line. Wrapped in a fieldset only when there
|
|
297
|
+
* is more than one line: two identically labelled fields with nothing between
|
|
298
|
+
* them is how one child's name lands on the other's booking.
|
|
299
|
+
*/
|
|
300
|
+
function ChildFields({ legend, nameId, yearId, value, family, labels, classNames, FamilyChip, onChange, }) {
|
|
301
|
+
const fields = (_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: nameId, children: labels['details.child.name'] }), _jsx(Input, { id: nameId, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), value: value.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: yearId, children: labels['details.child.year'] }), _jsx(Input, { id: yearId, inputMode: "numeric", maxLength: 4, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), placeholder: labels['details.child.yearPlaceholder'], value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
|
|
302
|
+
const withSuggestions = (_jsxs("div", { className: "space-y-3", children: [family !== undefined && family.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: family.map((member) => (_jsx(FamilyChip, { member: member, pressed: value.name === member.name && value.birthYear === String(member.birthYear), onPick: () => onChange({ name: member.name, birthYear: String(member.birthYear) }, member.personId) }, `${member.name}-${member.birthYear}`))) })), fields] }));
|
|
303
|
+
if (legend === null)
|
|
304
|
+
return withSuggestions;
|
|
305
|
+
return (_jsxs("fieldset", { className: slotClass(classNames, 'childGroup', 'space-y-2 rounded-lg border border-border px-5 py-4'), children: [_jsx("legend", { className: "px-1 font-sans text-sm font-bold text-muted-foreground", children: legend }), withSuggestions] }));
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* A failed submit must not be silent: announced (nothing else on the screen
|
|
309
|
+
* moved), and it offers the phone, because what reaches here is what the
|
|
310
|
+
* visitor cannot fix by trying harder.
|
|
311
|
+
*/
|
|
312
|
+
function SubmitFailure({ error, phone, format, labels, className, }) {
|
|
313
|
+
const template = labels['details.call.link'];
|
|
314
|
+
const call = phone ? fillLabel(template, { phone }) : null;
|
|
315
|
+
return (_jsxs("p", { role: "alert", className: className, children: [labels[DETAILS_ERROR_LABEL_KEYS[error]], ' ', phone && call ? (_jsx("a", { href: format.telHref(phone), "aria-label": call, className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(template, { phone }) })) : (labels['details.call.none'])] }));
|
|
316
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AgeRange } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The business's calendar, injected.
|
|
4
|
+
*
|
|
5
|
+
* Every date a booking screen draws is a date *where the business is*, not
|
|
6
|
+
* where the visitor's browser is — so the screens never call `Date` getters or
|
|
7
|
+
* `Intl` themselves. The booking package builds one of these per config
|
|
8
|
+
* (`createClock({ timeZone, locale, dayparts })`) and passes it in; a test or a
|
|
9
|
+
* story can hand in any implementation with the same shape.
|
|
10
|
+
*/
|
|
11
|
+
export interface BookingClock {
|
|
12
|
+
/** `YYYY-MM-DD` of the business-local date `ts` falls on. */
|
|
13
|
+
dayKey(ts: number): string;
|
|
14
|
+
/** An instant at the start of the business-local day `offsetDays` after `ts`'s. DST-safe. */
|
|
15
|
+
dayStart(ts: number, offsetDays?: number): number;
|
|
16
|
+
/** «14:30». */
|
|
17
|
+
formatTime(ts: number): string;
|
|
18
|
+
/** Business-local hour 0–23. */
|
|
19
|
+
hour(ts: number): number;
|
|
20
|
+
isWeekend(ts: number): boolean;
|
|
21
|
+
/** Whole business-local calendar days from `from` to `to` (can be negative). */
|
|
22
|
+
daysBetween(from: number, to: number): number;
|
|
23
|
+
/** Key of the daypart `ts` falls in (e.g. `morning`). */
|
|
24
|
+
daypartOf(ts: number): string;
|
|
25
|
+
/** «today» / «tomorrow» / «Thursday 3 September». */
|
|
26
|
+
dayLabel(ts: number, now?: number): string;
|
|
27
|
+
/** The short chip form: «Today», «Thu 3». */
|
|
28
|
+
dayChip(ts: number, now?: number): string;
|
|
29
|
+
/** «today 14:30» / «Thu 3 Sep 14:30». */
|
|
30
|
+
when(ts: number, now?: number): string;
|
|
31
|
+
/** The short date with weekday: «tor. 3. sep.» / «Thu 3 Sept». */
|
|
32
|
+
date(ts: number): string;
|
|
33
|
+
/** `date` plus the time: «tor. 3. sep. kl. 14:30». */
|
|
34
|
+
dateTime(ts: number): string;
|
|
35
|
+
/** «Thursday». */
|
|
36
|
+
weekday(ts: number): string;
|
|
37
|
+
/** Month name, 1–12, lower-case where the locale writes it so («september»). */
|
|
38
|
+
monthName(month: number): string;
|
|
39
|
+
/** Seven short weekday heads, Monday first («Mo» … «Su»). */
|
|
40
|
+
weekdayHeads(): readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Everything locale- or business-dependent a screen formats, in one object.
|
|
44
|
+
* Screens take it as their `format` prop. Nothing here is copy: the words come
|
|
45
|
+
* through `labels`.
|
|
46
|
+
*/
|
|
47
|
+
export interface BookingFormat {
|
|
48
|
+
clock: BookingClock;
|
|
49
|
+
/** Minor units → display, e.g. `186000` → «1 860 kr». */
|
|
50
|
+
price(minor: number): string;
|
|
51
|
+
/** A `tel:` href for a phone number as written. */
|
|
52
|
+
telHref(phone: string): string;
|
|
53
|
+
/** A stylist's name as customers should see it (trimmed of internal suffixes). */
|
|
54
|
+
stylistName(name: string): string;
|
|
55
|
+
/** One or two initials for an avatar fallback. */
|
|
56
|
+
initials(name: string): string;
|
|
57
|
+
/** «7 år» / «6–7 years». */
|
|
58
|
+
ageLabel(range: AgeRange): string;
|
|
59
|
+
}
|
|
60
|
+
/** A named part of the day for the time step, in display order (`clock.daypartOf` returns the key). */
|
|
61
|
+
export interface BookingDaypart {
|
|
62
|
+
key: string;
|
|
63
|
+
label: string;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export * from './add-child-sheet.js';
|
|
2
|
+
export * from './booking-skeleton.js';
|
|
3
|
+
export * from './confirmation.js';
|
|
4
|
+
export * from './details-screen.js';
|
|
5
|
+
export type { BookingClock, BookingDaypart, BookingFormat } from './format.js';
|
|
6
|
+
export { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
|
|
7
|
+
export * from './internal/ui.js';
|
|
8
|
+
export * from './label-keys.js';
|
|
9
|
+
export { fillLabel } from './labels.js';
|
|
10
|
+
export * from './live-status.js';
|
|
11
|
+
export * from './login-panel.js';
|
|
12
|
+
export * from './login-sheet.js';
|
|
13
|
+
export * from './manage-screen.js';
|
|
14
|
+
export * from './otp-slots.js';
|
|
15
|
+
export * from './portal/account-card.js';
|
|
16
|
+
export * from './portal/action-result.js';
|
|
17
|
+
export * from './portal/age-confirm-card.js';
|
|
18
|
+
export * from './portal/child-cards.js';
|
|
19
|
+
export * from './portal/data-controls.js';
|
|
20
|
+
export * from './portal/family-editor.js';
|
|
21
|
+
export * from './portal/logout-button.js';
|
|
22
|
+
export * from './portal/portal-shell.js';
|
|
23
|
+
export * from './portal/portal-unreachable.js';
|
|
24
|
+
export * from './portal/profile-form.js';
|
|
25
|
+
export * from './portal/rebook-cards.js';
|
|
26
|
+
export * from './portal/upcoming-bookings.js';
|
|
27
|
+
export * from './portal/vipps-link-row.js';
|
|
28
|
+
export * from './portal/visit-history.js';
|
|
29
|
+
export * from './service-screen.js';
|
|
30
|
+
export type { ScreenComponents, SlotClassNames } from './slots.js';
|
|
31
|
+
export * from './stylist-screen.js';
|
|
32
|
+
export * from './summary-bar.js';
|
|
33
|
+
export * from './time-screen.js';
|
|
34
|
+
export type * from './types.js';
|
|
35
|
+
export * from './vipps-button.js';
|
|
36
|
+
export * from './who-screen.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @medalsocial/meda/booking — unbranded, presentational booking screens.
|
|
2
|
+
//
|
|
3
|
+
// Props in, callbacks out: nothing here fetches, routes, or touches storage.
|
|
4
|
+
// Copy arrives through `labels`, dates/prices through `format`, look through
|
|
5
|
+
// the shadcn variables the theme bridge maps. Override ladder, cheapest first:
|
|
6
|
+
// CSS variables → labels → classNames (per slot) → components (card renderers).
|
|
7
|
+
// Styles: import `@medalsocial/meda/booking/styles.css` and
|
|
8
|
+
// `@medalsocial/meda/primitives/styles.css` after the bridge.
|
|
9
|
+
export * from './add-child-sheet.js';
|
|
10
|
+
export * from './booking-skeleton.js';
|
|
11
|
+
export * from './confirmation.js';
|
|
12
|
+
export * from './details-screen.js';
|
|
13
|
+
export { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
|
|
14
|
+
export * from './internal/ui.js';
|
|
15
|
+
export * from './label-keys.js';
|
|
16
|
+
export { fillLabel } from './labels.js';
|
|
17
|
+
export * from './live-status.js';
|
|
18
|
+
export * from './login-panel.js';
|
|
19
|
+
export * from './login-sheet.js';
|
|
20
|
+
export * from './manage-screen.js';
|
|
21
|
+
export * from './otp-slots.js';
|
|
22
|
+
export * from './portal/account-card.js';
|
|
23
|
+
export * from './portal/action-result.js';
|
|
24
|
+
export * from './portal/age-confirm-card.js';
|
|
25
|
+
export * from './portal/child-cards.js';
|
|
26
|
+
export * from './portal/data-controls.js';
|
|
27
|
+
export * from './portal/family-editor.js';
|
|
28
|
+
export * from './portal/logout-button.js';
|
|
29
|
+
export * from './portal/portal-shell.js';
|
|
30
|
+
export * from './portal/portal-unreachable.js';
|
|
31
|
+
export * from './portal/profile-form.js';
|
|
32
|
+
export * from './portal/rebook-cards.js';
|
|
33
|
+
export * from './portal/upcoming-bookings.js';
|
|
34
|
+
export * from './portal/vipps-link-row.js';
|
|
35
|
+
export * from './portal/visit-history.js';
|
|
36
|
+
export * from './service-screen.js';
|
|
37
|
+
export * from './stylist-screen.js';
|
|
38
|
+
export * from './summary-bar.js';
|
|
39
|
+
export * from './time-screen.js';
|
|
40
|
+
export * from './vipps-button.js';
|
|
41
|
+
export * from './who-screen.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* `fillLabel` for templates whose placeholders are elements (a link, a
|
|
4
|
+
* button) rather than strings: `'Have an account? {trigger} – …'` with
|
|
5
|
+
* `{ trigger: <button>…</button> }`. Text parts stay text; unknown
|
|
6
|
+
* placeholders are left as written.
|
|
7
|
+
*/
|
|
8
|
+
export declare function labelParts(template: string, parts: Readonly<Record<string, ReactNode>>): ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* A filled label as element children: the template split around its
|
|
11
|
+
* `{placeholders}`, so React renders one text node per literal run and one
|
|
12
|
+
* per value — the same DOM as writing the sentence out in JSX
|
|
13
|
+
* (`Total {total} · paid at the salon`). Prefer it over `fillLabel` wherever
|
|
14
|
+
* a label is rendered as children; a browser lays text out per text node, so
|
|
15
|
+
* one joined string can land a line's glyphs a sub-pixel away from the same
|
|
16
|
+
* sentence composed in JSX.
|
|
17
|
+
*
|
|
18
|
+
* Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
|
|
19
|
+
* Empty literal runs and empty values render nothing, as in JSX; a label
|
|
20
|
+
* that fills to nothing at all is `null`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderLabel(template: string, values: Readonly<Record<string, string | number>>): ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* `parts.filter(Boolean).join(separator)` as element children: every part
|
|
25
|
+
* and every separator its own text piece. Blank parts drop out, so an absent
|
|
26
|
+
* one closes the gap rather than leaving a doubled separator.
|
|
27
|
+
*/
|
|
28
|
+
export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string): ReactNode;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* `fillLabel` for templates whose placeholders are elements (a link, a
|
|
5
|
+
* button) rather than strings: `'Have an account? {trigger} – …'` with
|
|
6
|
+
* `{ trigger: <button>…</button> }`. Text parts stay text; unknown
|
|
7
|
+
* placeholders are left as written.
|
|
8
|
+
*/
|
|
9
|
+
export function labelParts(template, parts) {
|
|
10
|
+
const pieces = template.split(/(\{\w+\})/g);
|
|
11
|
+
return pieces.map((piece, index) => {
|
|
12
|
+
const match = /^\{(\w+)\}$/.exec(piece);
|
|
13
|
+
if (match && Object.hasOwn(parts, match[1])) {
|
|
14
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: the pieces of a fixed template never reorder
|
|
15
|
+
return _jsx(Fragment, { children: parts[match[1]] }, index);
|
|
16
|
+
}
|
|
17
|
+
return piece;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A filled label as element children: the template split around its
|
|
22
|
+
* `{placeholders}`, so React renders one text node per literal run and one
|
|
23
|
+
* per value — the same DOM as writing the sentence out in JSX
|
|
24
|
+
* (`Total {total} · paid at the salon`). Prefer it over `fillLabel` wherever
|
|
25
|
+
* a label is rendered as children; a browser lays text out per text node, so
|
|
26
|
+
* one joined string can land a line's glyphs a sub-pixel away from the same
|
|
27
|
+
* sentence composed in JSX.
|
|
28
|
+
*
|
|
29
|
+
* Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
|
|
30
|
+
* Empty literal runs and empty values render nothing, as in JSX; a label
|
|
31
|
+
* that fills to nothing at all is `null`.
|
|
32
|
+
*/
|
|
33
|
+
export function renderLabel(template, values) {
|
|
34
|
+
const pieces = template.split(/(\{\w+\})/g);
|
|
35
|
+
const nodes = [];
|
|
36
|
+
for (const piece of pieces) {
|
|
37
|
+
const match = /^\{(\w+)\}$/.exec(piece);
|
|
38
|
+
const value = match && Object.hasOwn(values, match[1]) ? values[match[1]] : piece;
|
|
39
|
+
if (value !== '')
|
|
40
|
+
nodes.push(value);
|
|
41
|
+
}
|
|
42
|
+
return nodes.length > 0 ? nodes : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `parts.filter(Boolean).join(separator)` as element children: every part
|
|
46
|
+
* and every separator its own text piece. Blank parts drop out, so an absent
|
|
47
|
+
* one closes the gap rather than leaving a doubled separator.
|
|
48
|
+
*/
|
|
49
|
+
export function joinLabelParts(parts, separator) {
|
|
50
|
+
return parts
|
|
51
|
+
.filter((part) => part !== null &&
|
|
52
|
+
part !== undefined &&
|
|
53
|
+
part !== false &&
|
|
54
|
+
part !== '' &&
|
|
55
|
+
!(Array.isArray(part) && part.length === 0))
|
|
56
|
+
.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The booking screens' control look, in one place.
|
|
4
|
+
*
|
|
5
|
+
* Booking has its own proportions — 48 px fields, 1.5 px borders, near-square
|
|
6
|
+
* corners, a tall primary button — tuned for a parent booking on a phone. The
|
|
7
|
+
* screens build on meda's primitives (`Input`, `Textarea`, `Checkbox`,
|
|
8
|
+
* `Field`, `Sheet`) and pass these classes as overrides; tailwind-merge makes
|
|
9
|
+
* them win over the primitive defaults they conflict with. Colours are bridge
|
|
10
|
+
* variables only, so a site's own `--primary` / `--border` / … re-theme them.
|
|
11
|
+
*/
|
|
12
|
+
export type BookingButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link';
|
|
13
|
+
export type BookingButtonSize = 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
|
|
14
|
+
/** Classes for a booking button — also for a link that should look like one. */
|
|
15
|
+
export declare function bookingButtonClass({ variant, size, className, }?: {
|
|
16
|
+
variant?: BookingButtonVariant;
|
|
17
|
+
size?: BookingButtonSize;
|
|
18
|
+
className?: string;
|
|
19
|
+
}): string;
|
|
20
|
+
export interface BookingButtonProps extends ComponentProps<'button'> {
|
|
21
|
+
variant?: BookingButtonVariant;
|
|
22
|
+
size?: BookingButtonSize;
|
|
23
|
+
}
|
|
24
|
+
/** The booking screens' button. A native `<button type="button">` unless told otherwise. */
|
|
25
|
+
export declare function BookingButton({ variant, size, className, type, ...props }: BookingButtonProps): import("react").JSX.Element;
|
|
26
|
+
/** Override for meda `Input` in booking forms. */
|
|
27
|
+
export declare const BOOKING_INPUT_CLASS = "h-12 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50";
|
|
28
|
+
/** Override for meda `Textarea` in booking forms. */
|
|
29
|
+
export declare const BOOKING_TEXTAREA_CLASS = "min-h-16 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50";
|
|
30
|
+
/** Override for meda `Checkbox` in booking forms. */
|
|
31
|
+
export declare const BOOKING_CHECKBOX_CLASS = "shadow-xs transition-shadow focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-input/30";
|
|
32
|
+
/** A native `<select>` sized like a booking input. */
|
|
33
|
+
export declare const BOOKING_SELECT_CLASS = "h-12 w-full rounded-[4px] border border-input bg-transparent px-3 text-base outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from '../../lib/utils.js';
|
|
4
|
+
const BUTTON_BASE = 'group/button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-[3px] border border-transparent bg-clip-padding text-sm font-semibold transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4';
|
|
5
|
+
const BUTTON_VARIANTS = {
|
|
6
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
7
|
+
outline: 'border-[1.5px] border-primary/35 bg-transparent text-primary hover:border-primary hover:bg-primary/5 aria-expanded:bg-primary/5 aria-expanded:text-primary',
|
|
8
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
|
9
|
+
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
|
10
|
+
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30',
|
|
11
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
12
|
+
};
|
|
13
|
+
const BUTTON_SIZES = {
|
|
14
|
+
default: 'h-10 gap-1.5 px-5 text-[15px]',
|
|
15
|
+
sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5',
|
|
16
|
+
lg: 'h-auto gap-1.5 px-7 py-[15px] text-[15px]',
|
|
17
|
+
icon: 'size-9',
|
|
18
|
+
'icon-sm': 'size-8 rounded-[min(var(--radius-md),10px)]',
|
|
19
|
+
};
|
|
20
|
+
/** Classes for a booking button — also for a link that should look like one. */
|
|
21
|
+
export function bookingButtonClass({ variant = 'default', size = 'default', className, } = {}) {
|
|
22
|
+
return cn(BUTTON_BASE, BUTTON_VARIANTS[variant], BUTTON_SIZES[size], className);
|
|
23
|
+
}
|
|
24
|
+
/** The booking screens' button. A native `<button type="button">` unless told otherwise. */
|
|
25
|
+
export function BookingButton({ variant, size, className, type = 'button', ...props }) {
|
|
26
|
+
return (_jsx("button", { type: type, "data-slot": "button", className: bookingButtonClass({ variant, size, className }), ...props }));
|
|
27
|
+
}
|
|
28
|
+
/** Override for meda `Input` in booking forms. */
|
|
29
|
+
export const BOOKING_INPUT_CLASS = 'h-12 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50';
|
|
30
|
+
/** Override for meda `Textarea` in booking forms. */
|
|
31
|
+
export const BOOKING_TEXTAREA_CLASS = 'min-h-16 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50';
|
|
32
|
+
/** Override for meda `Checkbox` in booking forms. */
|
|
33
|
+
export const BOOKING_CHECKBOX_CLASS = 'shadow-xs transition-shadow focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-input/30';
|
|
34
|
+
/** A native `<select>` sized like a booking input. */
|
|
35
|
+
export const BOOKING_SELECT_CLASS = 'h-12 w-full rounded-[4px] border border-input bg-transparent px-3 text-base outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50';
|