@medalsocial/meda 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/dist/booking/add-child-sheet.d.ts +38 -0
- package/dist/booking/add-child-sheet.js +88 -0
- package/dist/booking/booking-skeleton.d.ts +24 -0
- package/dist/booking/booking-skeleton.js +17 -0
- package/dist/booking/confirmation.d.ts +85 -0
- package/dist/booking/confirmation.js +102 -0
- package/dist/booking/details-screen.d.ts +118 -0
- package/dist/booking/details-screen.js +314 -0
- package/dist/booking/format.d.ts +64 -0
- package/dist/booking/format.js +1 -0
- package/dist/booking/index.d.ts +35 -0
- package/dist/booking/index.js +40 -0
- package/dist/booking/internal/label-parts.d.ts +8 -0
- package/dist/booking/internal/label-parts.js +19 -0
- package/dist/booking/internal/ui.d.ts +33 -0
- package/dist/booking/internal/ui.js +35 -0
- package/dist/booking/label-keys.d.ts +34 -0
- package/dist/booking/label-keys.js +56 -0
- package/dist/booking/labels.d.ts +15 -0
- package/dist/booking/labels.js +17 -0
- package/dist/booking/live-status.d.ts +14 -0
- package/dist/booking/live-status.js +17 -0
- package/dist/booking/login-panel.d.ts +108 -0
- package/dist/booking/login-panel.js +239 -0
- package/dist/booking/login-sheet.d.ts +41 -0
- package/dist/booking/login-sheet.js +49 -0
- package/dist/booking/manage-screen.d.ts +113 -0
- package/dist/booking/manage-screen.js +369 -0
- package/dist/booking/otp-slots.d.ts +42 -0
- package/dist/booking/otp-slots.js +109 -0
- package/dist/booking/portal/account-card.d.ts +24 -0
- package/dist/booking/portal/account-card.js +18 -0
- package/dist/booking/portal/action-result.d.ts +30 -0
- package/dist/booking/portal/action-result.js +18 -0
- package/dist/booking/portal/age-confirm-card.d.ts +39 -0
- package/dist/booking/portal/age-confirm-card.js +42 -0
- package/dist/booking/portal/child-cards.d.ts +65 -0
- package/dist/booking/portal/child-cards.js +59 -0
- package/dist/booking/portal/data-controls.d.ts +48 -0
- package/dist/booking/portal/data-controls.js +103 -0
- package/dist/booking/portal/family-editor.d.ts +141 -0
- package/dist/booking/portal/family-editor.js +422 -0
- package/dist/booking/portal/logout-button.d.ts +21 -0
- package/dist/booking/portal/logout-button.js +27 -0
- package/dist/booking/portal/portal-shell.d.ts +71 -0
- package/dist/booking/portal/portal-shell.js +38 -0
- package/dist/booking/portal/portal-unreachable.d.ts +31 -0
- package/dist/booking/portal/portal-unreachable.js +19 -0
- package/dist/booking/portal/profile-form.d.ts +75 -0
- package/dist/booking/portal/profile-form.js +144 -0
- package/dist/booking/portal/rebook-cards.d.ts +44 -0
- package/dist/booking/portal/rebook-cards.js +35 -0
- package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
- package/dist/booking/portal/upcoming-bookings.js +88 -0
- package/dist/booking/portal/vipps-link-row.d.ts +54 -0
- package/dist/booking/portal/vipps-link-row.js +80 -0
- package/dist/booking/portal/visit-history.d.ts +63 -0
- package/dist/booking/portal/visit-history.js +73 -0
- package/dist/booking/service-screen.d.ts +137 -0
- package/dist/booking/service-screen.js +233 -0
- package/dist/booking/slots.d.ts +12 -0
- package/dist/booking/slots.js +5 -0
- package/dist/booking/stylist-screen.d.ts +117 -0
- package/dist/booking/stylist-screen.js +226 -0
- package/dist/booking/summary-bar.d.ts +26 -0
- package/dist/booking/summary-bar.js +32 -0
- package/dist/booking/time-screen.d.ts +191 -0
- package/dist/booking/time-screen.js +418 -0
- package/dist/booking/types.d.ts +284 -0
- package/dist/booking/types.js +15 -0
- package/dist/booking/vipps-button.d.ts +49 -0
- package/dist/booking/vipps-button.js +33 -0
- package/dist/booking/who-screen.d.ts +121 -0
- package/dist/booking/who-screen.js +164 -0
- package/dist/primitives/checkbox.d.ts +23 -0
- package/dist/primitives/checkbox.js +20 -0
- package/dist/primitives/field.d.ts +47 -0
- package/dist/primitives/field.js +48 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/sheet.d.ts +68 -0
- package/dist/primitives/sheet.js +303 -0
- package/dist/primitives/textarea.d.ts +14 -0
- package/dist/primitives/textarea.js +13 -0
- package/dist/styles/booking.css +10 -0
- package/package.json +8 -1
- package/skills/components/SKILL.md +53 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural data shapes the booking screens take as props.
|
|
3
|
+
*
|
|
4
|
+
* These are local copies, on purpose: `@medalsocial/meda/booking` renders a
|
|
5
|
+
* booking flow but never fetches one, so it must not depend on the booking
|
|
6
|
+
* package or the Medal SDK. Whatever produces the data (the booking package,
|
|
7
|
+
* a test, a story) only has to be assignable to these shapes.
|
|
8
|
+
*
|
|
9
|
+
* Conventions every shape follows:
|
|
10
|
+
* - instants are epoch milliseconds (`…Ts`), never ISO strings;
|
|
11
|
+
* - money is integer minor units (`…Ore`: øre / cents);
|
|
12
|
+
* - optional fields are ABSENT rather than `undefined`-or-blank, except where a
|
|
13
|
+
* `null` is itself an answer (documented on the field).
|
|
14
|
+
*/
|
|
15
|
+
/** One bookable service as the wizard carries it. */
|
|
16
|
+
export interface WizardService {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
/** The service group key (see `ServiceScreen`'s `categories`). */
|
|
20
|
+
category: string;
|
|
21
|
+
durationMinutes: number;
|
|
22
|
+
priceOre: number;
|
|
23
|
+
/** How many of this service one booking may hold. */
|
|
24
|
+
maxPerBooking: number;
|
|
25
|
+
/** 0 when there is no weekend surcharge. */
|
|
26
|
+
weekendSurchargePct: number;
|
|
27
|
+
bufferBeforeMinutes: number;
|
|
28
|
+
bufferAfterMinutes: number;
|
|
29
|
+
/** Inclusive age bounds, when the service is age-restricted. */
|
|
30
|
+
ageMinYears?: number;
|
|
31
|
+
ageMaxYears?: number;
|
|
32
|
+
}
|
|
33
|
+
/** A service as the service step lists it: online-bookable or «call us». */
|
|
34
|
+
export interface BookingServiceDto extends WizardService {
|
|
35
|
+
bookableOnline: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** One stylist (bookable resource). */
|
|
38
|
+
export interface BookingResourceDto {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
photoUrl: string | null;
|
|
42
|
+
bio: string | null;
|
|
43
|
+
/** The services this resource can perform. */
|
|
44
|
+
serviceIds: string[];
|
|
45
|
+
sortOrder: number;
|
|
46
|
+
}
|
|
47
|
+
/** One free slot. `resourceId: null` = «first available», not yet resolved. */
|
|
48
|
+
export interface BookingSlotDto {
|
|
49
|
+
startTs: number;
|
|
50
|
+
resourceId: string | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One date the business keeps opening hours on. A date missing from the list
|
|
54
|
+
* is one it keeps no hours on. `lastStartTs` is the last start THIS visit could
|
|
55
|
+
* take (not the closing time); `null` = open-hours posted but shut (holiday).
|
|
56
|
+
*/
|
|
57
|
+
export interface BookingDayDto {
|
|
58
|
+
dayKey: string;
|
|
59
|
+
opensTs: number;
|
|
60
|
+
closesTs: number;
|
|
61
|
+
lastStartTs: number | null;
|
|
62
|
+
}
|
|
63
|
+
export type WizardStep = 'who' | 'service' | 'when' | 'details';
|
|
64
|
+
/** One seat in the booking: a child (named or not) or an adult. */
|
|
65
|
+
export interface WizardPerson {
|
|
66
|
+
key: string;
|
|
67
|
+
adult?: boolean;
|
|
68
|
+
personId?: string;
|
|
69
|
+
name?: string;
|
|
70
|
+
birthYear?: number;
|
|
71
|
+
birthMonth?: number;
|
|
72
|
+
}
|
|
73
|
+
/** One line of the basket. */
|
|
74
|
+
export interface WizardItem {
|
|
75
|
+
service: WizardService;
|
|
76
|
+
bookedForName?: string;
|
|
77
|
+
bookedForBirthYear?: number;
|
|
78
|
+
bookedForPersonId?: string;
|
|
79
|
+
bookedForBirthMonth?: number;
|
|
80
|
+
adult?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export type WizardError = 'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' | 'unconfigured' | 'upstreamError' | 'inProgress';
|
|
83
|
+
export type PartyMode = 'sequential' | 'parallel';
|
|
84
|
+
export interface WizardState {
|
|
85
|
+
step: WizardStep;
|
|
86
|
+
people: WizardPerson[];
|
|
87
|
+
choices: Array<WizardService | null>;
|
|
88
|
+
items: WizardItem[];
|
|
89
|
+
resourceId: string | null;
|
|
90
|
+
stylistAnswered: boolean;
|
|
91
|
+
partyMode: PartyMode;
|
|
92
|
+
startTs: number | null;
|
|
93
|
+
resolvedResourceId: string | null;
|
|
94
|
+
partyResourceIds: string[] | null;
|
|
95
|
+
contact: {
|
|
96
|
+
phone: string;
|
|
97
|
+
name: string;
|
|
98
|
+
email: string;
|
|
99
|
+
};
|
|
100
|
+
notes: string;
|
|
101
|
+
consentTerms: boolean;
|
|
102
|
+
consentMarketing: boolean;
|
|
103
|
+
pendingService: WizardService | null;
|
|
104
|
+
error: WizardError | null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The subset of the machine's actions the screens raise. A screen never
|
|
108
|
+
* reduces them — it hands them to its `onChange` / callback and the caller's
|
|
109
|
+
* machine decides what they mean.
|
|
110
|
+
*/
|
|
111
|
+
export type WizardAction = {
|
|
112
|
+
type: 'setPartyMode';
|
|
113
|
+
mode: PartyMode;
|
|
114
|
+
} | {
|
|
115
|
+
type: 'goToStep';
|
|
116
|
+
step: WizardStep;
|
|
117
|
+
} | {
|
|
118
|
+
type: 'setContact';
|
|
119
|
+
field: keyof WizardState['contact'];
|
|
120
|
+
value: string;
|
|
121
|
+
} | {
|
|
122
|
+
type: 'setItemField';
|
|
123
|
+
index: number;
|
|
124
|
+
field: 'bookedForName';
|
|
125
|
+
value: string;
|
|
126
|
+
} | {
|
|
127
|
+
type: 'setItemField';
|
|
128
|
+
index: number;
|
|
129
|
+
field: 'bookedForBirthYear';
|
|
130
|
+
value: number | null;
|
|
131
|
+
} | {
|
|
132
|
+
type: 'setItemField';
|
|
133
|
+
index: number;
|
|
134
|
+
field: 'bookedForPersonId';
|
|
135
|
+
value: string | null;
|
|
136
|
+
} | {
|
|
137
|
+
type: 'setNotes';
|
|
138
|
+
value: string;
|
|
139
|
+
} | {
|
|
140
|
+
type: 'setConsent';
|
|
141
|
+
which: 'terms' | 'marketing';
|
|
142
|
+
accepted: boolean;
|
|
143
|
+
};
|
|
144
|
+
/** One stylist seat inside a family (party) slot. */
|
|
145
|
+
export interface PartySeat {
|
|
146
|
+
startTs: number;
|
|
147
|
+
resourceId: string;
|
|
148
|
+
}
|
|
149
|
+
/** A whole family visit at one start. */
|
|
150
|
+
export interface PartySlot {
|
|
151
|
+
startTs: number;
|
|
152
|
+
mode: PartyMode;
|
|
153
|
+
seats: PartySeat[];
|
|
154
|
+
}
|
|
155
|
+
/** An age, exact (`min === max`) or a two-year range when only the year is known. */
|
|
156
|
+
export interface AgeRange {
|
|
157
|
+
min: number;
|
|
158
|
+
max: number;
|
|
159
|
+
}
|
|
160
|
+
/** A child the «add child» sheet collects. Never a full birth date. */
|
|
161
|
+
export interface NewChild {
|
|
162
|
+
name: string;
|
|
163
|
+
birthYear: number;
|
|
164
|
+
birthMonth?: number;
|
|
165
|
+
notes?: string;
|
|
166
|
+
}
|
|
167
|
+
/** What an async save answers a sheet or form with. */
|
|
168
|
+
export type SaveResult = {
|
|
169
|
+
ok: true;
|
|
170
|
+
} | {
|
|
171
|
+
ok: false;
|
|
172
|
+
message: string;
|
|
173
|
+
};
|
|
174
|
+
export interface BookingLastVisit {
|
|
175
|
+
serviceId: string;
|
|
176
|
+
serviceName: string | null;
|
|
177
|
+
resourceId: string | null;
|
|
178
|
+
startTs: number;
|
|
179
|
+
}
|
|
180
|
+
export interface BookingFamilyMember {
|
|
181
|
+
name: string;
|
|
182
|
+
birthYear: number;
|
|
183
|
+
personId?: string;
|
|
184
|
+
birthMonth?: number;
|
|
185
|
+
preferredResourceId?: string;
|
|
186
|
+
lastVisit?: BookingLastVisit;
|
|
187
|
+
}
|
|
188
|
+
export interface BookingGuardian {
|
|
189
|
+
firstName: string | null;
|
|
190
|
+
lastName: string | null;
|
|
191
|
+
email: string;
|
|
192
|
+
phone: string | null;
|
|
193
|
+
family: BookingFamilyMember[];
|
|
194
|
+
}
|
|
195
|
+
export type BookingStatus = 'pending' | 'confirmed' | 'completed' | 'cancelled' | 'no_show';
|
|
196
|
+
/** One booking as the manage screen shows it. Never carries the manage token. */
|
|
197
|
+
export interface BookingManageDto {
|
|
198
|
+
bookingId: string;
|
|
199
|
+
status: BookingStatus;
|
|
200
|
+
rescheduledFromId: string | null;
|
|
201
|
+
startTs: number;
|
|
202
|
+
endTs: number;
|
|
203
|
+
serviceId: string | null;
|
|
204
|
+
serviceName: string;
|
|
205
|
+
resourceId: string | null;
|
|
206
|
+
resourceName: string;
|
|
207
|
+
bookedForName: string | null;
|
|
208
|
+
partySequenceId: string | null;
|
|
209
|
+
amountOre: number;
|
|
210
|
+
cancelWindowHours: number;
|
|
211
|
+
rescheduleWindowHours: number;
|
|
212
|
+
canCancel: boolean;
|
|
213
|
+
canReschedule: boolean;
|
|
214
|
+
}
|
|
215
|
+
export interface PortalFamilyMemberDto {
|
|
216
|
+
personId: string | null;
|
|
217
|
+
name: string;
|
|
218
|
+
birthYear: number;
|
|
219
|
+
birthMonth: number | null;
|
|
220
|
+
notes: string | null;
|
|
221
|
+
preferredResourceId: string | null;
|
|
222
|
+
}
|
|
223
|
+
/** What the business calls the people on a profile («barn» / «children»). */
|
|
224
|
+
export interface PortalPersonNouns {
|
|
225
|
+
person: string;
|
|
226
|
+
persons: string;
|
|
227
|
+
}
|
|
228
|
+
export interface PortalProfileDto {
|
|
229
|
+
email: string;
|
|
230
|
+
firstName: string | null;
|
|
231
|
+
lastName: string | null;
|
|
232
|
+
phone: string | null;
|
|
233
|
+
family: PortalFamilyMemberDto[];
|
|
234
|
+
/** Whether Medal speaks birth months / notes / preferred stylist. */
|
|
235
|
+
personDetails: boolean;
|
|
236
|
+
marketingConsent: boolean;
|
|
237
|
+
labels?: PortalPersonNouns;
|
|
238
|
+
vippsLinked?: boolean;
|
|
239
|
+
}
|
|
240
|
+
export interface PortalBookingDto {
|
|
241
|
+
bookingId: string;
|
|
242
|
+
status: BookingStatus;
|
|
243
|
+
startTs: number;
|
|
244
|
+
endTs: number;
|
|
245
|
+
serviceId: string | null;
|
|
246
|
+
serviceName: string | null;
|
|
247
|
+
resourceId: string | null;
|
|
248
|
+
resourceName: string | null;
|
|
249
|
+
bookedForName: string | null;
|
|
250
|
+
bookedForPersonId: string | null;
|
|
251
|
+
bookedForBirthYear: number | null;
|
|
252
|
+
bookedForBirthMonth: number | null;
|
|
253
|
+
amountOre: number | null;
|
|
254
|
+
notes: string | null;
|
|
255
|
+
/** Where the booking is managed, when it can be. */
|
|
256
|
+
managePath: string | null;
|
|
257
|
+
}
|
|
258
|
+
export interface RebookSuggestion {
|
|
259
|
+
serviceId: string;
|
|
260
|
+
serviceName: string | null;
|
|
261
|
+
resourceId: string | null;
|
|
262
|
+
resourceName: string | null;
|
|
263
|
+
bookedForName: string | null;
|
|
264
|
+
}
|
|
265
|
+
/** One child's card on the portal, derived from the profile and the bookings. */
|
|
266
|
+
export interface ChildSummary {
|
|
267
|
+
personId: string | null;
|
|
268
|
+
name: string;
|
|
269
|
+
birthYear: number;
|
|
270
|
+
birthMonth: number | null;
|
|
271
|
+
ageRange: AgeRange;
|
|
272
|
+
age: number;
|
|
273
|
+
lastVisitTs: number | null;
|
|
274
|
+
serviceId: string | null;
|
|
275
|
+
serviceName: string | null;
|
|
276
|
+
resourceId: string | null;
|
|
277
|
+
preferredResourceId: string | null;
|
|
278
|
+
nextVisitTs: number | null;
|
|
279
|
+
}
|
|
280
|
+
/** One year of visit history. */
|
|
281
|
+
export interface VisitYear {
|
|
282
|
+
year: number;
|
|
283
|
+
bookings: PortalBookingDto[];
|
|
284
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural data shapes the booking screens take as props.
|
|
3
|
+
*
|
|
4
|
+
* These are local copies, on purpose: `@medalsocial/meda/booking` renders a
|
|
5
|
+
* booking flow but never fetches one, so it must not depend on the booking
|
|
6
|
+
* package or the Medal SDK. Whatever produces the data (the booking package,
|
|
7
|
+
* a test, a story) only has to be assignable to these shapes.
|
|
8
|
+
*
|
|
9
|
+
* Conventions every shape follows:
|
|
10
|
+
* - instants are epoch milliseconds (`…Ts`), never ISO strings;
|
|
11
|
+
* - money is integer minor units (`…Ore`: øre / cents);
|
|
12
|
+
* - optional fields are ABSENT rather than `undefined`-or-blank, except where a
|
|
13
|
+
* `null` is itself an answer (documented on the field).
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type SlotClassNames } from './slots.js';
|
|
2
|
+
export declare const VIPPS_BUTTON_LABEL_KEYS: readonly ["vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
3
|
+
export type VippsButtonLabels = Record<(typeof VIPPS_BUTTON_LABEL_KEYS)[number], string>;
|
|
4
|
+
/** Why a Vipps start did NOT end in a redirect. `null` is silence. */
|
|
5
|
+
export type VippsStartState = {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: 'unavailable' | 'throttled';
|
|
8
|
+
} | null;
|
|
9
|
+
/**
|
|
10
|
+
* The start action, in `useActionState`'s shape — so a server action with
|
|
11
|
+
* this signature can be passed straight through and the button keeps working
|
|
12
|
+
* before hydration. On success it is expected to navigate (redirect) and never
|
|
13
|
+
* answer; the only answers the button shows are the reasons it did not.
|
|
14
|
+
*/
|
|
15
|
+
export type VippsStartAction = (previous: VippsStartState, formData: FormData) => VippsStartState | Promise<VippsStartState>;
|
|
16
|
+
/**
|
|
17
|
+
* - `root`: the `<form>`.
|
|
18
|
+
* - `button`: the submit button. meda draws it in the bridge `primary`
|
|
19
|
+
* colours; a site that must show the provider's own brand colour passes
|
|
20
|
+
* it here (e.g. its orange background and white text).
|
|
21
|
+
* - `notice`: the sentence under the button.
|
|
22
|
+
*/
|
|
23
|
+
export type VippsButtonSlot = 'root' | 'button' | 'notice';
|
|
24
|
+
export interface VippsButtonProps {
|
|
25
|
+
/**
|
|
26
|
+
* The start action, handed to `useActionState` unwrapped so a server action
|
|
27
|
+
* can be passed straight through (the form then works before hydration, and
|
|
28
|
+
* a framework redirect thrown from it propagates). Report failures by
|
|
29
|
+
* returning `{ reason }` — they become the notice under the button. A thrown
|
|
30
|
+
* error is not caught here; it reaches the host's error boundary.
|
|
31
|
+
*/
|
|
32
|
+
onVipps: VippsStartAction;
|
|
33
|
+
/** Where to land afterwards; rides along as a hidden `next` field. */
|
|
34
|
+
next?: string | null;
|
|
35
|
+
/** Replaces `labels['vipps.button']` (e.g. «Continue with Vipps» mid-flow). */
|
|
36
|
+
label?: string;
|
|
37
|
+
labels: VippsButtonLabels;
|
|
38
|
+
classNames?: SlotClassNames<VippsButtonSlot>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* «Log in with Vipps» — one button, one action, no fields.
|
|
42
|
+
*
|
|
43
|
+
* A real `<form action>` rather than an `onClick`: the action ends in a
|
|
44
|
+
* redirect, and a form submission is what lets the browser follow it as a
|
|
45
|
+
* plain navigation. It also means the button works before hydration when the
|
|
46
|
+
* action is a server action. `useFormStatus` must be read from INSIDE the
|
|
47
|
+
* form, hence the small inner component.
|
|
48
|
+
*/
|
|
49
|
+
export declare function VippsButton({ onVipps, next, label, labels, classNames }: VippsButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useActionState, useId } from 'react';
|
|
4
|
+
import { useFormStatus } from 'react-dom';
|
|
5
|
+
import { bookingButtonClass } from './internal/ui.js';
|
|
6
|
+
import { slotClass } from './slots.js';
|
|
7
|
+
export const VIPPS_BUTTON_LABEL_KEYS = [
|
|
8
|
+
'vipps.button',
|
|
9
|
+
'vipps.unavailable',
|
|
10
|
+
'vipps.throttled',
|
|
11
|
+
];
|
|
12
|
+
function SubmitButton({ label, className }) {
|
|
13
|
+
const { pending } = useFormStatus();
|
|
14
|
+
return (_jsx("button", { type: "submit", "data-slot": "button", disabled: pending, "aria-busy": pending || undefined, className: className, children: label }));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* «Log in with Vipps» — one button, one action, no fields.
|
|
18
|
+
*
|
|
19
|
+
* A real `<form action>` rather than an `onClick`: the action ends in a
|
|
20
|
+
* redirect, and a form submission is what lets the browser follow it as a
|
|
21
|
+
* plain navigation. It also means the button works before hydration when the
|
|
22
|
+
* action is a server action. `useFormStatus` must be read from INSIDE the
|
|
23
|
+
* form, hence the small inner component.
|
|
24
|
+
*/
|
|
25
|
+
export function VippsButton({ onVipps, next = null, label, labels, classNames }) {
|
|
26
|
+
const [state, formAction] = useActionState(onVipps, null);
|
|
27
|
+
const noticeId = `${useId()}-notice`;
|
|
28
|
+
const notice = state === null ? null : labels[`vipps.${state.reason}`];
|
|
29
|
+
return (_jsxs("form", { action: formAction, className: slotClass(classNames, 'root', 'space-y-3'), "aria-describedby": notice ? noticeId : undefined, children: [next && _jsx("input", { type: "hidden", name: "next", value: next }), _jsx(SubmitButton, { label: label ?? labels['vipps.button'], className: bookingButtonClass({
|
|
30
|
+
size: 'lg',
|
|
31
|
+
className: slotClass(classNames, 'button', 'w-full rounded-full bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/40'),
|
|
32
|
+
}) }), _jsx("div", { "aria-live": "polite", children: notice && (_jsx("p", { id: noticeId, className: slotClass(classNames, 'notice', 'text-sm text-muted-foreground'), children: notice })) })] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
import { type AddChildSheetLabels } from './add-child-sheet.js';
|
|
3
|
+
import type { BookingFormat } from './format.js';
|
|
4
|
+
import { type SlotClassNames } from './slots.js';
|
|
5
|
+
import type { NewChild, SaveResult, WizardPerson } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* The first step — who is the visit for?
|
|
8
|
+
*
|
|
9
|
+
* Presentational, like the other steps: every answer leaves as `onChoose` and
|
|
10
|
+
* the caller's machine decides what it means.
|
|
11
|
+
*
|
|
12
|
+
* TWO SHAPES, one question:
|
|
13
|
+
*
|
|
14
|
+
* - **A guest** gets a row of chips (e.g. «1 child · 2 children · 3 children ·
|
|
15
|
+
* Adult») and a chip is the whole answer: one tap, and the next step is on
|
|
16
|
+
* screen. Nothing about the child is asked here.
|
|
17
|
+
* - **A logged-in parent** gets their children as cards — initial, name, a
|
|
18
|
+
* line (age on the appointment day, last visit) — to tick, up to
|
|
19
|
+
* `maxPeople`, plus a «myself (adult)» row, and moves on with the wizard's
|
|
20
|
+
* own «next». Ticking is a live answer to the machine, so the sticky bar
|
|
21
|
+
* knows when there is one.
|
|
22
|
+
*
|
|
23
|
+
* «+ Add child» opens a small sheet for either: a logged-in parent's new child
|
|
24
|
+
* is created by the caller (`onAddChild` resolves once it exists), a guest's
|
|
25
|
+
* lives only in this booking.
|
|
26
|
+
*
|
|
27
|
+
* SEMANTICS. The chips are a radio group (one answer of several); the cards
|
|
28
|
+
* are checkboxes in a fieldset whose legend says the rule. At the limit an
|
|
29
|
+
* unticked card is `aria-disabled`, not `disabled`: it stays in the tab order
|
|
30
|
+
* and says why it does nothing, instead of vanishing from a screen reader's
|
|
31
|
+
* list of the children.
|
|
32
|
+
*
|
|
33
|
+
* Heights are fixed — cards, chips and the dashed card are one size whatever
|
|
34
|
+
* they hold — so a login that swaps chips for cards, or a child that arrives
|
|
35
|
+
* from the sheet, grows the list by whole rows and moves nothing sideways.
|
|
36
|
+
*/
|
|
37
|
+
export declare const WHO_SCREEN_LABEL_KEYS: readonly ["who.heading", "who.guest.groupLabel", "who.guest.children.one", "who.guest.children.other", "who.guest.adult", "who.guest.addedList", "who.family.legend", "who.family.self", "who.family.limit", "addChild.trigger", "addChild.title", "addChild.description.saved", "addChild.description.local", "addChild.name", "addChild.birthYear", "addChild.birthYearPlaceholder", "addChild.birthMonth", "addChild.birthMonthUnknown", "addChild.notes", "addChild.missing", "addChild.submit", "addChild.close"];
|
|
38
|
+
export type WhoScreenLabels = Record<(typeof WHO_SCREEN_LABEL_KEYS)[number], string> & AddChildSheetLabels;
|
|
39
|
+
/**
|
|
40
|
+
* - `root` — the `<section>`
|
|
41
|
+
* - `heading` — the `<h2>`
|
|
42
|
+
* - `chip` / `chipSelected` — a guest chip / the pressed one
|
|
43
|
+
* - `card` / `cardSelected` — a person card (and the «myself» row) / a ticked one
|
|
44
|
+
* - `limit` — the always-mounted limit sentence
|
|
45
|
+
*/
|
|
46
|
+
export type WhoScreenSlot = 'root' | 'heading' | 'chip' | 'chipSelected' | 'card' | 'cardSelected' | 'limit';
|
|
47
|
+
/** One guest chip: the seats it stands for. */
|
|
48
|
+
export interface WhoGuestChoice {
|
|
49
|
+
key: string;
|
|
50
|
+
/** The seats this chip answers with (e.g. two anonymous child seats). */
|
|
51
|
+
people: WizardPerson[];
|
|
52
|
+
/**
|
|
53
|
+
* The chip's text. Absent: an all-adult choice reads `who.guest.adult`,
|
|
54
|
+
* anything else `who.guest.children.one|other` with `{count}`.
|
|
55
|
+
*/
|
|
56
|
+
label?: string;
|
|
57
|
+
}
|
|
58
|
+
/** A person the screen draws as a card, with its display line precomputed. */
|
|
59
|
+
export interface WhoPersonEntry {
|
|
60
|
+
/** The seat the card ticks (its `key` identifies it; `name` is shown). */
|
|
61
|
+
person: WizardPerson;
|
|
62
|
+
/** «7 years · Last: Kids' cut, 12 Aug», or the age alone, or `''`. */
|
|
63
|
+
line: string;
|
|
64
|
+
}
|
|
65
|
+
export interface PersonCardProps {
|
|
66
|
+
name: string;
|
|
67
|
+
line: string;
|
|
68
|
+
/** Position in the list — the default card picks an avatar tint from it. */
|
|
69
|
+
index: number;
|
|
70
|
+
selected: boolean;
|
|
71
|
+
/** At the limit: announced and inert, but still focusable (`aria-disabled`). */
|
|
72
|
+
disabled: boolean;
|
|
73
|
+
describedBy?: string;
|
|
74
|
+
onToggle: () => void;
|
|
75
|
+
className?: string;
|
|
76
|
+
selectedClassName?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface WhoScreenComponents {
|
|
79
|
+
PersonCard?: ComponentType<PersonCardProps>;
|
|
80
|
+
}
|
|
81
|
+
export interface WhoScreenProps {
|
|
82
|
+
labels: WhoScreenLabels;
|
|
83
|
+
format: BookingFormat;
|
|
84
|
+
/** The machine's current answer. */
|
|
85
|
+
people: WizardPerson[];
|
|
86
|
+
/** The logged-in parent's children, in profile order; `null` for a guest. */
|
|
87
|
+
family: WhoPersonEntry[] | null;
|
|
88
|
+
/** A guest's chips, in display order. */
|
|
89
|
+
guestChoices: WhoGuestChoice[];
|
|
90
|
+
/**
|
|
91
|
+
* Children a guest named in the sheet (they live only in this booking),
|
|
92
|
+
* drawn as ticked cards under the chips. Ignored for a family.
|
|
93
|
+
*/
|
|
94
|
+
addedChildren?: WhoPersonEntry[];
|
|
95
|
+
/** The most people one booking may seat. */
|
|
96
|
+
maxPeople: number;
|
|
97
|
+
/** The key of the «myself (adult)» seat on a family's list. */
|
|
98
|
+
selfKey: string;
|
|
99
|
+
/**
|
|
100
|
+
* A seat only a guest's shape can draw (a chip's anonymous seat, a guest's
|
|
101
|
+
* named child, the guest «adult»). Under a family such a seat is one nobody
|
|
102
|
+
* can see or untick, so it never counts towards the limit and never
|
|
103
|
+
* survives the next tap. Default: none.
|
|
104
|
+
*/
|
|
105
|
+
isGuestSeat?: (person: WizardPerson) => boolean;
|
|
106
|
+
onChoose: (people: WizardPerson[], advance: boolean) => void;
|
|
107
|
+
/**
|
|
108
|
+
* Save a new child. Logged in: creates the person and resolves once they
|
|
109
|
+
* exist (or with a sentence for the sheet); a guest's is local.
|
|
110
|
+
*/
|
|
111
|
+
onAddChild: (child: NewChild) => Promise<SaveResult>;
|
|
112
|
+
/** Shown under the heading for a guest — e.g. an offer to log in. */
|
|
113
|
+
loginRow?: ReactNode;
|
|
114
|
+
/** Newest birth year the sheet offers (see `AddChildSheet`). */
|
|
115
|
+
currentYear?: number;
|
|
116
|
+
classNames?: SlotClassNames<WhoScreenSlot>;
|
|
117
|
+
components?: WhoScreenComponents;
|
|
118
|
+
}
|
|
119
|
+
export declare function WhoScreen({ labels, format, people, family, guestChoices, addedChildren, maxPeople, selfKey, isGuestSeat, onChoose, onAddChild, loginRow, currentYear, classNames, components, }: WhoScreenProps): import("react").JSX.Element;
|
|
120
|
+
/** The default person card: an initial on a tinted avatar, the name, a line, a tick. */
|
|
121
|
+
export declare function DefaultPersonCard({ name, line, index, selected, disabled, describedBy, onToggle, className, selectedClassName, }: PersonCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { useRef } from 'react';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
import { ADD_CHILD_SHEET_LABEL_KEYS, AddChildSheet, } from './add-child-sheet.js';
|
|
7
|
+
import { fillLabel } from './labels.js';
|
|
8
|
+
import { slotClass } from './slots.js';
|
|
9
|
+
/**
|
|
10
|
+
* The first step — who is the visit for?
|
|
11
|
+
*
|
|
12
|
+
* Presentational, like the other steps: every answer leaves as `onChoose` and
|
|
13
|
+
* the caller's machine decides what it means.
|
|
14
|
+
*
|
|
15
|
+
* TWO SHAPES, one question:
|
|
16
|
+
*
|
|
17
|
+
* - **A guest** gets a row of chips (e.g. «1 child · 2 children · 3 children ·
|
|
18
|
+
* Adult») and a chip is the whole answer: one tap, and the next step is on
|
|
19
|
+
* screen. Nothing about the child is asked here.
|
|
20
|
+
* - **A logged-in parent** gets their children as cards — initial, name, a
|
|
21
|
+
* line (age on the appointment day, last visit) — to tick, up to
|
|
22
|
+
* `maxPeople`, plus a «myself (adult)» row, and moves on with the wizard's
|
|
23
|
+
* own «next». Ticking is a live answer to the machine, so the sticky bar
|
|
24
|
+
* knows when there is one.
|
|
25
|
+
*
|
|
26
|
+
* «+ Add child» opens a small sheet for either: a logged-in parent's new child
|
|
27
|
+
* is created by the caller (`onAddChild` resolves once it exists), a guest's
|
|
28
|
+
* lives only in this booking.
|
|
29
|
+
*
|
|
30
|
+
* SEMANTICS. The chips are a radio group (one answer of several); the cards
|
|
31
|
+
* are checkboxes in a fieldset whose legend says the rule. At the limit an
|
|
32
|
+
* unticked card is `aria-disabled`, not `disabled`: it stays in the tab order
|
|
33
|
+
* and says why it does nothing, instead of vanishing from a screen reader's
|
|
34
|
+
* list of the children.
|
|
35
|
+
*
|
|
36
|
+
* Heights are fixed — cards, chips and the dashed card are one size whatever
|
|
37
|
+
* they hold — so a login that swaps chips for cards, or a child that arrives
|
|
38
|
+
* from the sheet, grows the list by whole rows and moves nothing sideways.
|
|
39
|
+
*/
|
|
40
|
+
export const WHO_SCREEN_LABEL_KEYS = [
|
|
41
|
+
'who.heading',
|
|
42
|
+
'who.guest.groupLabel',
|
|
43
|
+
'who.guest.children.one',
|
|
44
|
+
'who.guest.children.other',
|
|
45
|
+
'who.guest.adult',
|
|
46
|
+
'who.guest.addedList',
|
|
47
|
+
'who.family.legend',
|
|
48
|
+
'who.family.self',
|
|
49
|
+
'who.family.limit',
|
|
50
|
+
...ADD_CHILD_SHEET_LABEL_KEYS,
|
|
51
|
+
];
|
|
52
|
+
/** The tints the avatars take, in order. */
|
|
53
|
+
const AVATAR_TINTS = ['bg-secondary/30', 'bg-primary/10', 'bg-accent'];
|
|
54
|
+
const HEADING_ID = 'booking-who-heading';
|
|
55
|
+
const LIMIT_ID = 'booking-who-limit';
|
|
56
|
+
const noGuestSeats = () => false;
|
|
57
|
+
const ARROW_STEPS = {
|
|
58
|
+
ArrowRight: 1,
|
|
59
|
+
ArrowDown: 1,
|
|
60
|
+
ArrowLeft: -1,
|
|
61
|
+
ArrowUp: -1,
|
|
62
|
+
};
|
|
63
|
+
function choiceLabel(choice, labels) {
|
|
64
|
+
if (choice.label !== undefined)
|
|
65
|
+
return choice.label;
|
|
66
|
+
if (choice.people.length > 0 && choice.people.every((person) => person.adult)) {
|
|
67
|
+
return labels['who.guest.adult'];
|
|
68
|
+
}
|
|
69
|
+
const count = choice.people.length;
|
|
70
|
+
return fillLabel(labels[count === 1 ? 'who.guest.children.one' : 'who.guest.children.other'], {
|
|
71
|
+
count,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function choicePressed(people, choice) {
|
|
75
|
+
const wanted = choice.people;
|
|
76
|
+
return (people.length === wanted.length &&
|
|
77
|
+
people.every((person, index) => person.key === wanted[index]?.key));
|
|
78
|
+
}
|
|
79
|
+
export function WhoScreen({ labels, format, people, family, guestChoices, addedChildren = [], maxPeople, selfKey, isGuestSeat = noGuestSeats, onChoose, onAddChild, loginRow, currentYear, classNames, components, }) {
|
|
80
|
+
const PersonCard = components?.PersonCard ?? DefaultPersonCard;
|
|
81
|
+
const heading = (_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['who.heading'] }));
|
|
82
|
+
const sheet = (disabled, saveNotes) => (_jsx(AddChildSheet, { labels: labels, format: format, saveNotes: saveNotes, disabled: disabled, currentYear: currentYear, onSave: onAddChild }));
|
|
83
|
+
const cardClasses = {
|
|
84
|
+
className: classNames?.card,
|
|
85
|
+
selectedClassName: classNames?.cardSelected,
|
|
86
|
+
};
|
|
87
|
+
if (family === null) {
|
|
88
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, loginRow, _jsxs("div", { className: "space-y-4", children: [_jsx(GuestChips, { choices: guestChoices, people: people, labels: labels, classNames: classNames, onChoose: onChoose }), addedChildren.length > 0 && (_jsx("ul", { "aria-label": labels['who.guest.addedList'], className: "space-y-2", children: addedChildren.map((entry, index) => (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: true, disabled: false, onToggle: () => onChoose(people.filter((other) => other.key !== entry.person.key), false), ...cardClasses }) }, entry.person.key))) })), sheet(addedChildren.length >= maxPeople, false)] })] }));
|
|
89
|
+
}
|
|
90
|
+
// Defensively only what this picker draws: the caller clears guest seats
|
|
91
|
+
// when the family appears, and a seat nobody can see must never count
|
|
92
|
+
// towards the limit nor survive the next tap.
|
|
93
|
+
const seated = people.filter((person) => !isGuestSeat(person));
|
|
94
|
+
const chosen = new Set(seated.map((person) => person.key));
|
|
95
|
+
const full = seated.length >= maxPeople;
|
|
96
|
+
const self = { key: selfKey, adult: true };
|
|
97
|
+
const selfChecked = chosen.has(selfKey);
|
|
98
|
+
function toggle(person) {
|
|
99
|
+
if (chosen.has(person.key)) {
|
|
100
|
+
onChoose(seated.filter((other) => other.key !== person.key), false);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (full)
|
|
104
|
+
return;
|
|
105
|
+
onChoose([...seated, person], false);
|
|
106
|
+
}
|
|
107
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, _jsxs("fieldset", { className: "space-y-4", children: [_jsx("legend", { className: "mb-2 text-sm text-muted-foreground", children: fillLabel(labels['who.family.legend'], { max: maxPeople }) }), _jsxs("ul", { className: "space-y-2", children: [family.map((entry, index) => {
|
|
108
|
+
const selected = chosen.has(entry.person.key);
|
|
109
|
+
return (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: selected, disabled: !selected && full, describedBy: !selected && full ? LIMIT_ID : undefined, onToggle: () => toggle(entry.person), ...cardClasses }) }, entry.person.key));
|
|
110
|
+
}), _jsx("li", { children: sheet(full, true) })] }), _jsxs(CheckRow, { checked: selfChecked, disabled: !selfChecked && full, describedBy: !selfChecked && full ? LIMIT_ID : undefined, onToggle: () => toggle(self), className: cn('h-14 justify-between px-5 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsx("span", { className: "font-medium", children: labels['who.family.self'] }), _jsx(CheckCircle, { checked: selfChecked })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? fillLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] })] }));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The guest chips as a radio group. Roving focus: one chip is in the tab
|
|
114
|
+
* order (the chosen one, else the first) and the arrows move between them.
|
|
115
|
+
* The arrows move focus WITHOUT choosing — a chip is a whole answer that
|
|
116
|
+
* moves the wizard on, and an arrow key that jumped to the next step would
|
|
117
|
+
* take the visitor somewhere they only glanced at.
|
|
118
|
+
*/
|
|
119
|
+
function GuestChips({ choices, people, labels, classNames, onChoose, }) {
|
|
120
|
+
const refs = useRef([]);
|
|
121
|
+
const checked = choices.findIndex((choice) => choicePressed(people, choice));
|
|
122
|
+
const tabStop = checked === -1 ? 0 : checked;
|
|
123
|
+
function onKeyDown(event, index) {
|
|
124
|
+
const step = ARROW_STEPS[event.key];
|
|
125
|
+
let target;
|
|
126
|
+
if (event.key === 'Home')
|
|
127
|
+
target = 0;
|
|
128
|
+
else if (event.key === 'End')
|
|
129
|
+
target = choices.length - 1;
|
|
130
|
+
else if (step !== undefined)
|
|
131
|
+
target = (index + step + choices.length) % choices.length;
|
|
132
|
+
else
|
|
133
|
+
return;
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
refs.current[target]?.focus();
|
|
136
|
+
}
|
|
137
|
+
return (_jsx("div", { role: "radiogroup", "aria-label": labels['who.guest.groupLabel'], className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: choices.map((choice, index) => {
|
|
138
|
+
const pressed = index === checked;
|
|
139
|
+
return (_jsx("button", { ref: (element) => {
|
|
140
|
+
refs.current[index] = element;
|
|
141
|
+
}, type: "button", role: "radio", "aria-checked": pressed, tabIndex: index === tabStop ? 0 : -1, onKeyDown: (event) => onKeyDown(event, index), onClick: () => onChoose(choice.people, true), className: cn('flex h-12 items-center justify-center rounded-full border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', pressed
|
|
142
|
+
? 'border-primary bg-primary/10 font-bold ring-2 ring-primary'
|
|
143
|
+
: 'border-border bg-card font-medium hover:border-primary', classNames?.chip, pressed && classNames?.chipSelected), children: choiceLabel(choice, labels) }, choice.key));
|
|
144
|
+
}) }));
|
|
145
|
+
}
|
|
146
|
+
function CheckCircle({ checked }) {
|
|
147
|
+
return (_jsx("span", { "aria-hidden": "true", className: cn('flex size-6 shrink-0 items-center justify-center rounded-full border', checked ? 'border-primary bg-primary text-primary-foreground' : 'border-border'), children: checked && _jsx(Check, { className: "size-4" }) }));
|
|
148
|
+
}
|
|
149
|
+
/** The default person card: an initial on a tinted avatar, the name, a line, a tick. */
|
|
150
|
+
export function DefaultPersonCard({ name, line, index, selected, disabled, describedBy, onToggle, className, selectedClassName, }) {
|
|
151
|
+
return (_jsxs(CheckRow, { checked: selected, disabled: disabled, describedBy: describedBy, onToggle: onToggle, className: cn('h-[4.5rem] gap-3 px-4', className), selectedClassName: selectedClassName, children: [_jsx("span", { "aria-hidden": "true", className: cn('flex size-11 shrink-0 items-center justify-center rounded-full font-sans text-lg font-bold', AVATAR_TINTS[index % AVATAR_TINTS.length]), children: name.trim().charAt(0).toUpperCase() }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate font-semibold", children: name }), _jsx("span", { className: "block truncate text-sm text-muted-foreground", children: line })] }), _jsx(CheckCircle, { checked: selected })] }));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A card that IS a checkbox: a native one, visually hidden, inside the label
|
|
155
|
+
* the whole card is — so the name is the card's text, Space toggles it, and
|
|
156
|
+
* nothing re-implements what the browser already does. At the limit it is
|
|
157
|
+
* `aria-disabled` rather than `disabled`: focusable, announced, inert.
|
|
158
|
+
*/
|
|
159
|
+
function CheckRow({ checked, disabled, describedBy, onToggle, className, selectedClassName, children, }) {
|
|
160
|
+
return (_jsxs("label", { className: cn('flex w-full cursor-pointer items-center rounded-lg border text-left transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary', checked ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-primary', disabled && 'cursor-not-allowed opacity-50 hover:border-border', className, checked && selectedClassName), children: [_jsx("input", { type: "checkbox", className: "sr-only", checked: checked, "aria-disabled": disabled || undefined, "aria-describedby": describedBy, onChange: () => {
|
|
161
|
+
if (!disabled)
|
|
162
|
+
onToggle();
|
|
163
|
+
} }), children] }));
|
|
164
|
+
}
|