@arquimedes.co/eureka-forms 3.0.68 → 3.0.69
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/dist/@Types/BookingFormStep.d.ts +10 -0
- package/dist/FormSteps/BookingStep/BookingStep.d.ts +3 -3
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.d.ts +10 -3
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.js +103 -6
- package/dist/Services/BookingService.d.ts +79 -0
- package/dist/Services/BookingService.js +66 -0
- package/dist/Services/BookingService.test.d.ts +1 -0
- package/dist/Services/BookingService.test.js +57 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.d.ts +22 -1
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.js +74 -10
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.js +50 -0
- package/package.json +1 -1
|
@@ -184,4 +184,14 @@ export interface BookingValue {
|
|
|
184
184
|
status: 'PENDING' | 'CONFIRMED';
|
|
185
185
|
/** Timestamp ISO hasta el que está bloqueado el slot (cuando `status === 'PENDING'`). */
|
|
186
186
|
lockedUntil?: string;
|
|
187
|
+
/**
|
|
188
|
+
* La cita que el backend creó al bloquear el slot. Junto con `hold`, es lo que el envío del
|
|
189
|
+
* formulario necesita para convertir el bloqueo en una cita en firme.
|
|
190
|
+
*/
|
|
191
|
+
appointment?: string;
|
|
192
|
+
/**
|
|
193
|
+
* Secreto que identifica al dueño del bloqueo. Solo quien lo tiene puede renovarlo, liberarlo o
|
|
194
|
+
* confirmarlo; viaja con el envío y el backend no lo conserva en el ticket.
|
|
195
|
+
*/
|
|
196
|
+
hold?: string;
|
|
187
197
|
}
|
|
@@ -4,9 +4,9 @@ import { StepProps } from '../Step';
|
|
|
4
4
|
export interface BookingStepProps extends StepProps {
|
|
5
5
|
step: BookingStep;
|
|
6
6
|
/**
|
|
7
|
-
* Callback to fetch available slots for a given person + date.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Callback to fetch available slots for a given person + date. A host that provides it replaces
|
|
8
|
+
* the form's public booking edge entirely (stories, previews). When omitted, the step reads its
|
|
9
|
+
* offer, days, slots and holds from that edge.
|
|
10
10
|
*/
|
|
11
11
|
fetchSlots?: (params: {
|
|
12
12
|
personId?: string;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { BookingStepProps } from '../BookingStep';
|
|
2
2
|
/**
|
|
3
|
-
* Conector del paso BOOKING: enlaza el estado del formulario (`useFormStep`) y el
|
|
4
|
-
*
|
|
3
|
+
* Conector del paso BOOKING: enlaza el estado del formulario (`useFormStep`) y el theming del form
|
|
4
|
+
* con `ErkBookingPicker`, y habla con el borde público de agendamiento del formulario.
|
|
5
|
+
*
|
|
6
|
+
* Todo lo que el picker muestra sale de la oferta que publica el backend, no del paso guardado: las
|
|
7
|
+
* personas llegan como handles firmados (nunca ids de agente), los días son los que tienen cupo, y
|
|
8
|
+
* las horas se leen en la zona horaria de la organización. Confirmar en el picker bloquea el slot;
|
|
9
|
+
* enviar el formulario es lo que lo convierte en cita, y "Cambiar" lo devuelve a la oferta.
|
|
10
|
+
*
|
|
11
|
+
* Un host que pase `fetchSlots` sustituye al borde por completo (historias, previsualizaciones).
|
|
5
12
|
*/
|
|
6
|
-
declare function MaterialBookingStep({ step, editable, fetchSlots }: BookingStepProps): JSX.Element;
|
|
13
|
+
declare function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }: BookingStepProps): JSX.Element;
|
|
7
14
|
export default MaterialBookingStep;
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useContext } from 'react';
|
|
2
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { PersonSelectionMode } from '../../../@Types/BookingFormStep';
|
|
3
4
|
import { useFormStep } from '../../StepHooks';
|
|
4
5
|
import FormContext from '../../../Contexts/FormContext';
|
|
5
6
|
import { selectBreakPoint, useAppSelector } from '../../../hooks';
|
|
6
7
|
import { calcStepWidth } from '../../StepFunctions';
|
|
7
8
|
import ErkBookingPicker from '../../../Shared/ErkBookingPicker/ErkBookingPicker';
|
|
9
|
+
import { bookingErrorMessage, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, nonEmpty, releaseBookingSlot, toPickerSlot, } from '../../../Services/BookingService';
|
|
10
|
+
/** Una reserva se renueva a mitad de su vida, y nunca más seguido que cada minuto. */
|
|
11
|
+
const MIN_RENEW_MS = 60000;
|
|
12
|
+
const DEFAULT_LOCK_MINUTES = 15;
|
|
8
13
|
/**
|
|
9
|
-
* Conector del paso BOOKING: enlaza el estado del formulario (`useFormStep`) y el
|
|
10
|
-
*
|
|
14
|
+
* Conector del paso BOOKING: enlaza el estado del formulario (`useFormStep`) y el theming del form
|
|
15
|
+
* con `ErkBookingPicker`, y habla con el borde público de agendamiento del formulario.
|
|
16
|
+
*
|
|
17
|
+
* Todo lo que el picker muestra sale de la oferta que publica el backend, no del paso guardado: las
|
|
18
|
+
* personas llegan como handles firmados (nunca ids de agente), los días son los que tienen cupo, y
|
|
19
|
+
* las horas se leen en la zona horaria de la organización. Confirmar en el picker bloquea el slot;
|
|
20
|
+
* enviar el formulario es lo que lo convierte en cita, y "Cambiar" lo devuelve a la oferta.
|
|
21
|
+
*
|
|
22
|
+
* Un host que pase `fetchSlots` sustituye al borde por completo (historias, previsualizaciones).
|
|
11
23
|
*/
|
|
12
|
-
function MaterialBookingStep({ step, editable, fetchSlots }) {
|
|
24
|
+
function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }) {
|
|
13
25
|
const currentBreakPoint = useAppSelector(selectBreakPoint);
|
|
14
|
-
const { postview, formStyle } = useAppSelector((state) => state.global);
|
|
26
|
+
const { postview, formStyle, idOrganization, internal, apiKey: globalApiKey, } = useAppSelector((state) => state.global);
|
|
15
27
|
const formCtx = useContext(FormContext);
|
|
16
28
|
const isReadOnly = !editable || postview;
|
|
17
29
|
const widthStyle = currentBreakPoint <= step.size ? '100%' : calcStepWidth(step.size, formCtx.size);
|
|
@@ -27,6 +39,91 @@ function MaterialBookingStep({ step, editable, fetchSlots }) {
|
|
|
27
39
|
defaultValue: null,
|
|
28
40
|
rules: { required: step.required ? 'Selecciona una cita' : undefined },
|
|
29
41
|
});
|
|
30
|
-
|
|
42
|
+
const apiKey = formCtx.apiKey ?? globalApiKey;
|
|
43
|
+
const route = useMemo(() => (apiKey ? { apiKey, idStep: step.id, idOrganization: idOrganization || undefined, internal } : undefined), [apiKey, step.id, idOrganization, internal]);
|
|
44
|
+
const usesEdge = !customFetchSlots && !isReadOnly && !!route;
|
|
45
|
+
const [offer, setOffer] = useState(undefined);
|
|
46
|
+
const [offerError, setOfferError] = useState(undefined);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!usesEdge || !route)
|
|
49
|
+
return;
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
fetchBookingOffer(route)
|
|
52
|
+
.then((loaded) => {
|
|
53
|
+
if (!cancelled)
|
|
54
|
+
setOffer(loaded);
|
|
55
|
+
})
|
|
56
|
+
.catch((e) => {
|
|
57
|
+
if (!cancelled)
|
|
58
|
+
setOfferError(bookingErrorMessage(e));
|
|
59
|
+
});
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
};
|
|
63
|
+
}, [usesEdge, route]);
|
|
64
|
+
const persons = useMemo(() => offer?.persons?.map((person) => ({
|
|
65
|
+
id: person.token,
|
|
66
|
+
name: person.name,
|
|
67
|
+
role: person.role,
|
|
68
|
+
avatarUrl: person.avatarUrl,
|
|
69
|
+
description: person.description,
|
|
70
|
+
})), [offer]);
|
|
71
|
+
const fetchDays = useCallback(async ({ personId }) => {
|
|
72
|
+
if (!route || !offer)
|
|
73
|
+
return [];
|
|
74
|
+
// The offer already answered for "anybody"; one person's days are that person's offer.
|
|
75
|
+
return personId ? (await fetchBookingOffer(route, personId)).days : offer.days;
|
|
76
|
+
}, [route, offer]);
|
|
77
|
+
const fetchSlots = useCallback(async ({ personId, date }) => {
|
|
78
|
+
if (!route || !offer)
|
|
79
|
+
return [];
|
|
80
|
+
const slots = await fetchBookingSlots(route, date, personId);
|
|
81
|
+
return slots.map((slot) => toPickerSlot(slot, offer.timeZone));
|
|
82
|
+
}, [route, offer]);
|
|
83
|
+
const holdSlot = useCallback(async ({ slot, contact }) => {
|
|
84
|
+
if (!route)
|
|
85
|
+
throw new Error('No pudimos reservar ese horario.');
|
|
86
|
+
try {
|
|
87
|
+
return await holdBookingSlot(route, {
|
|
88
|
+
slot: slot.id,
|
|
89
|
+
contact: contact?.name ? { name: contact.name, email: nonEmpty(contact.email) } : undefined,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
throw new Error(bookingErrorMessage(e));
|
|
94
|
+
}
|
|
95
|
+
}, [route]);
|
|
96
|
+
// A held slot is somebody's calendar taken off the offer; giving it back the moment the requester
|
|
97
|
+
// changes their mind is what keeps "Cambiar" from costing the organization the slot for the whole lock.
|
|
98
|
+
const handleChange = useCallback((next) => {
|
|
99
|
+
if (next === null && usesEdge && route && value?.appointment && value.hold) {
|
|
100
|
+
void releaseBookingSlot(route, { appointment: value.appointment, hold: value.hold }).catch(() => undefined);
|
|
101
|
+
}
|
|
102
|
+
onChange(next);
|
|
103
|
+
}, [onChange, usesEdge, route, value]);
|
|
104
|
+
// The hold outlives a slow form by being renewed with its own token; a renewal that fails (the slot
|
|
105
|
+
// handle expired, the network went) is left alone — the lock simply lapses and the slot is offered again.
|
|
106
|
+
const renewal = useRef(undefined);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
clearInterval(renewal.current);
|
|
109
|
+
if (!usesEdge || !route || !value?.appointment || !value.hold || !value.slotId || value.status !== 'PENDING') {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const lockMs = (step.temporaryLockMinutes ?? DEFAULT_LOCK_MINUTES) * 60000;
|
|
113
|
+
const { slotId, hold, contactName, contactEmail } = value;
|
|
114
|
+
renewal.current = setInterval(() => {
|
|
115
|
+
void holdBookingSlot(route, {
|
|
116
|
+
slot: slotId,
|
|
117
|
+
hold,
|
|
118
|
+
contact: contactName ? { name: contactName, email: nonEmpty(contactEmail) } : undefined,
|
|
119
|
+
}).catch(() => undefined);
|
|
120
|
+
}, Math.max(MIN_RENEW_MS, lockMs / 2));
|
|
121
|
+
return () => clearInterval(renewal.current);
|
|
122
|
+
}, [usesEdge, route, value, step.temporaryLockMinutes]);
|
|
123
|
+
// The server decides how people are presented: a step that resolves to one calendar offers no choice.
|
|
124
|
+
const personSelectionMode = usesEdge
|
|
125
|
+
? (offer?.personSelection ?? PersonSelectionMode.NONE)
|
|
126
|
+
: step.personSelectionMode;
|
|
127
|
+
return (_jsx("div", { ref: ref, style: cssVars, children: _jsx(ErkBookingPicker, { value: value, onChange: handleChange, label: step.label, description: step.description, durationMinutes: offer?.durationMinutes ?? step.durationMinutes, bufferMinutes: step.bufferMinutes, bookingMode: step.bookingMode, temporaryLockMinutes: step.temporaryLockMinutes, personSelectionMode: personSelectionMode, persons: usesEdge ? persons : step.persons, collectContact: step.collectContact, fetchSlots: usesEdge ? fetchSlots : customFetchSlots, fetchDays: usesEdge ? fetchDays : undefined, holdSlot: usesEdge ? holdSlot : undefined, disabled: usesEdge && !offer, readOnly: isReadOnly, error: error?.message ?? offerError }) }));
|
|
31
128
|
}
|
|
32
129
|
export default MaterialBookingStep;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { BookingSlot } from '../@Types/BookingFormStep';
|
|
2
|
+
/**
|
|
3
|
+
* Cómo llegar al paso BOOKING de un formulario publicado. Misma regla que el envío del formulario:
|
|
4
|
+
* un form embebido o interno nombra su organización y sale por el dominio de forms; uno servido por
|
|
5
|
+
* el subdominio de la organización habla con `/api` en el mismo origen.
|
|
6
|
+
*/
|
|
7
|
+
export interface BookingRoute {
|
|
8
|
+
apiKey: string;
|
|
9
|
+
idStep: string;
|
|
10
|
+
idOrganization?: string;
|
|
11
|
+
internal?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Una persona agendable tal como la publica el backend: un handle opaco, nunca un id de agente. */
|
|
14
|
+
export interface BookingOfferPerson {
|
|
15
|
+
token: string;
|
|
16
|
+
name: string;
|
|
17
|
+
role?: string;
|
|
18
|
+
avatarUrl?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Lo que el picker necesita para dibujarse: en qué zona horaria leer las horas y qué días tienen cupo. */
|
|
22
|
+
export interface BookingOffer {
|
|
23
|
+
timeZone: string;
|
|
24
|
+
durationMinutes: number;
|
|
25
|
+
bookingMode: string;
|
|
26
|
+
personSelection: 'NONE' | 'OPTIONAL' | 'REQUIRED';
|
|
27
|
+
persons?: BookingOfferPerson[];
|
|
28
|
+
/** Días con al menos un slot, `YYYY-MM-DD` en la zona horaria de la organización. */
|
|
29
|
+
days: string[];
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
}
|
|
33
|
+
/** Un slot tal como lo ofrece el backend: instantes ISO y el handle firmado que lo nombra. */
|
|
34
|
+
export interface BookingOfferSlot {
|
|
35
|
+
start: string;
|
|
36
|
+
end: string;
|
|
37
|
+
token: string;
|
|
38
|
+
}
|
|
39
|
+
/** Un slot retirado de la oferta mientras se termina el formulario. */
|
|
40
|
+
export interface BookingHold {
|
|
41
|
+
appointment: string;
|
|
42
|
+
hold: string;
|
|
43
|
+
lockedUntil: string;
|
|
44
|
+
}
|
|
45
|
+
export interface BookingContact {
|
|
46
|
+
name: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
phone?: string;
|
|
49
|
+
}
|
|
50
|
+
/** La oferta del paso; con `person`, la oferta de esa persona (los días que ella tiene libres). */
|
|
51
|
+
export declare function fetchBookingOffer(route: BookingRoute, person?: string): Promise<BookingOffer>;
|
|
52
|
+
/** Los slots de un día, `date` en `YYYY-MM-DD` de la zona horaria de la organización. */
|
|
53
|
+
export declare function fetchBookingSlots(route: BookingRoute, date: string, person?: string): Promise<BookingOfferSlot[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Bloquea un slot (o renueva un bloqueo propio si se envía su `hold`). El backend responde 409 cuando
|
|
56
|
+
* otra persona ya lo tomó, y 422 cuando el paso exige contacto y no llegó.
|
|
57
|
+
*/
|
|
58
|
+
export declare function holdBookingSlot(route: BookingRoute, body: {
|
|
59
|
+
slot: string;
|
|
60
|
+
hold?: string;
|
|
61
|
+
contact?: BookingContact;
|
|
62
|
+
}): Promise<BookingHold>;
|
|
63
|
+
/** Devuelve un slot antes de que venza su bloqueo, para que se ofrezca de nuevo de inmediato. */
|
|
64
|
+
export declare function releaseBookingSlot(route: BookingRoute, body: {
|
|
65
|
+
appointment: string;
|
|
66
|
+
hold: string;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* La hora `HH:mm` de un instante en la zona horaria de la organización. El picker muestra el reloj de
|
|
70
|
+
* quien recibe la cita, no el del navegador: un slot de las 09:00 en Bogotá es a las 09:00 aunque el
|
|
71
|
+
* que agenda esté en Madrid.
|
|
72
|
+
*/
|
|
73
|
+
export declare function clockIn(iso: string, timeZone: string): string;
|
|
74
|
+
/** Un slot de la oferta como lo consume el picker: el handle es su id y las horas van en el reloj de la organización. */
|
|
75
|
+
export declare function toPickerSlot(slot: BookingOfferSlot, timeZone: string): BookingSlot;
|
|
76
|
+
/** Un texto opcional de un campo de contacto: vacío o solo espacios cuenta como no dado. */
|
|
77
|
+
export declare function nonEmpty(text?: string): string | undefined;
|
|
78
|
+
/** El motivo de un rechazo del backend, que llega como texto plano, o un mensaje genérico. */
|
|
79
|
+
export declare function bookingErrorMessage(error: unknown): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import axiosInstance from '../Utils/AxiosAPI';
|
|
2
|
+
import widgetInstance from '../Utils/AxiosWidget';
|
|
3
|
+
function client(route) {
|
|
4
|
+
return route.idOrganization || route.internal ? widgetInstance : axiosInstance;
|
|
5
|
+
}
|
|
6
|
+
function url(route, suffix, params = {}) {
|
|
7
|
+
const search = new URLSearchParams();
|
|
8
|
+
if (route.idOrganization || route.internal)
|
|
9
|
+
search.set('idOrganization', route.idOrganization ?? '');
|
|
10
|
+
for (const [key, value] of Object.entries(params)) {
|
|
11
|
+
if (value)
|
|
12
|
+
search.set(key, value);
|
|
13
|
+
}
|
|
14
|
+
const query = search.toString();
|
|
15
|
+
return `/booking/${route.apiKey}/${route.idStep}${suffix}${query ? `?${query}` : ''}`;
|
|
16
|
+
}
|
|
17
|
+
/** La oferta del paso; con `person`, la oferta de esa persona (los días que ella tiene libres). */
|
|
18
|
+
export async function fetchBookingOffer(route, person) {
|
|
19
|
+
const response = await client(route).get(url(route, '', { person }));
|
|
20
|
+
return response.data;
|
|
21
|
+
}
|
|
22
|
+
/** Los slots de un día, `date` en `YYYY-MM-DD` de la zona horaria de la organización. */
|
|
23
|
+
export async function fetchBookingSlots(route, date, person) {
|
|
24
|
+
const response = await client(route).get(url(route, '/slots', { date, person }));
|
|
25
|
+
return response.data.slots;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Bloquea un slot (o renueva un bloqueo propio si se envía su `hold`). El backend responde 409 cuando
|
|
29
|
+
* otra persona ya lo tomó, y 422 cuando el paso exige contacto y no llegó.
|
|
30
|
+
*/
|
|
31
|
+
export async function holdBookingSlot(route, body) {
|
|
32
|
+
const response = await client(route).post(url(route, '/hold'), body);
|
|
33
|
+
return response.data;
|
|
34
|
+
}
|
|
35
|
+
/** Devuelve un slot antes de que venza su bloqueo, para que se ofrezca de nuevo de inmediato. */
|
|
36
|
+
export async function releaseBookingSlot(route, body) {
|
|
37
|
+
await client(route).post(url(route, '/release'), body);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* La hora `HH:mm` de un instante en la zona horaria de la organización. El picker muestra el reloj de
|
|
41
|
+
* quien recibe la cita, no el del navegador: un slot de las 09:00 en Bogotá es a las 09:00 aunque el
|
|
42
|
+
* que agenda esté en Madrid.
|
|
43
|
+
*/
|
|
44
|
+
export function clockIn(iso, timeZone) {
|
|
45
|
+
return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone }).format(new Date(iso));
|
|
46
|
+
}
|
|
47
|
+
/** Un slot de la oferta como lo consume el picker: el handle es su id y las horas van en el reloj de la organización. */
|
|
48
|
+
export function toPickerSlot(slot, timeZone) {
|
|
49
|
+
return {
|
|
50
|
+
id: slot.token,
|
|
51
|
+
startTime: clockIn(slot.start, timeZone),
|
|
52
|
+
endTime: clockIn(slot.end, timeZone),
|
|
53
|
+
available: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Un texto opcional de un campo de contacto: vacío o solo espacios cuenta como no dado. */
|
|
57
|
+
export function nonEmpty(text) {
|
|
58
|
+
return text?.trim() ? text : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** El motivo de un rechazo del backend, que llega como texto plano, o un mensaje genérico. */
|
|
61
|
+
export function bookingErrorMessage(error) {
|
|
62
|
+
const data = error?.response?.data;
|
|
63
|
+
if (typeof data === 'string' && data.trim())
|
|
64
|
+
return data;
|
|
65
|
+
return 'No pudimos reservar ese horario. Intenta de nuevo.';
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/require-await, @typescript-eslint/explicit-function-return-type -- test file: vi.mocked()/expect() reference mocked axios methods as values, and the mock factory uses terse async/return types */
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import axiosInstance from '../Utils/AxiosAPI';
|
|
4
|
+
import widgetInstance from '../Utils/AxiosWidget';
|
|
5
|
+
import { bookingErrorMessage, clockIn, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, releaseBookingSlot, toPickerSlot, } from './BookingService';
|
|
6
|
+
vi.mock('../Utils/AxiosWidget', () => ({
|
|
7
|
+
default: { get: vi.fn(), post: vi.fn() },
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../Utils/AxiosAPI', () => ({
|
|
10
|
+
default: { get: vi.fn(), post: vi.fn() },
|
|
11
|
+
axiosBaseQuery: () => async () => ({ data: undefined }),
|
|
12
|
+
}));
|
|
13
|
+
const ROUTE = { apiKey: 'form-key', idStep: 'BOOKING-1' };
|
|
14
|
+
describe('BookingService', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
it('reads a slot on the organization’s clock, not the browser’s', () => {
|
|
19
|
+
// 14:00Z is 09:00 in Bogotá and 16:00 in Madrid; the picker shows the calendar's own hours.
|
|
20
|
+
expect(clockIn('2026-09-22T14:00:00.000Z', 'America/Bogota')).toBe('09:00');
|
|
21
|
+
expect(clockIn('2026-09-22T14:00:00.000Z', 'Europe/Madrid')).toBe('16:00');
|
|
22
|
+
expect(clockIn('2026-09-22T05:00:00.000Z', 'America/Bogota')).toBe('00:00');
|
|
23
|
+
expect(toPickerSlot({ start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'tok' }, 'America/Bogota')).toEqual({ id: 'tok', startTime: '09:00', endTime: '09:30', available: true });
|
|
24
|
+
});
|
|
25
|
+
it('talks to the same-origin API for a form served under the organization', async () => {
|
|
26
|
+
vi.mocked(axiosInstance.get).mockResolvedValue({ data: { days: ['2026-09-22'] } });
|
|
27
|
+
await fetchBookingOffer(ROUTE);
|
|
28
|
+
expect(axiosInstance.get).toHaveBeenCalledWith('/booking/form-key/BOOKING-1');
|
|
29
|
+
await fetchBookingSlots(ROUTE, '2026-09-22', 'person-token');
|
|
30
|
+
expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/slots?date=2026-09-22&person=person-token');
|
|
31
|
+
expect(widgetInstance.get).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
it('names the organization and goes through the forms domain for an embedded form', async () => {
|
|
34
|
+
vi.mocked(widgetInstance.get).mockResolvedValue({ data: { days: [] } });
|
|
35
|
+
vi.mocked(widgetInstance.post).mockResolvedValue({ data: { appointment: 'a1', hold: 'h1', lockedUntil: 'x' } });
|
|
36
|
+
const route = { ...ROUTE, idOrganization: 'acme' };
|
|
37
|
+
await fetchBookingOffer(route, 'p');
|
|
38
|
+
expect(widgetInstance.get).toHaveBeenCalledWith('/booking/form-key/BOOKING-1?idOrganization=acme&person=p');
|
|
39
|
+
const held = await holdBookingSlot(route, { slot: 'slot-token', contact: { name: 'Ana' } });
|
|
40
|
+
expect(widgetInstance.post).toHaveBeenCalledWith('/booking/form-key/BOOKING-1/hold?idOrganization=acme', {
|
|
41
|
+
slot: 'slot-token',
|
|
42
|
+
contact: { name: 'Ana' },
|
|
43
|
+
});
|
|
44
|
+
expect(held).toEqual({ appointment: 'a1', hold: 'h1', lockedUntil: 'x' });
|
|
45
|
+
await releaseBookingSlot(route, { appointment: 'a1', hold: 'h1' });
|
|
46
|
+
expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/release?idOrganization=acme', {
|
|
47
|
+
appointment: 'a1',
|
|
48
|
+
hold: 'h1',
|
|
49
|
+
});
|
|
50
|
+
expect(axiosInstance.get).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
it('surfaces the edge’s plain-text refusal, and a generic message otherwise', () => {
|
|
53
|
+
expect(bookingErrorMessage({ response: { data: 'That slot is no longer available' } })).toBe('That slot is no longer available');
|
|
54
|
+
expect(bookingErrorMessage({ response: { data: { message: 'Forbidden' } } })).toBe('No pudimos reservar ese horario. Intenta de nuevo.');
|
|
55
|
+
expect(bookingErrorMessage(new Error('network'))).toBe('No pudimos reservar ese horario. Intenta de nuevo.');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -17,6 +17,27 @@ export interface ErkBookingPickerProps {
|
|
|
17
17
|
personId?: string;
|
|
18
18
|
date: string;
|
|
19
19
|
}) => Promise<BookingSlot[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Los días (`YYYY-MM-DD`) con algún horario para la persona elegida. Sin él, cualquier día desde
|
|
22
|
+
* hoy es elegible.
|
|
23
|
+
*/
|
|
24
|
+
fetchDays?: (args: {
|
|
25
|
+
personId?: string;
|
|
26
|
+
}) => Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Retira el slot de la oferta al confirmar. Lo que devuelve se guarda en el valor junto con la
|
|
29
|
+
* selección; si rechaza, el wizard vuelve a la hora con el motivo y recarga los horarios.
|
|
30
|
+
*/
|
|
31
|
+
holdSlot?: (args: {
|
|
32
|
+
slot: BookingSlot;
|
|
33
|
+
personId?: string;
|
|
34
|
+
contact?: {
|
|
35
|
+
name?: string;
|
|
36
|
+
email?: string;
|
|
37
|
+
};
|
|
38
|
+
}) => Promise<Pick<BookingValue, 'appointment' | 'hold' | 'lockedUntil'>>;
|
|
39
|
+
/** Deshabilita la tarjeta de entrada mientras el host termina de cargar la oferta. */
|
|
40
|
+
disabled?: boolean;
|
|
20
41
|
readOnly?: boolean;
|
|
21
42
|
error?: string;
|
|
22
43
|
}
|
|
@@ -27,5 +48,5 @@ export interface ErkBookingPickerProps {
|
|
|
27
48
|
* En el paso final el panel derecho desaparece y el resumen se expande en una columna
|
|
28
49
|
* centrada (cross-fade corto). Confirmar cierra el modal y deja el resumen compacto.
|
|
29
50
|
*/
|
|
30
|
-
declare function ErkBookingPicker({ value, onChange, label, description, durationMinutes, bufferMinutes, bookingMode, temporaryLockMinutes, personSelectionMode, persons, collectContact, fetchSlots, readOnly, error, }: ErkBookingPickerProps): JSX.Element;
|
|
51
|
+
declare function ErkBookingPicker({ value, onChange, label, description, durationMinutes, bufferMinutes, bookingMode, temporaryLockMinutes, personSelectionMode, persons, collectContact, fetchSlots, fetchDays, holdSlot, disabled, readOnly, error, }: ErkBookingPickerProps): JSX.Element;
|
|
31
52
|
export default ErkBookingPicker;
|
|
@@ -59,7 +59,7 @@ function capitalize(text) {
|
|
|
59
59
|
* En el paso final el panel derecho desaparece y el resumen se expande en una columna
|
|
60
60
|
* centrada (cross-fade corto). Confirmar cierra el modal y deja el resumen compacto.
|
|
61
61
|
*/
|
|
62
|
-
function ErkBookingPicker({ value, onChange, label, description, durationMinutes, bufferMinutes, bookingMode, temporaryLockMinutes, personSelectionMode, persons, collectContact = false, fetchSlots, readOnly = false, error, }) {
|
|
62
|
+
function ErkBookingPicker({ value, onChange, label, description, durationMinutes, bufferMinutes, bookingMode, temporaryLockMinutes, personSelectionMode, persons, collectContact = false, fetchSlots, fetchDays, holdSlot, disabled = false, readOnly = false, error, }) {
|
|
63
63
|
const needsPerson = !!personSelectionMode && personSelectionMode !== PersonSelectionMode.NONE;
|
|
64
64
|
const personsOptional = personSelectionMode === PersonSelectionMode.OPTIONAL;
|
|
65
65
|
const today = startOfDay(new Date());
|
|
@@ -77,6 +77,10 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
77
77
|
const [loading, setLoading] = useState(false);
|
|
78
78
|
const [contactName, setContactName] = useState('');
|
|
79
79
|
const [contactEmail, setContactEmail] = useState('');
|
|
80
|
+
/** `undefined` mientras se consultan; sin `fetchDays` nunca se consultan y todo día futuro vale. */
|
|
81
|
+
const [days, setDays] = useState(undefined);
|
|
82
|
+
const [holding, setHolding] = useState(false);
|
|
83
|
+
const [holdError, setHoldError] = useState(undefined);
|
|
80
84
|
const reduceMotion = useMemo(() => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, []);
|
|
81
85
|
const monthCells = useMemo(() => {
|
|
82
86
|
const first = startOfWeek(startOfMonth(viewMonth), { weekStartsOn: WEEK_STARTS_ON });
|
|
@@ -105,6 +109,34 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
105
109
|
// oxlint-disable-next-line react/set-state-in-effect
|
|
106
110
|
void loadSlots(date, person?.id);
|
|
107
111
|
}, [value, date, person, loadSlots]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!fetchDays || !open || value)
|
|
114
|
+
return;
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
// Same shape as the slots effect: the days come from the host's API and the picker only mirrors them.
|
|
117
|
+
// oxlint-disable-next-line react/set-state-in-effect
|
|
118
|
+
setDays(undefined);
|
|
119
|
+
fetchDays({ personId: person?.id })
|
|
120
|
+
.then((available) => {
|
|
121
|
+
if (cancelled)
|
|
122
|
+
return;
|
|
123
|
+
setDays(available);
|
|
124
|
+
// The month on screen may hold nothing; the first day on offer is where the requester needs to be.
|
|
125
|
+
const first = available[0];
|
|
126
|
+
if (!first)
|
|
127
|
+
return;
|
|
128
|
+
setViewMonth((current) => available.some((d) => isSameMonth(new Date(d + 'T00:00:00'), current))
|
|
129
|
+
? current
|
|
130
|
+
: startOfMonth(new Date(first + 'T00:00:00')));
|
|
131
|
+
})
|
|
132
|
+
.catch(() => {
|
|
133
|
+
if (!cancelled)
|
|
134
|
+
setDays([]);
|
|
135
|
+
});
|
|
136
|
+
return () => {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
};
|
|
139
|
+
}, [fetchDays, open, value, person]);
|
|
108
140
|
// Al entrar/salir del paso final el layout se reestructura; el reacomodo
|
|
109
141
|
// ocurre mientras la tarjeta está invisible (fade corto en dos fases).
|
|
110
142
|
const goToStep = (target) => {
|
|
@@ -132,11 +164,10 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
132
164
|
return true;
|
|
133
165
|
}
|
|
134
166
|
})();
|
|
135
|
-
const confirm = () => {
|
|
167
|
+
const confirm = async () => {
|
|
136
168
|
if (!slot || !date)
|
|
137
169
|
return;
|
|
138
|
-
|
|
139
|
-
onChange({
|
|
170
|
+
const selection = {
|
|
140
171
|
type: 'BOOKING_VALUE',
|
|
141
172
|
slotId: slot.id,
|
|
142
173
|
date: fmtDate(date),
|
|
@@ -147,7 +178,35 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
147
178
|
contactName: contactName || undefined,
|
|
148
179
|
contactEmail: contactEmail || undefined,
|
|
149
180
|
status: 'PENDING',
|
|
150
|
-
}
|
|
181
|
+
};
|
|
182
|
+
if (!holdSlot) {
|
|
183
|
+
setOpen(false);
|
|
184
|
+
onChange(selection);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
setHolding(true);
|
|
188
|
+
setHoldError(undefined);
|
|
189
|
+
try {
|
|
190
|
+
const held = await holdSlot({
|
|
191
|
+
slot,
|
|
192
|
+
personId: person?.id,
|
|
193
|
+
contact: collectContact
|
|
194
|
+
? { name: contactName || undefined, email: contactEmail || undefined }
|
|
195
|
+
: undefined,
|
|
196
|
+
});
|
|
197
|
+
setOpen(false);
|
|
198
|
+
onChange({ ...selection, ...held });
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
// Somebody else took the slot while the form was being finished: back to the hour, with fresh slots.
|
|
202
|
+
setHoldError(e instanceof Error && e.message ? e.message : 'No pudimos reservar ese horario.');
|
|
203
|
+
setSlot(undefined);
|
|
204
|
+
goToStep(wizardKeys.indexOf('hour'));
|
|
205
|
+
void loadSlots(date, person?.id);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
setHolding(false);
|
|
209
|
+
}
|
|
151
210
|
};
|
|
152
211
|
const reset = () => {
|
|
153
212
|
onChange(null);
|
|
@@ -156,6 +215,8 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
156
215
|
setPersonTouched(false);
|
|
157
216
|
setDate(undefined);
|
|
158
217
|
setSlot(undefined);
|
|
218
|
+
setDays(undefined);
|
|
219
|
+
setHoldError(undefined);
|
|
159
220
|
setContactName('');
|
|
160
221
|
setContactEmail('');
|
|
161
222
|
};
|
|
@@ -192,7 +253,7 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
192
253
|
navParts.push(_jsx("span", { children: dayText }, "d"));
|
|
193
254
|
if (hourText)
|
|
194
255
|
navParts.push(_jsx("span", { className: styles.navHl, children: hourText }, "h"));
|
|
195
|
-
return (_jsxs("div", { className: styles.container, children: [_jsxs("div", { className: styles.entry, children: [_jsx("div", { className: styles.avatar, children: "\uD83D\uDCC5" }), _jsxs("div", { className: styles.entryInfo, children: [_jsx("span", { className: styles.entryLabel, children: label }), _jsx("span", { className: styles.entrySub, children: description ?? 'Agenda tu cita' })] }), _jsx(ErkButton, { text: "Agendar cita", onClick: () => setOpen(true) })] }), _jsx(Dialog, { open: open, onClose: () => setOpen(false), maxWidth: "md", fullWidth: true, slotProps: { paper: { style: { borderRadius: 16, background: 'transparent', boxShadow: 'none' } } }, children: _jsxs("div", { className: `${styles.card}${isFinal ? ` ${styles.cardFinal}` : ''}${xfade ? ` ${styles.xfade}` : ''}`, children: [_jsxs("div", { className: isFinal ? `${styles.book} ${styles.bookFinal}` : styles.book, children: [_jsxs("aside", { className: styles.who, children: [_jsxs("div", { className: styles.brand, children: [_jsx("div", { className: styles.avatar, children: person ? initials(person.name) : '📅' }), _jsxs("div", { children: [_jsx("h2", { className: styles.brandName, children: person?.name ?? label }), _jsx("p", { className: styles.brandRole, children: person?.role ?? description ?? 'Agenda tu cita' })] })] }), _jsxs("div", { className: styles.meta, children: [_jsxs("div", { className: styles.row, children: [_jsx("span", { className: styles.ic, children: "\u25F7" }), durationMinutes, " min", bufferMinutes ? ` · buffer ${bufferMinutes} min` : ''] }), bookingMode && (_jsxs("div", { className: styles.row, children: [_jsx("span", { className: styles.ic, children: "\u25F1" }), _jsx("span", { className: styles.pill, children: MODE_LABEL[bookingMode] })] }))] }), _jsx("div", { className: styles.sum, "aria-live": "polite", children: summaryRows.map((r) => (_jsxs("div", { className: r.text ? `${styles.srow} ${styles.srowOn}` : styles.srow, children: [_jsx("span", { className: styles.dot, children: "\u2713" }), _jsx("span", { children: r.text ?? r.placeholder })] }, r.key))) }), isFinal && collectContact && (_jsxs("div", { className: styles.cfields, children: [_jsx(ErkTextField, { label: "Nombre", value: contactName, onChange: setContactName }), _jsx(ErkTextField, { label: "Correo", value: contactEmail, onChange: setContactEmail })] })), !!temporaryLockMinutes && (_jsxs("div", { className: styles.lock, children: ["\u23F3 Reservaremos el horario ", _jsxs("b", { children: [temporaryLockMinutes, " min"] }), " mientras confirmas."] }))] }), _jsxs("div", { className: styles.right, children: [_jsx("nav", { className: styles.stepper, "aria-label": "Progreso", children: wizardKeys.map((key, i) => (_jsxs("span", { className: styles.stepGroup, children: [_jsxs("button", { type: "button", disabled: i >= wizStep, onClick: () => goToStep(i), className: i < wizStep
|
|
256
|
+
return (_jsxs("div", { className: styles.container, children: [_jsxs("div", { className: styles.entry, children: [_jsx("div", { className: styles.avatar, children: "\uD83D\uDCC5" }), _jsxs("div", { className: styles.entryInfo, children: [_jsx("span", { className: styles.entryLabel, children: label }), _jsx("span", { className: styles.entrySub, children: description ?? 'Agenda tu cita' })] }), _jsx(ErkButton, { text: "Agendar cita", disabled: disabled, onClick: () => setOpen(true) })] }), _jsx(Dialog, { open: open, onClose: () => setOpen(false), maxWidth: "md", fullWidth: true, slotProps: { paper: { style: { borderRadius: 16, background: 'transparent', boxShadow: 'none' } } }, children: _jsxs("div", { className: `${styles.card}${isFinal ? ` ${styles.cardFinal}` : ''}${xfade ? ` ${styles.xfade}` : ''}`, children: [_jsxs("div", { className: isFinal ? `${styles.book} ${styles.bookFinal}` : styles.book, children: [_jsxs("aside", { className: styles.who, children: [_jsxs("div", { className: styles.brand, children: [_jsx("div", { className: styles.avatar, children: person ? initials(person.name) : '📅' }), _jsxs("div", { children: [_jsx("h2", { className: styles.brandName, children: person?.name ?? label }), _jsx("p", { className: styles.brandRole, children: person?.role ?? description ?? 'Agenda tu cita' })] })] }), _jsxs("div", { className: styles.meta, children: [_jsxs("div", { className: styles.row, children: [_jsx("span", { className: styles.ic, children: "\u25F7" }), durationMinutes, " min", bufferMinutes ? ` · buffer ${bufferMinutes} min` : ''] }), bookingMode && (_jsxs("div", { className: styles.row, children: [_jsx("span", { className: styles.ic, children: "\u25F1" }), _jsx("span", { className: styles.pill, children: MODE_LABEL[bookingMode] })] }))] }), _jsx("div", { className: styles.sum, "aria-live": "polite", children: summaryRows.map((r) => (_jsxs("div", { className: r.text ? `${styles.srow} ${styles.srowOn}` : styles.srow, children: [_jsx("span", { className: styles.dot, children: "\u2713" }), _jsx("span", { children: r.text ?? r.placeholder })] }, r.key))) }), isFinal && collectContact && (_jsxs("div", { className: styles.cfields, children: [_jsx(ErkTextField, { label: "Nombre", value: contactName, onChange: setContactName }), _jsx(ErkTextField, { label: "Correo", value: contactEmail, onChange: setContactEmail })] })), !!temporaryLockMinutes && (_jsxs("div", { className: styles.lock, children: ["\u23F3 Reservaremos el horario ", _jsxs("b", { children: [temporaryLockMinutes, " min"] }), " mientras confirmas."] }))] }), _jsxs("div", { className: styles.right, children: [_jsx("nav", { className: styles.stepper, "aria-label": "Progreso", children: wizardKeys.map((key, i) => (_jsxs("span", { className: styles.stepGroup, children: [_jsxs("button", { type: "button", disabled: i >= wizStep, onClick: () => goToStep(i), className: i < wizStep
|
|
196
257
|
? `${styles.stepItem} ${styles.stepDone}`
|
|
197
258
|
: i === wizStep
|
|
198
259
|
? `${styles.stepItem} ${styles.stepOn}`
|
|
@@ -202,10 +263,13 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
202
263
|
? `${styles.pcard} ${styles.pcardOn}`
|
|
203
264
|
: styles.pcard, onClick: () => pickPerson(p), children: [_jsx("span", { className: styles.pav, children: initials(p.name) }), _jsxs("span", { className: styles.pinfo, children: [_jsx("span", { className: styles.pn, children: p.name }), p.role && _jsx("span", { className: styles.pr, children: p.role })] }), _jsx("span", { className: styles.chk, children: "\u2713" })] }, p.id))), personsOptional && (_jsxs("button", { type: "button", className: personTouched && !person
|
|
204
265
|
? `${styles.pcard} ${styles.pcardAny} ${styles.pcardOn}`
|
|
205
|
-
: `${styles.pcard} ${styles.pcardAny}`, onClick: () => pickPerson(undefined), children: [_jsx("span", { className: styles.pav, children: "\u2726" }), _jsxs("span", { className: styles.pinfo, children: [_jsx("span", { className: styles.pn, children: "Cualquiera" }), _jsx("span", { className: styles.pr, children: "El sistema asigna" })] }), _jsx("span", { className: styles.chk, children: "\u2713" })] }))] })] }, "person")), currentKey === 'day' && (_jsxs("section", { className: styles.pane, children: [_jsx("h3", { className: styles.q, children: "Elige un d\u00EDa" }), _jsx("p", { className: styles.qsub, children:
|
|
266
|
+
: `${styles.pcard} ${styles.pcardAny}`, onClick: () => pickPerson(undefined), children: [_jsx("span", { className: styles.pav, children: "\u2726" }), _jsxs("span", { className: styles.pinfo, children: [_jsx("span", { className: styles.pn, children: "Cualquiera" }), _jsx("span", { className: styles.pr, children: "El sistema asigna" })] }), _jsx("span", { className: styles.chk, children: "\u2713" })] }))] })] }, "person")), currentKey === 'day' && (_jsxs("section", { className: styles.pane, children: [_jsx("h3", { className: styles.q, children: "Elige un d\u00EDa" }), _jsx("p", { className: styles.qsub, children: fetchDays
|
|
267
|
+
? 'Solo se pueden elegir los días con horarios disponibles.'
|
|
268
|
+
: 'Solo se muestran fechas a partir de hoy.' }), _jsxs("div", { className: styles.monthWrap, children: [_jsxs("div", { className: styles.monthHead, children: [_jsx("span", { className: styles.monthTitle, children: capitalize(format(viewMonth, 'MMMM yyyy', { locale: es })) }), _jsxs("span", { className: styles.monthNav, children: [_jsx("button", { type: "button", "aria-label": "Mes anterior", onClick: () => setViewMonth((m) => addMonths(m, -1)), children: "\u2039" }), _jsx("button", { type: "button", "aria-label": "Mes siguiente", onClick: () => setViewMonth((m) => addMonths(m, 1)), children: "\u203A" })] })] }), _jsxs("div", { className: styles.mgrid, children: [DOW.map((d, i) => (_jsx("span", { className: styles.mdow, children: d }, i))), monthCells.map((d) => {
|
|
206
269
|
const outside = !isSameMonth(d, viewMonth);
|
|
207
270
|
const past = isBefore(d, today);
|
|
208
|
-
const
|
|
271
|
+
const offered = !days || days.includes(fmtDate(d));
|
|
272
|
+
const selectable = !outside && !past && offered;
|
|
209
273
|
const cls = date && isSameDay(d, date)
|
|
210
274
|
? `${styles.mday} ${styles.mdaySel}`
|
|
211
275
|
: selectable
|
|
@@ -215,7 +279,7 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
215
279
|
setDate(d);
|
|
216
280
|
setSlot(undefined);
|
|
217
281
|
}, children: format(d, 'd') }, d.toISOString()));
|
|
218
|
-
})] })] })] }, "day")), currentKey === 'hour' && (_jsxs("section", { className: styles.pane, children: [_jsx("h3", { className: styles.q, children: "Elige una hora" }), _jsxs("p", { className: styles.qsub, children: [dayText, " \u00B7 citas de ", durationMinutes, " min"] }), loading ? (_jsx("div", { className: styles.empty, children: "Buscando horarios\u2026" })) : slots.length === 0 ? (_jsx("div", { className: styles.empty, children: "No hay horarios disponibles ese d\u00EDa." })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.slots, children: slots.map((s) => (_jsx("button", { type: "button", disabled: !s.available, className: slot?.id === s.id
|
|
282
|
+
})] })] }), fetchDays && days === undefined && (_jsx("div", { className: styles.empty, children: "Buscando d\u00EDas con horarios\u2026" })), days?.length === 0 && (_jsx("div", { className: styles.empty, children: "No hay d\u00EDas con horarios disponibles." }))] }, "day")), currentKey === 'hour' && (_jsxs("section", { className: styles.pane, children: [_jsx("h3", { className: styles.q, children: "Elige una hora" }), _jsxs("p", { className: styles.qsub, children: [dayText, " \u00B7 citas de ", durationMinutes, " min"] }), holdError && (_jsx("div", { className: styles.err, role: "alert", children: holdError })), loading ? (_jsx("div", { className: styles.empty, children: "Buscando horarios\u2026" })) : slots.length === 0 ? (_jsx("div", { className: styles.empty, children: "No hay horarios disponibles ese d\u00EDa." })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.slots, children: slots.map((s) => (_jsx("button", { type: "button", disabled: !s.available, className: slot?.id === s.id
|
|
219
283
|
? `${styles.slot} ${styles.slotSel}`
|
|
220
284
|
: s.available
|
|
221
285
|
? styles.slot
|
|
@@ -223,6 +287,6 @@ function ErkBookingPicker({ value, onChange, label, description, durationMinutes
|
|
|
223
287
|
? 'Si todo está bien, completa tus datos y confirma.'
|
|
224
288
|
: 'Si todo está bien, confirma tu cita.' })] }, "confirm"))] })] }), _jsxs("div", { className: styles.navbar, children: [_jsx("div", { className: styles.navSummary, children: isFinal || !canAdvance || navParts.length === 0
|
|
225
289
|
? STEP_HINT[currentKey]
|
|
226
|
-
: navParts.reduce((acc, part, i) => i === 0 ? [part] : [...acc, _jsx("span", { children: " \u00B7 " }, `s${i}`), part], []) }), wizStep > 0 && (_jsx(ErkButton, { text: "Atr\u00E1s", variant: "outlined", onClick: () => goToStep(wizStep - 1) })), isFinal ? (_jsx(ErkButton, { text:
|
|
290
|
+
: navParts.reduce((acc, part, i) => i === 0 ? [part] : [...acc, _jsx("span", { children: " \u00B7 " }, `s${i}`), part], []) }), wizStep > 0 && (_jsx(ErkButton, { text: "Atr\u00E1s", variant: "outlined", onClick: () => goToStep(wizStep - 1) })), isFinal ? (_jsx(ErkButton, { text: holding ? 'Reservando…' : 'Confirmar cita', sx: ACCENT_SX, disabled: !slot || holding, onClick: () => void confirm() })) : (_jsx(ErkButton, { text: "Siguiente", disabled: !canAdvance, onClick: () => goToStep(wizStep + 1) }))] })] }) }), error && _jsx("div", { className: styles.err, children: error })] }));
|
|
227
291
|
}
|
|
228
292
|
export default ErkBookingPicker;
|
|
@@ -91,6 +91,56 @@ describe('ErkBookingPicker (wizard)', () => {
|
|
|
91
91
|
// La selección se mantiene y permite avanzar de nuevo
|
|
92
92
|
expect(screen.getByRole('button', { name: 'Siguiente' })).toBeEnabled();
|
|
93
93
|
});
|
|
94
|
+
it('offers only the days the host names, and holds the slot before the value is set', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
const onChange = vi.fn();
|
|
97
|
+
const today = format(new Date(), 'yyyy-MM-dd');
|
|
98
|
+
const fetchDays = vi.fn(() => Promise.resolve([today]));
|
|
99
|
+
const holdSlot = vi.fn(() => Promise.resolve({ appointment: 'appt-1', hold: 'secret-1', lockedUntil: '2026-09-22T14:15:00.000Z' }));
|
|
100
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkBookingPicker, { value: null, onChange: onChange, label: "Agendar cita", durationMinutes: 50, personSelectionMode: PersonSelectionMode.NONE, fetchDays: fetchDays, fetchSlots: () => Promise.resolve(SLOTS), holdSlot: holdSlot }) }));
|
|
101
|
+
await user.click(screen.getByRole('button', { name: 'Agendar cita' }));
|
|
102
|
+
expect(await screen.findByRole('heading', { name: 'Elige un día' })).toBeInTheDocument();
|
|
103
|
+
expect(fetchDays).toHaveBeenCalledWith({ personId: undefined });
|
|
104
|
+
// Only today is on offer: every other day of the month stays disabled.
|
|
105
|
+
const todayButton = clickableToday();
|
|
106
|
+
const enabledDays = screen
|
|
107
|
+
.getAllByRole('button')
|
|
108
|
+
.filter((b) => /^\d{1,2}$/.test(b.textContent ?? '') && !b.disabled);
|
|
109
|
+
expect(enabledDays).toEqual([todayButton]);
|
|
110
|
+
await user.click(todayButton);
|
|
111
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
112
|
+
await user.click(await screen.findByRole('button', { name: '09:00' }));
|
|
113
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
114
|
+
await user.click(await screen.findByRole('button', { name: 'Confirmar cita' }));
|
|
115
|
+
// The hold is what the value carries on top of the selection: what submit needs to confirm it.
|
|
116
|
+
expect(holdSlot).toHaveBeenCalledWith({ slot: SLOTS[0], personId: undefined, contact: undefined });
|
|
117
|
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
118
|
+
slotId: 's-1',
|
|
119
|
+
date: today,
|
|
120
|
+
appointment: 'appt-1',
|
|
121
|
+
hold: 'secret-1',
|
|
122
|
+
lockedUntil: '2026-09-22T14:15:00.000Z',
|
|
123
|
+
status: 'PENDING',
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
it('goes back to the hours with the reason when the hold is refused', async () => {
|
|
127
|
+
const user = userEvent.setup();
|
|
128
|
+
const onChange = vi.fn();
|
|
129
|
+
const fetchSlots = vi.fn(() => Promise.resolve(SLOTS));
|
|
130
|
+
const holdSlot = vi.fn(() => Promise.reject(new Error('Ese horario ya fue reservado por alguien más')));
|
|
131
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkBookingPicker, { value: null, onChange: onChange, label: "Agendar cita", durationMinutes: 50, personSelectionMode: PersonSelectionMode.NONE, fetchSlots: fetchSlots, holdSlot: holdSlot }) }));
|
|
132
|
+
await user.click(screen.getByRole('button', { name: 'Agendar cita' }));
|
|
133
|
+
await user.click(clickableToday());
|
|
134
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
135
|
+
await user.click(await screen.findByRole('button', { name: '09:00' }));
|
|
136
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
137
|
+
await user.click(await screen.findByRole('button', { name: 'Confirmar cita' }));
|
|
138
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Ese horario ya fue reservado por alguien más');
|
|
139
|
+
expect(screen.getByRole('heading', { name: 'Elige una hora' })).toBeInTheDocument();
|
|
140
|
+
// The slots are asked for again, since the offer just changed under the requester.
|
|
141
|
+
expect(fetchSlots).toHaveBeenCalledTimes(2);
|
|
142
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
94
144
|
it('renders the compact summary when a value exists', () => {
|
|
95
145
|
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkBookingPicker, { value: {
|
|
96
146
|
type: 'BOOKING_VALUE',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arquimedes.co/eureka-forms",
|
|
3
3
|
"repository": "git://github.com/Arquimede5/Eureka-Forms.git",
|
|
4
|
-
"version": "3.0.
|
|
4
|
+
"version": "3.0.69",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"watch": "node node_modules/@typescript/native/bin/tsc --noEmit --watch --project tsconfig.app.json",
|
|
7
7
|
"start": "vite",
|