@devpablocristo/modules-scheduling 0.4.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.
@@ -0,0 +1,551 @@
1
+ import { useEffect, useState, type FormEvent } from 'react';
2
+ import { confirmAction } from '@devpablocristo/core-browser';
3
+ import { formatSchedulingDateTime, formatSchedulingWeekdayNarrow } from './locale';
4
+ import type { Booking, SchedulingCalendarCopy, Service, TimeSlot } from './types';
5
+
6
+ export type SchedulingBookingRecurrenceDraft = {
7
+ mode: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
8
+ frequency: 'daily' | 'weekly' | 'monthly';
9
+ interval: string;
10
+ count: string;
11
+ byWeekday: number[];
12
+ };
13
+
14
+ export type SchedulingBookingDraft = {
15
+ title: string;
16
+ customerName: string;
17
+ customerPhone: string;
18
+ customerEmail: string;
19
+ notes: string;
20
+ recurrence: SchedulingBookingRecurrenceDraft;
21
+ };
22
+
23
+ export type SchedulingBookingCreateEditor = {
24
+ date: string;
25
+ startTime: string;
26
+ endTime: string;
27
+ resourceId: string;
28
+ };
29
+
30
+ export type SchedulingBookingCreateResourceOption = {
31
+ id: string;
32
+ name: string;
33
+ timezone?: string;
34
+ };
35
+
36
+ export type SchedulingBookingModalState =
37
+ | {
38
+ open: false;
39
+ }
40
+ | {
41
+ open: true;
42
+ mode: 'create';
43
+ slot: TimeSlot | null;
44
+ slotOptions: TimeSlot[];
45
+ resourceOptions: SchedulingBookingCreateResourceOption[];
46
+ editor: SchedulingBookingCreateEditor;
47
+ validationMessage?: string | null;
48
+ service?: Service;
49
+ resourceName?: string;
50
+ draft: SchedulingBookingDraft;
51
+ }
52
+ | {
53
+ open: true;
54
+ mode: 'details';
55
+ booking: Booking;
56
+ service?: Service;
57
+ resourceName?: string;
58
+ };
59
+
60
+ export type SchedulingBookingAction =
61
+ | 'confirm'
62
+ | 'cancel'
63
+ | 'check_in'
64
+ | 'start_service'
65
+ | 'complete'
66
+ | 'no_show';
67
+
68
+ type Props = {
69
+ state: SchedulingBookingModalState;
70
+ copy: SchedulingCalendarCopy;
71
+ locale?: string;
72
+ saving?: boolean;
73
+ slotLoading?: boolean;
74
+ onClose: () => void;
75
+ onEditorChange?: (editor: Partial<SchedulingBookingCreateEditor>) => void;
76
+ onCreate: (draft: SchedulingBookingDraft) => Promise<void> | void;
77
+ onAction: (action: SchedulingBookingAction, booking: Booking) => Promise<void> | void;
78
+ };
79
+
80
+ function actionButtons(status: Booking['status']): SchedulingBookingAction[] {
81
+ switch (status) {
82
+ case 'hold':
83
+ case 'pending_confirmation':
84
+ return ['confirm', 'cancel'];
85
+ case 'confirmed':
86
+ return ['check_in', 'cancel', 'no_show'];
87
+ case 'checked_in':
88
+ return ['start_service', 'cancel'];
89
+ case 'in_service':
90
+ return ['complete'];
91
+ default:
92
+ return [];
93
+ }
94
+ }
95
+
96
+ function actionLabel(copy: SchedulingCalendarCopy, action: SchedulingBookingAction): string {
97
+ switch (action) {
98
+ case 'confirm':
99
+ return copy.confirmBooking;
100
+ case 'cancel':
101
+ return copy.cancelBooking;
102
+ case 'check_in':
103
+ return copy.checkInBooking;
104
+ case 'start_service':
105
+ return copy.startService;
106
+ case 'complete':
107
+ return copy.completeBooking;
108
+ case 'no_show':
109
+ return copy.noShowBooking;
110
+ }
111
+ }
112
+
113
+ function defaultRecurrenceDraft(): SchedulingBookingRecurrenceDraft {
114
+ return {
115
+ mode: 'none',
116
+ frequency: 'weekly',
117
+ interval: '1',
118
+ count: '8',
119
+ byWeekday: [],
120
+ };
121
+ }
122
+
123
+ export function SchedulingBookingModal({
124
+ state,
125
+ copy,
126
+ locale,
127
+ saving = false,
128
+ slotLoading = false,
129
+ onClose,
130
+ onEditorChange,
131
+ onCreate,
132
+ onAction,
133
+ }: Props) {
134
+ const [draft, setDraft] = useState<SchedulingBookingDraft>({
135
+ title: '',
136
+ customerName: '',
137
+ customerPhone: '',
138
+ customerEmail: '',
139
+ notes: '',
140
+ recurrence: defaultRecurrenceDraft(),
141
+ });
142
+
143
+ const closeWithGuard = async () => {
144
+ if (saving) {
145
+ return;
146
+ }
147
+ if (state.open && state.mode === 'create') {
148
+ const dirty =
149
+ draft.title.trim() !== state.draft.title.trim() ||
150
+ draft.customerName.trim() !== state.draft.customerName.trim() ||
151
+ draft.customerPhone.trim() !== state.draft.customerPhone.trim() ||
152
+ draft.customerEmail.trim() !== state.draft.customerEmail.trim() ||
153
+ draft.notes.trim() !== state.draft.notes.trim() ||
154
+ JSON.stringify(draft.recurrence) !== JSON.stringify(state.draft.recurrence);
155
+ if (dirty) {
156
+ const confirmed = await confirmAction({
157
+ title: copy.closeDirtyTitle,
158
+ description: copy.closeDirtyDescription,
159
+ confirmLabel: copy.discard,
160
+ cancelLabel: copy.keepEditing,
161
+ tone: 'danger',
162
+ });
163
+ if (!confirmed) {
164
+ return;
165
+ }
166
+ }
167
+ }
168
+ onClose();
169
+ };
170
+
171
+ useEffect(() => {
172
+ if (state.open && state.mode === 'create') {
173
+ setDraft(state.draft);
174
+ }
175
+ }, [state]);
176
+
177
+ useEffect(() => {
178
+ if (!state.open) {
179
+ return;
180
+ }
181
+
182
+ const onKeyDown = (event: KeyboardEvent) => {
183
+ if (event.key !== 'Escape') {
184
+ return;
185
+ }
186
+ event.preventDefault();
187
+ void closeWithGuard();
188
+ };
189
+
190
+ window.addEventListener('keydown', onKeyDown);
191
+ return () => window.removeEventListener('keydown', onKeyDown);
192
+ }, [state.open, closeWithGuard]);
193
+
194
+ if (!state.open) {
195
+ return null;
196
+ }
197
+
198
+ const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
199
+ event.preventDefault();
200
+ if (state.open && state.mode === 'create' && !state.slot) {
201
+ return;
202
+ }
203
+ await onCreate(draft);
204
+ };
205
+
206
+ const renderCreateSummary = () => {
207
+ if (!(state.open && state.mode === 'create')) {
208
+ return null;
209
+ }
210
+
211
+ const selectedSlot = state.slot;
212
+ const activeFrequency = draft.recurrence.mode === 'custom' ? draft.recurrence.frequency : draft.recurrence.mode;
213
+
214
+ return (
215
+ <div className="modules-scheduling__creator-grid">
216
+ <div className="modules-scheduling__creator-main">
217
+ <div className="form-group">
218
+ <label htmlFor="scheduling-booking-title">{copy.titleLabel}</label>
219
+ <input
220
+ id="scheduling-booking-title"
221
+ value={draft.title}
222
+ onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
223
+ autoFocus
224
+ />
225
+ </div>
226
+
227
+ <div className="modules-scheduling__form-row">
228
+ <div className="form-group grow">
229
+ <label htmlFor="scheduling-slot-date">{copy.focusDateLabel}</label>
230
+ <input
231
+ id="scheduling-slot-date"
232
+ type="date"
233
+ value={state.editor.date}
234
+ onChange={(event) => onEditorChange?.({ date: event.target.value })}
235
+ />
236
+ </div>
237
+ <div className="form-group grow">
238
+ <label htmlFor="scheduling-slot-resource">{copy.resourceNameLabel}</label>
239
+ <select
240
+ id="scheduling-slot-resource"
241
+ value={state.editor.resourceId}
242
+ onChange={(event) => onEditorChange?.({ resourceId: event.target.value })}
243
+ >
244
+ {state.resourceOptions.map((resource) => (
245
+ <option key={resource.id} value={resource.id}>
246
+ {resource.name}
247
+ </option>
248
+ ))}
249
+ </select>
250
+ </div>
251
+ </div>
252
+
253
+ <div className="modules-scheduling__form-row">
254
+ <div className="form-group grow">
255
+ <label htmlFor="scheduling-slot-start">{copy.slotStartLabel}</label>
256
+ <input
257
+ id="scheduling-slot-start"
258
+ type="time"
259
+ step={60 * ((selectedSlot?.granularity_minutes ?? state.service?.slot_granularity_minutes ?? 30))}
260
+ value={state.editor.startTime}
261
+ onChange={(event) => onEditorChange?.({ startTime: event.target.value })}
262
+ />
263
+ </div>
264
+ <div className="form-group grow">
265
+ <label htmlFor="scheduling-slot-end">{copy.slotEndLabel}</label>
266
+ <input
267
+ id="scheduling-slot-end"
268
+ type="time"
269
+ step={60 * ((selectedSlot?.granularity_minutes ?? state.service?.slot_granularity_minutes ?? 30))}
270
+ value={state.editor.endTime}
271
+ onChange={(event) => onEditorChange?.({ endTime: event.target.value })}
272
+ />
273
+ </div>
274
+ </div>
275
+
276
+ <div className="modules-scheduling__form-row">
277
+ <div className="form-group grow">
278
+ <label htmlFor="scheduling-repeat-mode">{copy.repeatLabel}</label>
279
+ <select
280
+ id="scheduling-repeat-mode"
281
+ value={draft.recurrence.mode}
282
+ onChange={(event) =>
283
+ setDraft((current) => ({
284
+ ...current,
285
+ recurrence: {
286
+ ...current.recurrence,
287
+ mode: event.target.value as SchedulingBookingRecurrenceDraft['mode'],
288
+ },
289
+ }))
290
+ }
291
+ >
292
+ <option value="none">{copy.repeatNever}</option>
293
+ <option value="daily">{copy.repeatDaily}</option>
294
+ <option value="weekly">{copy.repeatWeekly}</option>
295
+ <option value="monthly">{copy.repeatMonthly}</option>
296
+ <option value="custom">{copy.repeatCustom}</option>
297
+ </select>
298
+ </div>
299
+ {draft.recurrence.mode !== 'none' ? (
300
+ <div className="form-group grow">
301
+ <label htmlFor="scheduling-repeat-interval">{copy.repeatIntervalLabel}</label>
302
+ <input
303
+ id="scheduling-repeat-interval"
304
+ type="number"
305
+ min={1}
306
+ max={60}
307
+ value={draft.recurrence.interval}
308
+ onChange={(event) =>
309
+ setDraft((current) => ({
310
+ ...current,
311
+ recurrence: {
312
+ ...current.recurrence,
313
+ interval: event.target.value,
314
+ },
315
+ }))
316
+ }
317
+ />
318
+ </div>
319
+ ) : null}
320
+ {draft.recurrence.mode !== 'none' ? (
321
+ <div className="form-group grow">
322
+ <label htmlFor="scheduling-repeat-count">{copy.repeatCountLabel}</label>
323
+ <input
324
+ id="scheduling-repeat-count"
325
+ type="number"
326
+ min={1}
327
+ max={60}
328
+ value={draft.recurrence.count}
329
+ onChange={(event) =>
330
+ setDraft((current) => ({
331
+ ...current,
332
+ recurrence: {
333
+ ...current.recurrence,
334
+ count: event.target.value,
335
+ },
336
+ }))
337
+ }
338
+ />
339
+ </div>
340
+ ) : null}
341
+ </div>
342
+
343
+ {draft.recurrence.mode === 'custom' ? (
344
+ <div className="form-group">
345
+ <label htmlFor="scheduling-repeat-frequency">{copy.repeatFrequencyLabel}</label>
346
+ <select
347
+ id="scheduling-repeat-frequency"
348
+ value={draft.recurrence.frequency}
349
+ onChange={(event) =>
350
+ setDraft((current) => ({
351
+ ...current,
352
+ recurrence: {
353
+ ...current.recurrence,
354
+ frequency: event.target.value as SchedulingBookingRecurrenceDraft['frequency'],
355
+ },
356
+ }))
357
+ }
358
+ >
359
+ <option value="daily">{copy.repeatDaily}</option>
360
+ <option value="weekly">{copy.repeatWeekly}</option>
361
+ <option value="monthly">{copy.repeatMonthly}</option>
362
+ </select>
363
+ </div>
364
+ ) : null}
365
+
366
+ {draft.recurrence.mode !== 'none' && activeFrequency === 'weekly' ? (
367
+ <div className="form-group">
368
+ <label>{copy.repeatWeekdaysLabel}</label>
369
+ <div className="modules-scheduling__weekday-picker">
370
+ {[0, 1, 2, 3, 4, 5, 6].map((weekday) => {
371
+ const active = draft.recurrence.byWeekday.includes(weekday);
372
+ return (
373
+ <button
374
+ key={weekday}
375
+ type="button"
376
+ className={`modules-scheduling__weekday-btn ${active ? 'modules-scheduling__weekday-btn--active' : ''}`}
377
+ onClick={() =>
378
+ setDraft((current) => ({
379
+ ...current,
380
+ recurrence: {
381
+ ...current.recurrence,
382
+ byWeekday: active
383
+ ? current.recurrence.byWeekday.filter((item) => item !== weekday)
384
+ : [...current.recurrence.byWeekday, weekday].sort((left, right) => left - right),
385
+ },
386
+ }))
387
+ }
388
+ >
389
+ {formatSchedulingWeekdayNarrow(weekday, locale)}
390
+ </button>
391
+ );
392
+ })}
393
+ </div>
394
+ </div>
395
+ ) : null}
396
+
397
+ {slotLoading ? <div className="modules-scheduling__validation-message">{copy.availableSlotLoading}</div> : null}
398
+ {!slotLoading && state.validationMessage ? (
399
+ <div className="modules-scheduling__validation-message">{state.validationMessage}</div>
400
+ ) : null}
401
+
402
+ <div className="form-group">
403
+ <label htmlFor="scheduling-customer-name">{copy.customerNameLabel}</label>
404
+ <input
405
+ id="scheduling-customer-name"
406
+ value={draft.customerName}
407
+ onChange={(event) => setDraft((current) => ({ ...current, customerName: event.target.value }))}
408
+ required
409
+ autoComplete="name"
410
+ />
411
+ </div>
412
+
413
+ <div className="modules-scheduling__form-row">
414
+ <div className="form-group grow">
415
+ <label htmlFor="scheduling-customer-phone">{copy.customerPhoneLabel}</label>
416
+ <input
417
+ id="scheduling-customer-phone"
418
+ type="tel"
419
+ value={draft.customerPhone}
420
+ onChange={(event) => setDraft((current) => ({ ...current, customerPhone: event.target.value }))}
421
+ required
422
+ autoComplete="tel"
423
+ />
424
+ </div>
425
+ <div className="form-group grow">
426
+ <label htmlFor="scheduling-customer-email">{copy.customerEmailLabel}</label>
427
+ <input
428
+ id="scheduling-customer-email"
429
+ type="email"
430
+ value={draft.customerEmail}
431
+ onChange={(event) => setDraft((current) => ({ ...current, customerEmail: event.target.value }))}
432
+ autoComplete="email"
433
+ />
434
+ </div>
435
+ </div>
436
+
437
+ <div className="form-group">
438
+ <label htmlFor="scheduling-notes">{copy.notesLabel}</label>
439
+ <textarea
440
+ id="scheduling-notes"
441
+ rows={4}
442
+ value={draft.notes}
443
+ onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))}
444
+ />
445
+ </div>
446
+ </div>
447
+ </div>
448
+ );
449
+ };
450
+
451
+ return (
452
+ <div className="modules-scheduling__backdrop app-modal-backdrop" role="presentation" onClick={() => void closeWithGuard()}>
453
+ <div className="modules-scheduling__modal app-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
454
+ <div className="app-modal__header">
455
+ {state.mode === 'details' ? (
456
+ <div className="app-modal__title-block">
457
+ <p className="app-modal__eyebrow">{copy.bookingSubtitleDetails}</p>
458
+ <h3 className="app-modal__title">{copy.bookingTitleDetails}</h3>
459
+ <p className="app-modal__subtitle">
460
+ {`${formatSchedulingDateTime(state.booking.start_at, locale)} • ${state.resourceName ?? state.booking.resource_id}`}
461
+ </p>
462
+ </div>
463
+ ) : (
464
+ <div className="app-modal__footer-spacer" aria-hidden />
465
+ )}
466
+ <button type="button" className="app-modal__close" aria-label={copy.close} onClick={() => void closeWithGuard()}>
467
+ ×
468
+ </button>
469
+ </div>
470
+
471
+ {state.mode === 'create' ? (
472
+ <form className="app-modal__body modules-scheduling__modal-form" onSubmit={handleCreate}>
473
+ {renderCreateSummary()}
474
+
475
+ <div className="app-modal__footer">
476
+ <div className="app-modal__footer-spacer" aria-hidden />
477
+ <button type="button" className="btn-secondary btn-sm app-modal__action" disabled={saving} onClick={() => void closeWithGuard()}>
478
+ {copy.close}
479
+ </button>
480
+ <button type="submit" className="btn-primary btn-sm app-modal__action" disabled={saving || slotLoading || !state.slot}>
481
+ {saving ? copy.saving : copy.create}
482
+ </button>
483
+ </div>
484
+ </form>
485
+ ) : (
486
+ <>
487
+ <div className="app-modal__body modules-scheduling__modal-body">
488
+ <div className="modules-scheduling__detail-grid">
489
+ <div className="modules-scheduling__detail">
490
+ <span>{copy.customerNameLabel}</span>
491
+ <strong>{state.booking.customer_name}</strong>
492
+ </div>
493
+ <div className="modules-scheduling__detail">
494
+ <span>{copy.customerPhoneLabel}</span>
495
+ <strong>{state.booking.customer_phone || '—'}</strong>
496
+ </div>
497
+ <div className="modules-scheduling__detail">
498
+ <span>{copy.customerEmailLabel}</span>
499
+ <strong>{state.booking.customer_email || '—'}</strong>
500
+ </div>
501
+ <div className="modules-scheduling__detail">
502
+ <span>{copy.statusLabel}</span>
503
+ <strong>{copy.statuses[state.booking.status]}</strong>
504
+ </div>
505
+ <div className="modules-scheduling__detail">
506
+ <span>{copy.serviceNameLabel}</span>
507
+ <strong>{state.service?.name ?? state.booking.service_id}</strong>
508
+ </div>
509
+ <div className="modules-scheduling__detail">
510
+ <span>{copy.resourceNameLabel}</span>
511
+ <strong>{state.resourceName ?? state.booking.resource_id}</strong>
512
+ </div>
513
+ <div className="modules-scheduling__detail">
514
+ <span>{copy.slotLabel}</span>
515
+ <strong>{formatSchedulingDateTime(state.booking.start_at, locale)}</strong>
516
+ </div>
517
+ <div className="modules-scheduling__detail">
518
+ <span>{copy.referenceLabel}</span>
519
+ <strong>{state.booking.reference}</strong>
520
+ </div>
521
+ </div>
522
+ {state.booking.notes ? (
523
+ <div className="modules-scheduling__notes">
524
+ <span>{copy.notesLabel}</span>
525
+ <p>{state.booking.notes}</p>
526
+ </div>
527
+ ) : null}
528
+ </div>
529
+ <div className="app-modal__footer">
530
+ {actionButtons(state.booking.status).map((action) => (
531
+ <button
532
+ key={action}
533
+ type="button"
534
+ className={action === 'cancel' || action === 'no_show' ? 'btn-danger btn-sm app-modal__action' : 'btn-secondary btn-sm app-modal__action'}
535
+ onClick={() => void onAction(action, state.booking)}
536
+ disabled={saving}
537
+ >
538
+ {actionLabel(copy, action)}
539
+ </button>
540
+ ))}
541
+ <div className="app-modal__footer-spacer" aria-hidden />
542
+ <button type="button" className="btn-secondary btn-sm app-modal__action" disabled={saving} onClick={onClose}>
543
+ {copy.close}
544
+ </button>
545
+ </div>
546
+ </>
547
+ )}
548
+ </div>
549
+ </div>
550
+ );
551
+ }