@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.
- package/package.json +49 -0
- package/src/BlockedRangeModal.tsx +256 -0
- package/src/PublicSchedulingFlow.test.tsx +192 -0
- package/src/PublicSchedulingFlow.tsx +570 -0
- package/src/QueueOperatorBoard.test.tsx +169 -0
- package/src/QueueOperatorBoard.tsx +650 -0
- package/src/SchedulingBookingModal.test.tsx +241 -0
- package/src/SchedulingBookingModal.tsx +551 -0
- package/src/SchedulingCalendar.test.tsx +830 -0
- package/src/SchedulingCalendarBoard.tsx +1596 -0
- package/src/SchedulingDaySummary.test.tsx +136 -0
- package/src/SchedulingDaySummary.tsx +190 -0
- package/src/client.test.ts +105 -0
- package/src/client.ts +300 -0
- package/src/index.ts +66 -0
- package/src/locale.test.ts +36 -0
- package/src/locale.ts +91 -0
- package/src/next.ts +66 -0
- package/src/schedulingCalendarLogic.ts +373 -0
- package/src/styles.css +649 -0
- package/src/styles.next.css +1 -0
- package/src/types.ts +653 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import type { PublicSchedulingClient } from './client';
|
|
4
|
+
import { formatSchedulingDateTime, resolveSchedulingCopyLocale } from './locale';
|
|
5
|
+
import type {
|
|
6
|
+
PublicAvailabilitySlot,
|
|
7
|
+
PublicBooking,
|
|
8
|
+
PublicSchedulingFlowCopy,
|
|
9
|
+
PublicService,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
const publicKeys = {
|
|
13
|
+
info: (orgRef: string) => ['public-scheduling', 'info', orgRef] as const,
|
|
14
|
+
services: (orgRef: string) => ['public-scheduling', 'services', orgRef] as const,
|
|
15
|
+
availability: (orgRef: string, serviceId: string | null, date: string) =>
|
|
16
|
+
['public-scheduling', 'availability', orgRef, serviceId ?? 'none', date] as const,
|
|
17
|
+
myBookings: (orgRef: string, phone: string) => ['public-scheduling', 'my-bookings', orgRef, phone] as const,
|
|
18
|
+
queues: (orgRef: string) => ['public-scheduling', 'queues', orgRef] as const,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const publicSchedulingFlowCopyPresets: Record<'en' | 'es', PublicSchedulingFlowCopy> = {
|
|
22
|
+
en: {
|
|
23
|
+
title: 'Public booking flow',
|
|
24
|
+
description: 'Preview the public booking flow using the same public API contract.',
|
|
25
|
+
orgRefLabel: 'Organization reference',
|
|
26
|
+
orgRefHelp: 'You can use the public slug or the organization UUID.',
|
|
27
|
+
loadOrg: 'Load organization',
|
|
28
|
+
businessInfoTitle: 'Business profile',
|
|
29
|
+
serviceLabel: 'Service',
|
|
30
|
+
dateLabel: 'Date',
|
|
31
|
+
phoneLabel: 'Phone',
|
|
32
|
+
nameLabel: 'Customer name',
|
|
33
|
+
emailLabel: 'Email',
|
|
34
|
+
notesLabel: 'Notes',
|
|
35
|
+
availabilityTitle: 'Availability',
|
|
36
|
+
availabilityDescription: 'Customers book directly from available public slots.',
|
|
37
|
+
availabilityEmpty: 'No public slots available for the selected date.',
|
|
38
|
+
availabilityLoading: 'Loading availability…',
|
|
39
|
+
selectSlot: 'Select slot',
|
|
40
|
+
selectedSlotLabel: 'Selected slot',
|
|
41
|
+
bookNow: 'Book now',
|
|
42
|
+
booking: 'Booking…',
|
|
43
|
+
myBookingsTitle: 'My bookings',
|
|
44
|
+
myBookingsDescription: 'Look up public booking history by phone number.',
|
|
45
|
+
findBookings: 'Find bookings',
|
|
46
|
+
findingBookings: 'Searching…',
|
|
47
|
+
noBookings: 'No public bookings found for that phone number.',
|
|
48
|
+
queuesTitle: 'Remote queues',
|
|
49
|
+
queuesDescription: 'Customers can also join a virtual queue without opening the dashboard.',
|
|
50
|
+
joinQueue: 'Join queue',
|
|
51
|
+
joiningQueue: 'Joining…',
|
|
52
|
+
etaLabel: 'ETA',
|
|
53
|
+
positionLabel: 'Position',
|
|
54
|
+
ticketCodeLabel: 'Ticket',
|
|
55
|
+
publicDisabledTitle: 'Public booking flow is disabled',
|
|
56
|
+
publicDisabledDescription: 'Enable the schedule for this organization to expose the public booking flow.',
|
|
57
|
+
loading: 'Loading public booking flow…',
|
|
58
|
+
bookingCreatedTitle: 'Booking created',
|
|
59
|
+
queueCreatedTitle: 'Queue ticket created',
|
|
60
|
+
confirmBooking: 'Confirm',
|
|
61
|
+
cancelBooking: 'Cancel',
|
|
62
|
+
cancelBookingReason: 'Cancelled from public booking flow',
|
|
63
|
+
statuses: {
|
|
64
|
+
hold: 'On hold',
|
|
65
|
+
pending_confirmation: 'Pending confirmation',
|
|
66
|
+
confirmed: 'Confirmed',
|
|
67
|
+
checked_in: 'Checked in',
|
|
68
|
+
in_service: 'In service',
|
|
69
|
+
completed: 'Completed',
|
|
70
|
+
cancelled: 'Cancelled',
|
|
71
|
+
no_show: 'No-show',
|
|
72
|
+
expired: 'Expired',
|
|
73
|
+
active: 'Active',
|
|
74
|
+
paused: 'Paused',
|
|
75
|
+
closed: 'Closed',
|
|
76
|
+
waiting: 'Waiting',
|
|
77
|
+
called: 'Called',
|
|
78
|
+
serving: 'Serving',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
es: {
|
|
82
|
+
title: 'Reserva pública',
|
|
83
|
+
description: 'Vista previa del flujo público de reservas con el mismo contrato de la API pública.',
|
|
84
|
+
orgRefLabel: 'Referencia de organización',
|
|
85
|
+
orgRefHelp: 'Puede ser el slug público o el UUID de la organización.',
|
|
86
|
+
loadOrg: 'Cargar organización',
|
|
87
|
+
businessInfoTitle: 'Perfil del negocio',
|
|
88
|
+
serviceLabel: 'Servicio',
|
|
89
|
+
dateLabel: 'Fecha',
|
|
90
|
+
phoneLabel: 'Teléfono',
|
|
91
|
+
nameLabel: 'Cliente',
|
|
92
|
+
emailLabel: 'Email',
|
|
93
|
+
notesLabel: 'Notas',
|
|
94
|
+
availabilityTitle: 'Disponibilidad',
|
|
95
|
+
availabilityDescription: 'El cliente reserva directamente desde los slots públicos.',
|
|
96
|
+
availabilityEmpty: 'No hay slots públicos para la fecha seleccionada.',
|
|
97
|
+
availabilityLoading: 'Cargando disponibilidad…',
|
|
98
|
+
selectSlot: 'Elegir slot',
|
|
99
|
+
selectedSlotLabel: 'Slot elegido',
|
|
100
|
+
bookNow: 'Reservar',
|
|
101
|
+
booking: 'Reservando…',
|
|
102
|
+
myBookingsTitle: 'Mis reservas',
|
|
103
|
+
myBookingsDescription: 'Consultar el historial público por número de teléfono.',
|
|
104
|
+
findBookings: 'Buscar reservas',
|
|
105
|
+
findingBookings: 'Buscando…',
|
|
106
|
+
noBookings: 'No hay reservas públicas para ese teléfono.',
|
|
107
|
+
queuesTitle: 'Colas remotas',
|
|
108
|
+
queuesDescription: 'El cliente también puede sumarse a una cola virtual sin entrar al dashboard.',
|
|
109
|
+
joinQueue: 'Sumarme',
|
|
110
|
+
joiningQueue: 'Uniéndome…',
|
|
111
|
+
etaLabel: 'Tiempo estimado',
|
|
112
|
+
positionLabel: 'Posición',
|
|
113
|
+
ticketCodeLabel: 'Ticket',
|
|
114
|
+
publicDisabledTitle: 'La agenda pública está deshabilitada',
|
|
115
|
+
publicDisabledDescription: 'Activá la agenda de esta organización para exponer el flujo público.',
|
|
116
|
+
loading: 'Cargando agenda pública…',
|
|
117
|
+
bookingCreatedTitle: 'Reserva creada',
|
|
118
|
+
queueCreatedTitle: 'Ticket emitido',
|
|
119
|
+
confirmBooking: 'Confirmar',
|
|
120
|
+
cancelBooking: 'Cancelar',
|
|
121
|
+
cancelBookingReason: 'Cancelada desde el flujo público',
|
|
122
|
+
statuses: {
|
|
123
|
+
hold: 'En espera',
|
|
124
|
+
pending_confirmation: 'Pendiente',
|
|
125
|
+
confirmed: 'Confirmada',
|
|
126
|
+
checked_in: 'Check-in',
|
|
127
|
+
in_service: 'En atención',
|
|
128
|
+
completed: 'Completada',
|
|
129
|
+
cancelled: 'Cancelada',
|
|
130
|
+
no_show: 'No-show',
|
|
131
|
+
expired: 'Expirada',
|
|
132
|
+
active: 'Activa',
|
|
133
|
+
paused: 'Pausada',
|
|
134
|
+
closed: 'Cerrada',
|
|
135
|
+
waiting: 'Esperando',
|
|
136
|
+
called: 'Llamado',
|
|
137
|
+
serving: 'Atendiendo',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type PublicSchedulingFlowProps = {
|
|
143
|
+
client: PublicSchedulingClient;
|
|
144
|
+
orgRef: string;
|
|
145
|
+
locale?: string;
|
|
146
|
+
copy?: Partial<PublicSchedulingFlowCopy>;
|
|
147
|
+
className?: string;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type ContactDraft = {
|
|
151
|
+
name: string;
|
|
152
|
+
phone: string;
|
|
153
|
+
email: string;
|
|
154
|
+
notes: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function todayValue(): string {
|
|
158
|
+
return new Date().toISOString().slice(0, 10);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function statusLabel(copy: PublicSchedulingFlowCopy, status: string): string {
|
|
162
|
+
return copy.statuses[status] ?? status;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function PublicSchedulingFlow({
|
|
166
|
+
client,
|
|
167
|
+
orgRef,
|
|
168
|
+
locale = 'en',
|
|
169
|
+
copy: copyOverrides,
|
|
170
|
+
className = '',
|
|
171
|
+
}: PublicSchedulingFlowProps) {
|
|
172
|
+
const copy = { ...publicSchedulingFlowCopyPresets[resolveSchedulingCopyLocale(locale)], ...copyOverrides };
|
|
173
|
+
const queryClient = useQueryClient();
|
|
174
|
+
const [selectedServiceId, setSelectedServiceId] = useState<string>('');
|
|
175
|
+
const [selectedDate, setSelectedDate] = useState(todayValue());
|
|
176
|
+
const [selectedSlot, setSelectedSlot] = useState<PublicAvailabilitySlot | null>(null);
|
|
177
|
+
const [contact, setContact] = useState<ContactDraft>({ name: '', phone: '', email: '', notes: '' });
|
|
178
|
+
const [lookupPhone, setLookupPhone] = useState('');
|
|
179
|
+
const [submittedLookupPhone, setSubmittedLookupPhone] = useState('');
|
|
180
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
181
|
+
const trimmedOrgRef = orgRef.trim();
|
|
182
|
+
|
|
183
|
+
const infoQuery = useQuery({
|
|
184
|
+
queryKey: publicKeys.info(trimmedOrgRef),
|
|
185
|
+
queryFn: () => client.getBusinessInfo(trimmedOrgRef),
|
|
186
|
+
enabled: trimmedOrgRef.length > 0,
|
|
187
|
+
staleTime: 60_000,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const servicesQuery = useQuery({
|
|
191
|
+
queryKey: publicKeys.services(trimmedOrgRef),
|
|
192
|
+
queryFn: () => client.listServices(trimmedOrgRef),
|
|
193
|
+
enabled: trimmedOrgRef.length > 0,
|
|
194
|
+
staleTime: 60_000,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const queuesQuery = useQuery({
|
|
198
|
+
queryKey: publicKeys.queues(trimmedOrgRef),
|
|
199
|
+
queryFn: () => client.listQueues(trimmedOrgRef),
|
|
200
|
+
enabled: trimmedOrgRef.length > 0,
|
|
201
|
+
staleTime: 30_000,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const availabilityQuery = useQuery({
|
|
205
|
+
queryKey: publicKeys.availability(trimmedOrgRef, selectedServiceId || null, selectedDate),
|
|
206
|
+
queryFn: () =>
|
|
207
|
+
client.getAvailability(trimmedOrgRef, {
|
|
208
|
+
serviceId: selectedServiceId || undefined,
|
|
209
|
+
date: selectedDate,
|
|
210
|
+
}),
|
|
211
|
+
enabled: trimmedOrgRef.length > 0 && selectedServiceId.length > 0,
|
|
212
|
+
staleTime: 15_000,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const myBookingsQuery = useQuery({
|
|
216
|
+
queryKey: publicKeys.myBookings(trimmedOrgRef, submittedLookupPhone),
|
|
217
|
+
queryFn: () => client.listMyBookings(trimmedOrgRef, { phone: submittedLookupPhone }),
|
|
218
|
+
enabled: trimmedOrgRef.length > 0 && submittedLookupPhone.trim().length > 0,
|
|
219
|
+
staleTime: 10_000,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (selectedServiceId) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const firstService = servicesQuery.data?.[0];
|
|
227
|
+
if (firstService) {
|
|
228
|
+
setSelectedServiceId(firstService.id);
|
|
229
|
+
}
|
|
230
|
+
}, [servicesQuery.data, selectedServiceId]);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
setSelectedSlot(null);
|
|
234
|
+
}, [selectedDate, selectedServiceId, trimmedOrgRef]);
|
|
235
|
+
|
|
236
|
+
const bookMutation = useMutation({
|
|
237
|
+
mutationFn: async () =>
|
|
238
|
+
client.book(trimmedOrgRef, {
|
|
239
|
+
service_id: selectedServiceId || undefined,
|
|
240
|
+
customer_name: contact.name.trim(),
|
|
241
|
+
customer_phone: contact.phone.trim(),
|
|
242
|
+
customer_email: contact.email.trim() || undefined,
|
|
243
|
+
start_at: selectedSlot?.start_at ?? '',
|
|
244
|
+
notes: contact.notes.trim() || undefined,
|
|
245
|
+
}),
|
|
246
|
+
onMutate: () => setFeedback(null),
|
|
247
|
+
onSuccess: async () => {
|
|
248
|
+
setFeedback(copy.bookingCreatedTitle);
|
|
249
|
+
setSubmittedLookupPhone(contact.phone.trim());
|
|
250
|
+
await Promise.all([
|
|
251
|
+
queryClient.invalidateQueries({ queryKey: publicKeys.availability(trimmedOrgRef, selectedServiceId || null, selectedDate) }),
|
|
252
|
+
queryClient.invalidateQueries({ queryKey: publicKeys.myBookings(trimmedOrgRef, contact.phone.trim()) }),
|
|
253
|
+
]);
|
|
254
|
+
},
|
|
255
|
+
onError: (error: Error) => setFeedback(error.message),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const joinQueueMutation = useMutation({
|
|
259
|
+
mutationFn: async (queueId: string) =>
|
|
260
|
+
client.createQueueTicket(trimmedOrgRef, queueId, {
|
|
261
|
+
customer_name: contact.name.trim(),
|
|
262
|
+
customer_phone: contact.phone.trim(),
|
|
263
|
+
customer_email: contact.email.trim() || undefined,
|
|
264
|
+
}),
|
|
265
|
+
onMutate: () => setFeedback(null),
|
|
266
|
+
onSuccess: () => {
|
|
267
|
+
setFeedback(copy.queueCreatedTitle);
|
|
268
|
+
},
|
|
269
|
+
onError: (error: Error) => setFeedback(error.message),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const bookingActionMutation = useMutation({
|
|
273
|
+
mutationFn: async ({ action, booking }: { action: 'confirm' | 'cancel'; booking: PublicBooking }) => {
|
|
274
|
+
const token = action === 'confirm' ? booking.actions?.confirm_token : booking.actions?.cancel_token;
|
|
275
|
+
if (!token) {
|
|
276
|
+
throw new Error('missing booking action token');
|
|
277
|
+
}
|
|
278
|
+
if (action === 'confirm') {
|
|
279
|
+
return client.confirmBooking(trimmedOrgRef, token);
|
|
280
|
+
}
|
|
281
|
+
return client.cancelBooking(trimmedOrgRef, token, copy.cancelBookingReason);
|
|
282
|
+
},
|
|
283
|
+
onMutate: () => setFeedback(null),
|
|
284
|
+
onSuccess: async () => {
|
|
285
|
+
if (submittedLookupPhone.trim()) {
|
|
286
|
+
await queryClient.invalidateQueries({ queryKey: publicKeys.myBookings(trimmedOrgRef, submittedLookupPhone) });
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
onError: (error: Error) => setFeedback(error.message),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const services = servicesQuery.data ?? [];
|
|
293
|
+
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
|
294
|
+
|
|
295
|
+
if (!trimmedOrgRef) {
|
|
296
|
+
return (
|
|
297
|
+
<section className={`modules-scheduling ${className}`.trim()}>
|
|
298
|
+
<div className="card empty-state">
|
|
299
|
+
<p>{copy.orgRefHelp}</p>
|
|
300
|
+
</div>
|
|
301
|
+
</section>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (infoQuery.isLoading || servicesQuery.isLoading) {
|
|
306
|
+
return (
|
|
307
|
+
<section className={`modules-scheduling ${className}`.trim()}>
|
|
308
|
+
<div className="card modules-scheduling__empty">
|
|
309
|
+
<div className="spinner" />
|
|
310
|
+
<p>{copy.loading}</p>
|
|
311
|
+
</div>
|
|
312
|
+
</section>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const business = infoQuery.data;
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<section className={`modules-scheduling ${className}`.trim()}>
|
|
320
|
+
{feedback ? <div className="alert alert-success">{feedback}</div> : null}
|
|
321
|
+
|
|
322
|
+
{business ? (
|
|
323
|
+
<div className="card modules-scheduling__public-business">
|
|
324
|
+
<div className="card-header">
|
|
325
|
+
<div>
|
|
326
|
+
<h2>{copy.businessInfoTitle}</h2>
|
|
327
|
+
<p className="text-secondary">{business.business_name || business.name}</p>
|
|
328
|
+
</div>
|
|
329
|
+
<span className="modules-scheduling__public-ref">{business.slug || trimmedOrgRef}</span>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="modules-scheduling__public-business-meta">
|
|
332
|
+
{business.business_address ? <span>{business.business_address}</span> : null}
|
|
333
|
+
{business.business_phone ? <span>{business.business_phone}</span> : null}
|
|
334
|
+
{business.business_email ? <span>{business.business_email}</span> : null}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
) : null}
|
|
338
|
+
|
|
339
|
+
{business && !business.scheduling_enabled ? (
|
|
340
|
+
<div className="alert alert-warning">
|
|
341
|
+
<strong>{copy.publicDisabledTitle}</strong>
|
|
342
|
+
<div>{copy.publicDisabledDescription}</div>
|
|
343
|
+
</div>
|
|
344
|
+
) : null}
|
|
345
|
+
|
|
346
|
+
<div className="modules-scheduling__public-grid">
|
|
347
|
+
<div className="card">
|
|
348
|
+
<div className="card-header">
|
|
349
|
+
<div>
|
|
350
|
+
<h2>{copy.availabilityTitle}</h2>
|
|
351
|
+
<p className="text-secondary">{copy.availabilityDescription}</p>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div className="modules-scheduling__filters">
|
|
356
|
+
<div className="form-group grow">
|
|
357
|
+
<label htmlFor="public-scheduling-service">{copy.serviceLabel}</label>
|
|
358
|
+
<select
|
|
359
|
+
id="public-scheduling-service"
|
|
360
|
+
value={selectedServiceId}
|
|
361
|
+
onChange={(event) => setSelectedServiceId(event.target.value)}
|
|
362
|
+
>
|
|
363
|
+
{services.map((service: PublicService) => (
|
|
364
|
+
<option key={service.id} value={service.id}>
|
|
365
|
+
{service.name}
|
|
366
|
+
</option>
|
|
367
|
+
))}
|
|
368
|
+
</select>
|
|
369
|
+
</div>
|
|
370
|
+
<div className="form-group">
|
|
371
|
+
<label htmlFor="public-scheduling-date">{copy.dateLabel}</label>
|
|
372
|
+
<input
|
|
373
|
+
id="public-scheduling-date"
|
|
374
|
+
type="date"
|
|
375
|
+
value={selectedDate}
|
|
376
|
+
onChange={(event) => setSelectedDate(event.target.value)}
|
|
377
|
+
/>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
{availabilityQuery.isLoading ? (
|
|
382
|
+
<div className="modules-scheduling__empty">{copy.availabilityLoading}</div>
|
|
383
|
+
) : (availabilityQuery.data ?? []).length === 0 ? (
|
|
384
|
+
<div className="modules-scheduling__empty">{copy.availabilityEmpty}</div>
|
|
385
|
+
) : (
|
|
386
|
+
<div className="modules-scheduling__public-slots">
|
|
387
|
+
{(availabilityQuery.data ?? []).map((slot) => (
|
|
388
|
+
<button
|
|
389
|
+
key={slot.start_at}
|
|
390
|
+
type="button"
|
|
391
|
+
className={`modules-scheduling__public-slot${
|
|
392
|
+
selectedSlot?.start_at === slot.start_at ? ' modules-scheduling__public-slot--active' : ''
|
|
393
|
+
}`}
|
|
394
|
+
onClick={() => setSelectedSlot(slot)}
|
|
395
|
+
>
|
|
396
|
+
<strong>{formatSchedulingDateTime(slot.start_at, locale)}</strong>
|
|
397
|
+
<span>{copy.selectSlot}</span>
|
|
398
|
+
</button>
|
|
399
|
+
))}
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div className="card">
|
|
405
|
+
<div className="card-header">
|
|
406
|
+
<div>
|
|
407
|
+
<h2>{selectedService?.name ?? copy.bookNow}</h2>
|
|
408
|
+
<p className="text-secondary">
|
|
409
|
+
{copy.selectedSlotLabel}: {selectedSlot ? formatSchedulingDateTime(selectedSlot.start_at, locale) : '—'}
|
|
410
|
+
</p>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
<div className="modules-scheduling__public-form">
|
|
414
|
+
<div className="form-group">
|
|
415
|
+
<label htmlFor="public-scheduling-name">{copy.nameLabel}</label>
|
|
416
|
+
<input
|
|
417
|
+
id="public-scheduling-name"
|
|
418
|
+
value={contact.name}
|
|
419
|
+
onChange={(event) => setContact((current) => ({ ...current, name: event.target.value }))}
|
|
420
|
+
/>
|
|
421
|
+
</div>
|
|
422
|
+
<div className="form-group">
|
|
423
|
+
<label htmlFor="public-scheduling-phone">{copy.phoneLabel}</label>
|
|
424
|
+
<input
|
|
425
|
+
id="public-scheduling-phone"
|
|
426
|
+
value={contact.phone}
|
|
427
|
+
onChange={(event) => setContact((current) => ({ ...current, phone: event.target.value }))}
|
|
428
|
+
/>
|
|
429
|
+
</div>
|
|
430
|
+
<div className="form-group">
|
|
431
|
+
<label htmlFor="public-scheduling-email">{copy.emailLabel}</label>
|
|
432
|
+
<input
|
|
433
|
+
id="public-scheduling-email"
|
|
434
|
+
value={contact.email}
|
|
435
|
+
onChange={(event) => setContact((current) => ({ ...current, email: event.target.value }))}
|
|
436
|
+
/>
|
|
437
|
+
</div>
|
|
438
|
+
<div className="form-group">
|
|
439
|
+
<label htmlFor="public-scheduling-notes">{copy.notesLabel}</label>
|
|
440
|
+
<textarea
|
|
441
|
+
id="public-scheduling-notes"
|
|
442
|
+
rows={3}
|
|
443
|
+
value={contact.notes}
|
|
444
|
+
onChange={(event) => setContact((current) => ({ ...current, notes: event.target.value }))}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
<button
|
|
449
|
+
type="button"
|
|
450
|
+
className="btn-primary"
|
|
451
|
+
disabled={!selectedSlot || !contact.name.trim() || !contact.phone.trim() || bookMutation.isPending}
|
|
452
|
+
onClick={() => void bookMutation.mutateAsync()}
|
|
453
|
+
>
|
|
454
|
+
{bookMutation.isPending ? copy.booking : copy.bookNow}
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<div className="modules-scheduling__public-grid">
|
|
460
|
+
<div className="card">
|
|
461
|
+
<div className="card-header">
|
|
462
|
+
<div>
|
|
463
|
+
<h2>{copy.myBookingsTitle}</h2>
|
|
464
|
+
<p className="text-secondary">{copy.myBookingsDescription}</p>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
<div className="modules-scheduling__public-bookings-lookup">
|
|
468
|
+
<div className="form-group grow">
|
|
469
|
+
<label htmlFor="public-scheduling-bookings-phone">{copy.phoneLabel}</label>
|
|
470
|
+
<input
|
|
471
|
+
id="public-scheduling-bookings-phone"
|
|
472
|
+
value={lookupPhone}
|
|
473
|
+
onChange={(event) => setLookupPhone(event.target.value)}
|
|
474
|
+
/>
|
|
475
|
+
</div>
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
className="btn-secondary"
|
|
479
|
+
disabled={!lookupPhone.trim() || myBookingsQuery.isFetching}
|
|
480
|
+
onClick={() => setSubmittedLookupPhone(lookupPhone.trim())}
|
|
481
|
+
>
|
|
482
|
+
{myBookingsQuery.isFetching ? copy.findingBookings : copy.findBookings}
|
|
483
|
+
</button>
|
|
484
|
+
</div>
|
|
485
|
+
{submittedLookupPhone.trim().length === 0 ? null : myBookingsQuery.isLoading ? (
|
|
486
|
+
<div className="modules-scheduling__empty">{copy.findingBookings}</div>
|
|
487
|
+
) : (myBookingsQuery.data ?? []).length === 0 ? (
|
|
488
|
+
<div className="modules-scheduling__empty">{copy.noBookings}</div>
|
|
489
|
+
) : (
|
|
490
|
+
<div className="modules-scheduling__public-bookings">
|
|
491
|
+
{(myBookingsQuery.data ?? []).map((booking) => (
|
|
492
|
+
<div key={booking.id} className="modules-scheduling__public-booking-card">
|
|
493
|
+
<div className="modules-scheduling__public-booking-head">
|
|
494
|
+
<strong>{booking.title || booking.party_name}</strong>
|
|
495
|
+
<span className="modules-scheduling__badge modules-scheduling__badge--neutral">
|
|
496
|
+
{statusLabel(copy, booking.status)}
|
|
497
|
+
</span>
|
|
498
|
+
</div>
|
|
499
|
+
<div className="modules-scheduling__public-booking-meta">
|
|
500
|
+
<span>{formatSchedulingDateTime(booking.start_at, locale)}</span>
|
|
501
|
+
<span>{booking.party_phone}</span>
|
|
502
|
+
</div>
|
|
503
|
+
{(booking.actions?.confirm_token || booking.actions?.cancel_token) ? (
|
|
504
|
+
<div className="modules-scheduling__public-booking-actions">
|
|
505
|
+
{booking.actions?.confirm_token ? (
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
className="btn-secondary btn-sm"
|
|
509
|
+
disabled={bookingActionMutation.isPending}
|
|
510
|
+
onClick={() => void bookingActionMutation.mutateAsync({ action: 'confirm', booking })}
|
|
511
|
+
>
|
|
512
|
+
{copy.confirmBooking}
|
|
513
|
+
</button>
|
|
514
|
+
) : null}
|
|
515
|
+
{booking.actions?.cancel_token ? (
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
className="btn-danger btn-sm"
|
|
519
|
+
disabled={bookingActionMutation.isPending}
|
|
520
|
+
onClick={() => void bookingActionMutation.mutateAsync({ action: 'cancel', booking })}
|
|
521
|
+
>
|
|
522
|
+
{copy.cancelBooking}
|
|
523
|
+
</button>
|
|
524
|
+
) : null}
|
|
525
|
+
</div>
|
|
526
|
+
) : null}
|
|
527
|
+
</div>
|
|
528
|
+
))}
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
<div className="card">
|
|
534
|
+
<div className="card-header">
|
|
535
|
+
<div>
|
|
536
|
+
<h2>{copy.queuesTitle}</h2>
|
|
537
|
+
<p className="text-secondary">{copy.queuesDescription}</p>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div className="modules-scheduling__public-queues">
|
|
541
|
+
{(queuesQuery.data ?? []).map((queue) => (
|
|
542
|
+
<div key={queue.id} className="modules-scheduling__public-queue-card">
|
|
543
|
+
<div className="modules-scheduling__public-booking-head">
|
|
544
|
+
<strong>{queue.name}</strong>
|
|
545
|
+
<span className="modules-scheduling__badge modules-scheduling__badge--attention">
|
|
546
|
+
{statusLabel(copy, queue.status)}
|
|
547
|
+
</span>
|
|
548
|
+
</div>
|
|
549
|
+
<div className="modules-scheduling__public-booking-meta">
|
|
550
|
+
<span>{queue.code}</span>
|
|
551
|
+
<span>
|
|
552
|
+
{copy.etaLabel}: {Math.max(queue.avg_service_seconds, 0)}s
|
|
553
|
+
</span>
|
|
554
|
+
</div>
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
className="btn-secondary btn-sm"
|
|
558
|
+
disabled={!queue.allow_remote_join || !contact.name.trim() || !contact.phone.trim() || joinQueueMutation.isPending}
|
|
559
|
+
onClick={() => void joinQueueMutation.mutateAsync(queue.id)}
|
|
560
|
+
>
|
|
561
|
+
{joinQueueMutation.isPending ? copy.joiningQueue : copy.joinQueue}
|
|
562
|
+
</button>
|
|
563
|
+
</div>
|
|
564
|
+
))}
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
</section>
|
|
569
|
+
);
|
|
570
|
+
}
|