@arquimedes.co/eureka-forms 3.0.71 → 3.0.72
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 +70 -18
- package/dist/@Types/BookingFormStep.js +21 -0
- package/dist/App/App.submitError.test.d.ts +1 -0
- package/dist/App/App.submitError.test.js +53 -0
- package/dist/BookingManage/AppointmentText.d.ts +5 -0
- package/dist/BookingManage/AppointmentText.js +20 -0
- package/dist/BookingManage/BookingManagePage.d.ts +17 -0
- package/dist/BookingManage/BookingManagePage.js +177 -0
- package/dist/BookingManage/BookingManagePage.module.css +115 -0
- package/dist/BookingManage/BookingManagePage.test.d.ts +1 -0
- package/dist/BookingManage/BookingManagePage.test.js +149 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.d.ts +14 -5
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.js +8 -1
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.module.css +12 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.d.ts +1 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.js +21 -0
- package/dist/Form/Form.d.ts +3 -0
- package/dist/Form/Form.js +9 -2
- package/dist/Form/FormTypes/ColumnForm/ColumnForm.d.ts +1 -1
- package/dist/Form/FormTypes/ColumnForm/ColumnForm.js +3 -2
- package/dist/Form/FormTypes/StepperForm/StepperForm.d.ts +1 -1
- package/dist/Form/FormTypes/StepperForm/StepperForm.js +3 -2
- package/dist/Form/SubmitError/SubmitError.d.ts +11 -0
- package/dist/Form/SubmitError/SubmitError.js +9 -0
- package/dist/Form/SubmitError/SubmitError.module.css +25 -0
- package/dist/Form/SubmitError/SubmitError.test.d.ts +1 -0
- package/dist/Form/SubmitError/SubmitError.test.js +34 -0
- package/dist/Form/SubmitError/SubmitErrorReason.d.ts +13 -0
- package/dist/Form/SubmitError/SubmitErrorReason.js +20 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.d.ts +8 -1
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.js +197 -41
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.d.ts +1 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.js +275 -0
- package/dist/Services/BookingService.d.ts +159 -12
- package/dist/Services/BookingService.js +210 -12
- package/dist/Services/BookingService.test.js +130 -9
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.d.ts +63 -12
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.js +141 -36
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.module.css +41 -3
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.d.ts +11 -3
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.js +69 -9
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.test.js +13 -15
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.js +331 -62
- package/dist/main.js +4 -1
- package/package.json +1 -1
|
@@ -1,24 +1,57 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { useWatch } from 'react-hook-form';
|
|
4
|
+
import { shallowEqual } from 'react-redux';
|
|
5
|
+
import { BookingContactEmailSourceTypes, PersonSelectionMode, } from '../../../@Types/BookingFormStep';
|
|
4
6
|
import { useFormStep } from '../../StepHooks';
|
|
5
7
|
import FormContext from '../../../Contexts/FormContext';
|
|
6
8
|
import { selectBreakPoint, useAppSelector } from '../../../hooks';
|
|
7
9
|
import { calcStepWidth } from '../../StepFunctions';
|
|
8
10
|
import ErkBookingPicker from '../../../Shared/ErkBookingPicker/ErkBookingPicker';
|
|
9
|
-
import {
|
|
11
|
+
import { BookingErrorCodes, HOLD_LAPSED_MESSAGE, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, holdLapsed, holdToValue, isValidEmail, nonEmpty, releaseBookingSlot, renewBookingHold, toBookingError, toPickerSlots, } from '../../../Services/BookingService';
|
|
10
12
|
/** Una reserva se renueva a mitad de su vida, y nunca más seguido que cada minuto. */
|
|
11
13
|
const MIN_RENEW_MS = 60000;
|
|
12
14
|
const DEFAULT_LOCK_MINUTES = 15;
|
|
15
|
+
const NAME_INFO = 'name';
|
|
16
|
+
const LASTNAME_INFO = 'lastName';
|
|
17
|
+
/** A field name no step uses, watched in place of a contact step the form does not have. */
|
|
18
|
+
const UNSET_FIELD = '__bookingContactUnset';
|
|
19
|
+
/** The first step of the form mapped to a client field, if any. */
|
|
20
|
+
function clientInfoStep(form, clientInfoType) {
|
|
21
|
+
return Object.values(form.steps).find((step) => step.clientInfoType === clientInfoType)?.id;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The current text of another step. A step of the section on screen lives in react-hook-form; one of a
|
|
25
|
+
* section already left (stepper forms unregister it) lives in the store's section values.
|
|
26
|
+
*/
|
|
27
|
+
function textOf(watched, stored) {
|
|
28
|
+
const value = typeof watched === 'string' ? watched : typeof stored === 'string' ? stored : undefined;
|
|
29
|
+
return nonEmpty(value);
|
|
30
|
+
}
|
|
31
|
+
/** Stable key of an offered person across offers, whose tokens are re-signed on every load. */
|
|
32
|
+
function personKey(person) {
|
|
33
|
+
return `person|${person.name}|${person.role ?? ''}`;
|
|
34
|
+
}
|
|
35
|
+
/** Stable key of an offered event template across offers; its token is re-signed like a person's. */
|
|
36
|
+
function templateKey(template) {
|
|
37
|
+
return `template|${template.name}`;
|
|
38
|
+
}
|
|
13
39
|
/**
|
|
14
40
|
* Conector del paso BOOKING: enlaza el estado del formulario (`useFormStep`) y el theming del form
|
|
15
41
|
* con `ErkBookingPicker`, y habla con el borde público de agendamiento del formulario.
|
|
16
42
|
*
|
|
17
43
|
* Todo lo que el picker muestra sale de la oferta que publica el backend, no del paso guardado: las
|
|
18
44
|
* 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.
|
|
45
|
+
* las horas se leen en la zona horaria de la organización. "Reservar" en el picker bloquea el slot;
|
|
20
46
|
* enviar el formulario es lo que lo convierte en cita, y "Cambiar" lo devuelve a la oferta.
|
|
21
47
|
*
|
|
48
|
+
* El contacto de la reserva: el nombre sale de un paso de nombre ya diligenciado o lo pide el picker;
|
|
49
|
+
* el correo sale del paso que nombra `contactEmail` (`STEP`) o lo pide el picker (`FIELD`, o si ese
|
|
50
|
+
* paso quedó vacío).
|
|
51
|
+
*
|
|
52
|
+
* Con varios tipos de atención y `requesterPicksType`, quien agenda elige primero el tipo: los días,
|
|
53
|
+
* las horas y la reserva se piden con su handle (`eventTemplate`) y la duración es la del tipo.
|
|
54
|
+
*
|
|
22
55
|
* Un host que pase `fetchSlots` sustituye al borde por completo (historias, previsualizaciones).
|
|
23
56
|
*/
|
|
24
57
|
function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }) {
|
|
@@ -39,11 +72,58 @@ function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }) {
|
|
|
39
72
|
defaultValue: null,
|
|
40
73
|
rules: { required: step.required ? 'Selecciona una cita' : undefined },
|
|
41
74
|
});
|
|
75
|
+
// --- Contact taken from earlier steps -------------------------------------------------------
|
|
76
|
+
const emailStepId = step.contactEmail?.source === BookingContactEmailSourceTypes.STEP ? step.contactEmail.stepId : undefined;
|
|
77
|
+
const nameStepId = useMemo(() => clientInfoStep(formCtx, NAME_INFO), [formCtx]);
|
|
78
|
+
const lastNameStepId = useMemo(() => clientInfoStep(formCtx, LASTNAME_INFO), [formCtx]);
|
|
79
|
+
const contactStepIds = [emailStepId, nameStepId, lastNameStepId];
|
|
80
|
+
const watched = useWatch({ name: contactStepIds.map((id) => id ?? UNSET_FIELD) });
|
|
81
|
+
const stored = useAppSelector((state) => contactStepIds.map((id) => {
|
|
82
|
+
const idSection = id ? formCtx.steps[id]?.idSection : undefined;
|
|
83
|
+
return id && idSection ? state.site.values.sections[idSection]?.[id] : undefined;
|
|
84
|
+
}), shallowEqual);
|
|
85
|
+
const stepEmail = textOf(watched[0], stored[0]);
|
|
86
|
+
const knownEmail = emailStepId && isValidEmail(stepEmail) ? stepEmail : undefined;
|
|
87
|
+
const knownName = [textOf(watched[1], stored[1]), textOf(watched[2], stored[2])].filter(Boolean).join(' ');
|
|
88
|
+
const knownContact = useMemo(() => ({ name: knownName || undefined, email: knownEmail }), [knownName, knownEmail]);
|
|
89
|
+
// The confirmation needs an email: a step that should have given one but was left empty asks in the picker.
|
|
90
|
+
const askEmail = !knownEmail;
|
|
91
|
+
// --- Booking edge -----------------------------------------------------------------------------
|
|
42
92
|
const apiKey = formCtx.apiKey ?? globalApiKey;
|
|
43
93
|
const route = useMemo(() => (apiKey ? { apiKey, idStep: step.id, idOrganization: idOrganization || undefined, internal } : undefined), [apiKey, step.id, idOrganization, internal]);
|
|
44
94
|
const usesEdge = !customFetchSlots && !isReadOnly && !!route;
|
|
45
95
|
const [offer, setOffer] = useState(undefined);
|
|
46
96
|
const [offerError, setOfferError] = useState(undefined);
|
|
97
|
+
const [offerAttempt, setOfferAttempt] = useState(0);
|
|
98
|
+
const [lapsed, setLapsed] = useState(undefined);
|
|
99
|
+
/** The offer the callbacks read; the state copy only drives rendering. */
|
|
100
|
+
const offerRef = useRef(undefined);
|
|
101
|
+
/**
|
|
102
|
+
* Person and event template tokens are re-signed with each offer: any token ever handed out maps to
|
|
103
|
+
* the current one of whatever it named.
|
|
104
|
+
*/
|
|
105
|
+
const tokenKeys = useRef(new Map());
|
|
106
|
+
const currentTokens = useRef(new Map());
|
|
107
|
+
/** The renewal token of the live hold; it never travels in the step value or the ticket. */
|
|
108
|
+
const renewToken = useRef(undefined);
|
|
109
|
+
const adoptOffer = useCallback((loaded) => {
|
|
110
|
+
const named = [
|
|
111
|
+
...(loaded.persons ?? []).map((person) => [person.token, personKey(person)]),
|
|
112
|
+
...(loaded.eventTemplates ?? []).map((template) => [template.token, templateKey(template)]),
|
|
113
|
+
];
|
|
114
|
+
for (const [token, key] of named) {
|
|
115
|
+
tokenKeys.current.set(token, key);
|
|
116
|
+
currentTokens.current.set(key, token);
|
|
117
|
+
}
|
|
118
|
+
offerRef.current = loaded;
|
|
119
|
+
setOffer(loaded);
|
|
120
|
+
}, []);
|
|
121
|
+
const currentToken = useCallback((token) => {
|
|
122
|
+
if (!token)
|
|
123
|
+
return undefined;
|
|
124
|
+
const key = tokenKeys.current.get(token);
|
|
125
|
+
return (key && currentTokens.current.get(key)) ?? token;
|
|
126
|
+
}, []);
|
|
47
127
|
useEffect(() => {
|
|
48
128
|
if (!usesEdge || !route)
|
|
49
129
|
return;
|
|
@@ -51,16 +131,42 @@ function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }) {
|
|
|
51
131
|
fetchBookingOffer(route)
|
|
52
132
|
.then((loaded) => {
|
|
53
133
|
if (!cancelled)
|
|
54
|
-
|
|
134
|
+
adoptOffer(loaded);
|
|
55
135
|
})
|
|
56
136
|
.catch((e) => {
|
|
57
137
|
if (!cancelled)
|
|
58
|
-
setOfferError(
|
|
138
|
+
setOfferError(toBookingError(e, 'No pudimos cargar los horarios.').message);
|
|
59
139
|
});
|
|
60
140
|
return () => {
|
|
61
141
|
cancelled = true;
|
|
62
142
|
};
|
|
63
|
-
}, [usesEdge, route]);
|
|
143
|
+
}, [usesEdge, route, adoptOffer, offerAttempt]);
|
|
144
|
+
const retryOffer = useCallback(() => {
|
|
145
|
+
setOfferError(undefined);
|
|
146
|
+
setOfferAttempt((attempt) => attempt + 1);
|
|
147
|
+
}, []);
|
|
148
|
+
/**
|
|
149
|
+
* Runs a read against the edge; a stale person token (403 `STALE_OFFER`) reloads the offer, which
|
|
150
|
+
* re-signs every person and event template, and retries once. Any refusal reaches the picker as an `Error` whose
|
|
151
|
+
* message is the edge's own, so it can show it next to "Reintentar".
|
|
152
|
+
*/
|
|
153
|
+
const withFreshOffer = useCallback(async (call, fallback) => {
|
|
154
|
+
try {
|
|
155
|
+
return await call();
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
const refusal = toBookingError(e, fallback);
|
|
159
|
+
if (refusal.code !== BookingErrorCodes.STALE_OFFER || !route)
|
|
160
|
+
throw refusal;
|
|
161
|
+
try {
|
|
162
|
+
adoptOffer(await fetchBookingOffer(route));
|
|
163
|
+
return await call();
|
|
164
|
+
}
|
|
165
|
+
catch (retryError) {
|
|
166
|
+
throw toBookingError(retryError, fallback);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}, [route, adoptOffer]);
|
|
64
170
|
const persons = useMemo(() => offer?.persons?.map((person) => ({
|
|
65
171
|
id: person.token,
|
|
66
172
|
name: person.name,
|
|
@@ -68,66 +174,116 @@ function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }) {
|
|
|
68
174
|
avatarUrl: person.avatarUrl,
|
|
69
175
|
description: person.description,
|
|
70
176
|
})), [offer]);
|
|
71
|
-
const
|
|
72
|
-
|
|
177
|
+
const eventTemplates = useMemo(() => offer?.requesterPicksType && (offer.eventTemplates?.length ?? 0) > 1
|
|
178
|
+
? offer.eventTemplates?.map((template) => ({
|
|
179
|
+
id: template.token,
|
|
180
|
+
name: template.name,
|
|
181
|
+
description: template.description,
|
|
182
|
+
durationMinutes: template.durationMinutes,
|
|
183
|
+
bookingMode: template.bookingMode,
|
|
184
|
+
}))
|
|
185
|
+
: undefined, [offer]);
|
|
186
|
+
const offerLoaded = !!offer;
|
|
187
|
+
const fetchDays = useCallback(async ({ personId, eventTemplate }) => {
|
|
188
|
+
if (!route || !offerLoaded || !offerRef.current)
|
|
73
189
|
return [];
|
|
74
|
-
// The offer already answered for "anybody";
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
190
|
+
// The offer already answered for "anybody" and any type; anything narrower is its own offer.
|
|
191
|
+
if (!personId && !eventTemplate)
|
|
192
|
+
return offerRef.current.days;
|
|
193
|
+
return withFreshOffer(async () => (await fetchBookingOffer(route, {
|
|
194
|
+
person: currentToken(personId),
|
|
195
|
+
eventTemplate: currentToken(eventTemplate),
|
|
196
|
+
})).days, 'No pudimos cargar los días disponibles.');
|
|
197
|
+
},
|
|
198
|
+
// `offerLoaded` re-creates the callback once the first offer lands, so the picker asks again.
|
|
199
|
+
[route, offerLoaded, withFreshOffer, currentToken]);
|
|
200
|
+
const fetchSlots = useCallback(async ({ personId, date, eventTemplate, }) => {
|
|
201
|
+
if (!route || !offerLoaded || !offerRef.current)
|
|
79
202
|
return [];
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
203
|
+
return withFreshOffer(async () => {
|
|
204
|
+
const slots = await fetchBookingSlots(route, date, {
|
|
205
|
+
person: currentToken(personId),
|
|
206
|
+
eventTemplate: currentToken(eventTemplate),
|
|
207
|
+
});
|
|
208
|
+
return toPickerSlots(slots, offerRef.current?.timeZone ?? 'UTC');
|
|
209
|
+
}, 'No pudimos cargar los horarios de este día.');
|
|
210
|
+
}, [route, offerLoaded, withFreshOffer, currentToken]);
|
|
211
|
+
const holdSlot = useCallback(async ({ slot, contact, eventTemplate, }) => {
|
|
84
212
|
if (!route)
|
|
85
213
|
throw new Error('No pudimos reservar ese horario.');
|
|
214
|
+
const template = currentToken(eventTemplate);
|
|
215
|
+
let held;
|
|
86
216
|
try {
|
|
87
|
-
|
|
217
|
+
held = await holdBookingSlot(route, {
|
|
88
218
|
slot: slot.id,
|
|
89
|
-
contact:
|
|
219
|
+
contact: { name: contact.name, email: contact.email ?? '' },
|
|
220
|
+
...(template ? { eventTemplate: template } : {}),
|
|
90
221
|
});
|
|
91
222
|
}
|
|
92
223
|
catch (e) {
|
|
93
|
-
throw
|
|
224
|
+
throw toBookingError(e);
|
|
94
225
|
}
|
|
95
|
-
|
|
226
|
+
renewToken.current = held.renew;
|
|
227
|
+
setLapsed(undefined);
|
|
228
|
+
return holdToValue(held, offerRef.current?.timeZone ?? 'UTC');
|
|
229
|
+
}, [route, currentToken]);
|
|
96
230
|
// A held slot is somebody's calendar taken off the offer; giving it back the moment the requester
|
|
97
231
|
// changes their mind is what keeps "Cambiar" from costing the organization the slot for the whole lock.
|
|
98
232
|
const handleChange = useCallback((next) => {
|
|
99
233
|
if (next === null && usesEdge && route && value?.appointment && value.hold) {
|
|
100
234
|
void releaseBookingSlot(route, { appointment: value.appointment, hold: value.hold }).catch(() => undefined);
|
|
101
235
|
}
|
|
236
|
+
if (next === null)
|
|
237
|
+
renewToken.current = undefined;
|
|
102
238
|
onChange(next);
|
|
103
239
|
}, [onChange, usesEdge, route, value]);
|
|
104
|
-
// The hold outlives a slow form by being renewed with
|
|
105
|
-
//
|
|
106
|
-
|
|
240
|
+
// The hold outlives a slow form by being renewed with the token of its last renewal, every half
|
|
241
|
+
// lock. A renewal the edge refuses for good (or none landing before the lock ends) drops the value
|
|
242
|
+
// and sends the requester back to the hours with the reason, instead of letting submit find it gone.
|
|
243
|
+
const valueRef = useRef(value);
|
|
107
244
|
useEffect(() => {
|
|
108
|
-
|
|
109
|
-
|
|
245
|
+
valueRef.current = value;
|
|
246
|
+
}, [value]);
|
|
247
|
+
const appointment = value?.status === 'PENDING' ? value.appointment : undefined;
|
|
248
|
+
const hold = value?.status === 'PENDING' ? value.hold : undefined;
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!usesEdge || !route || !appointment || !hold)
|
|
110
251
|
return;
|
|
111
|
-
}
|
|
112
252
|
const lockMs = (step.temporaryLockMinutes ?? DEFAULT_LOCK_MINUTES) * 60000;
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
253
|
+
const lapse = () => {
|
|
254
|
+
renewToken.current = undefined;
|
|
255
|
+
onChange(null);
|
|
256
|
+
setLapsed(HOLD_LAPSED_MESSAGE);
|
|
257
|
+
};
|
|
258
|
+
const timer = setInterval(() => {
|
|
259
|
+
const renew = renewToken.current;
|
|
260
|
+
if (!renew) {
|
|
261
|
+
// A hold restored without its renewal token can only be kept until it runs out.
|
|
262
|
+
const until = valueRef.current?.lockedUntil;
|
|
263
|
+
if (until && new Date(until).getTime() <= Date.now())
|
|
264
|
+
lapse();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
renewBookingHold(route, { renew, hold })
|
|
268
|
+
.then((renewed) => {
|
|
269
|
+
renewToken.current = renewed.renew;
|
|
270
|
+
if (valueRef.current?.appointment === appointment) {
|
|
271
|
+
onChange({ ...valueRef.current, lockedUntil: renewed.lockedUntil });
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
.catch((e) => {
|
|
275
|
+
if (valueRef.current?.appointment !== appointment)
|
|
276
|
+
return;
|
|
277
|
+
if (holdLapsed(toBookingError(e), valueRef.current.lockedUntil))
|
|
278
|
+
lapse();
|
|
279
|
+
});
|
|
120
280
|
}, Math.max(MIN_RENEW_MS, lockMs / 2));
|
|
121
|
-
return () => clearInterval(
|
|
122
|
-
}, [usesEdge, route,
|
|
281
|
+
return () => clearInterval(timer);
|
|
282
|
+
}, [usesEdge, route, appointment, hold, step.temporaryLockMinutes, onChange]);
|
|
123
283
|
// The server decides how people are presented: a step that resolves to one calendar offers no choice.
|
|
124
284
|
const personSelectionMode = usesEdge
|
|
125
285
|
? (offer?.personSelection ?? PersonSelectionMode.NONE)
|
|
126
286
|
: 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,
|
|
128
|
-
// Whoever holds a slot must be named unless the step lets strangers book; asking for the name
|
|
129
|
-
// whenever it is required keeps the hold from being refused for a step that never turned the
|
|
130
|
-
// contact fields on.
|
|
131
|
-
collectContact: !step.allowAnonymous || step.collectContact === true, requireContact: !step.allowAnonymous, fetchSlots: usesEdge ? fetchSlots : customFetchSlots, fetchDays: usesEdge ? fetchDays : undefined, holdSlot: usesEdge ? holdSlot : undefined, disabled: usesEdge && !offer, readOnly: isReadOnly, error: error?.message ?? offerError }) }));
|
|
287
|
+
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, bookingMode: step.bookingMode, temporaryLockMinutes: step.temporaryLockMinutes, personSelectionMode: personSelectionMode, persons: usesEdge ? persons : step.persons, eventTemplates: usesEdge ? eventTemplates : undefined, timeZone: offer?.timeZone, knownContact: knownContact, askEmail: askEmail, lapsed: lapsed, fetchSlots: usesEdge ? fetchSlots : customFetchSlots, fetchDays: usesEdge ? fetchDays : undefined, holdSlot: usesEdge ? holdSlot : undefined, disabled: usesEdge && !offer, readOnly: isReadOnly, error: error?.message, loadError: usesEdge ? offerError : undefined, onRetryLoad: retryOffer }) }));
|
|
132
288
|
}
|
|
133
289
|
export default MaterialBookingStep;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { useFormContext } from 'react-hook-form';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import '@testing-library/jest-dom';
|
|
8
|
+
import { format } from 'date-fns';
|
|
9
|
+
import { es } from 'date-fns/locale';
|
|
10
|
+
import BookingStepComponent from '../BookingStep';
|
|
11
|
+
import { storyForm, withFormStepProviders } from '../../@StoryDecorators/FormStepDecorator';
|
|
12
|
+
import FormContext from '../../../Contexts/FormContext';
|
|
13
|
+
import FormStepTypes from '../../../constants/FormStepTypes';
|
|
14
|
+
import { BookingContactEmailSourceTypes, BookingMode, PersonSelectionMode, } from '../../../@Types/BookingFormStep';
|
|
15
|
+
import { fetchBookingOffer, fetchBookingSlots, holdBookingSlot, renewBookingHold, } from '../../../Services/BookingService';
|
|
16
|
+
vi.mock('../../../Services/BookingService', async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal()),
|
|
18
|
+
fetchBookingOffer: vi.fn(),
|
|
19
|
+
fetchBookingSlots: vi.fn(),
|
|
20
|
+
holdBookingSlot: vi.fn(),
|
|
21
|
+
renewBookingHold: vi.fn(),
|
|
22
|
+
releaseBookingSlot: vi.fn(() => Promise.resolve()),
|
|
23
|
+
}));
|
|
24
|
+
const TODAY = format(new Date(), 'yyyy-MM-dd');
|
|
25
|
+
const OFFER = {
|
|
26
|
+
timeZone: 'America/Bogota',
|
|
27
|
+
durationMinutes: 30,
|
|
28
|
+
bookingMode: 'VIRTUAL',
|
|
29
|
+
personSelection: 'NONE',
|
|
30
|
+
days: [TODAY],
|
|
31
|
+
from: '2026-10-14T15:00:00.000Z',
|
|
32
|
+
to: '2026-11-13T04:59:59.999Z',
|
|
33
|
+
};
|
|
34
|
+
const SLOT = { start: '2026-10-15T14:00:00.000Z', end: '2026-10-15T14:30:00.000Z', token: 'slot-1' };
|
|
35
|
+
const HELD = {
|
|
36
|
+
appointment: 'appt-1',
|
|
37
|
+
hold: 'secret-1',
|
|
38
|
+
lockedUntil: '2026-10-15T13:00:00.000Z',
|
|
39
|
+
renew: 'renew-1',
|
|
40
|
+
start: '2026-10-15T14:15:00.000Z',
|
|
41
|
+
end: '2026-10-15T14:45:00.000Z',
|
|
42
|
+
person: { token: 'pt-1', name: 'Bruno Pérez', role: 'Asesor' },
|
|
43
|
+
};
|
|
44
|
+
function textStep(id, clientInfoType) {
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
idSection: 'SECTION_1',
|
|
48
|
+
stepPath: [id],
|
|
49
|
+
type: FormStepTypes.TEXTINPUT,
|
|
50
|
+
label: id,
|
|
51
|
+
description: null,
|
|
52
|
+
required: false,
|
|
53
|
+
size: 4,
|
|
54
|
+
editable: true,
|
|
55
|
+
clientInfoType,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const FORM = {
|
|
59
|
+
...storyForm,
|
|
60
|
+
apiKey: 'form-key',
|
|
61
|
+
steps: {
|
|
62
|
+
...storyForm.steps,
|
|
63
|
+
'email-step': textStep('email-step', 'email'),
|
|
64
|
+
'name-step': textStep('name-step', 'name'),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
function bookingStep(overrides = {}) {
|
|
68
|
+
return {
|
|
69
|
+
id: 'BOOKING-1',
|
|
70
|
+
idSection: 'SECTION_1',
|
|
71
|
+
stepPath: ['BOOKING-1'],
|
|
72
|
+
type: FormStepTypes.BOOKING,
|
|
73
|
+
label: 'Agendar cita',
|
|
74
|
+
description: null,
|
|
75
|
+
required: false,
|
|
76
|
+
bookingMode: BookingMode.VIRTUAL,
|
|
77
|
+
durationMinutes: 30,
|
|
78
|
+
temporaryLockMinutes: 2,
|
|
79
|
+
contactEmail: { source: BookingContactEmailSourceTypes.STEP, stepId: 'email-step' },
|
|
80
|
+
size: 4,
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Fills earlier steps of the form, as the requester would have done before reaching the booking. */
|
|
85
|
+
function Seed({ values }) {
|
|
86
|
+
const { setValue } = useFormContext();
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
for (const [name, value] of Object.entries(values))
|
|
89
|
+
setValue(name, value);
|
|
90
|
+
}, [setValue, values]);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function renderStep(step, values) {
|
|
94
|
+
const Story = () => (_jsxs(FormContext.Provider, { value: FORM, children: [_jsx(Seed, { values: values }), _jsx(BookingStepComponent, { step: step, editable: true })] }));
|
|
95
|
+
render(withFormStepProviders(Story));
|
|
96
|
+
}
|
|
97
|
+
function today() {
|
|
98
|
+
return screen.getByRole('button', { name: format(new Date(), "EEEE d 'de' MMMM 'de' yyyy", { locale: es }) });
|
|
99
|
+
}
|
|
100
|
+
async function reserveFirstHour(user) {
|
|
101
|
+
const open = await screen.findByRole('button', { name: 'Agendar cita' });
|
|
102
|
+
await waitFor(() => expect(open).toBeEnabled());
|
|
103
|
+
await user.click(open);
|
|
104
|
+
await waitFor(() => expect(today()).toBeEnabled());
|
|
105
|
+
await user.click(today());
|
|
106
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
107
|
+
await user.click(await screen.findByRole('button', { name: /^09:00/ }));
|
|
108
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
109
|
+
await user.click(await screen.findByRole('button', { name: 'Reservar' }));
|
|
110
|
+
}
|
|
111
|
+
describe('MaterialBookingStep', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.mocked(fetchBookingOffer).mockResolvedValue(OFFER);
|
|
114
|
+
vi.mocked(fetchBookingSlots).mockResolvedValue([SLOT]);
|
|
115
|
+
vi.mocked(holdBookingSlot).mockResolvedValue(HELD);
|
|
116
|
+
});
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.useRealTimers();
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
});
|
|
121
|
+
it('holds with the name and email the form already has, and keeps what the hold says', async () => {
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
renderStep(bookingStep(), { 'email-step': 'ana@correo.com', 'name-step': 'Ana Gómez' });
|
|
124
|
+
await reserveFirstHour(user);
|
|
125
|
+
expect(holdBookingSlot).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'form-key' }), {
|
|
126
|
+
slot: 'slot-1',
|
|
127
|
+
contact: { name: 'Ana Gómez', email: 'ana@correo.com' },
|
|
128
|
+
});
|
|
129
|
+
// Hours and person come from the hold, not from the button that was clicked.
|
|
130
|
+
expect(await screen.findByText(/09:15–09:45 · con Bruno Pérez/)).toBeInTheDocument();
|
|
131
|
+
expect(screen.getByText(/Envía el formulario para confirmar la cita/)).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
it('asks for the email in the picker when the step is set to FIELD', async () => {
|
|
134
|
+
const user = userEvent.setup();
|
|
135
|
+
renderStep(bookingStep({ contactEmail: { source: BookingContactEmailSourceTypes.FIELD } }), {
|
|
136
|
+
'email-step': 'ana@correo.com',
|
|
137
|
+
'name-step': 'Ana Gómez',
|
|
138
|
+
});
|
|
139
|
+
const open = await screen.findByRole('button', { name: 'Agendar cita' });
|
|
140
|
+
await waitFor(() => expect(open).toBeEnabled());
|
|
141
|
+
await user.click(open);
|
|
142
|
+
await waitFor(() => expect(today()).toBeEnabled());
|
|
143
|
+
await user.click(today());
|
|
144
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
145
|
+
await user.click(await screen.findByRole('button', { name: /^09:00/ }));
|
|
146
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
147
|
+
expect(await screen.findByLabelText(/Correo/)).toBeInTheDocument();
|
|
148
|
+
expect(screen.queryByLabelText(/Nombre/)).not.toBeInTheDocument();
|
|
149
|
+
expect(screen.getByRole('button', { name: 'Reservar' })).toBeDisabled();
|
|
150
|
+
});
|
|
151
|
+
it('renews with the renewal token and, once the edge says the hold is gone, sends the requester back', async () => {
|
|
152
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
153
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
154
|
+
vi.mocked(renewBookingHold)
|
|
155
|
+
.mockResolvedValueOnce({ ...HELD, renew: 'renew-2', lockedUntil: '2026-10-15T13:05:00.000Z' })
|
|
156
|
+
.mockRejectedValueOnce({
|
|
157
|
+
response: {
|
|
158
|
+
status: 409,
|
|
159
|
+
data: { code: 'HOLD_EXPIRED', message: 'La reserva expiró; elige un horario nuevamente' },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
renderStep(bookingStep(), { 'email-step': 'ana@correo.com', 'name-step': 'Ana Gómez' });
|
|
163
|
+
await reserveFirstHour(user);
|
|
164
|
+
expect(await screen.findByRole('button', { name: 'Cambiar' })).toBeInTheDocument();
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
167
|
+
});
|
|
168
|
+
expect(renewBookingHold).toHaveBeenLastCalledWith(expect.anything(), { renew: 'renew-1', hold: 'secret-1' });
|
|
169
|
+
await act(async () => {
|
|
170
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
171
|
+
});
|
|
172
|
+
expect(renewBookingHold).toHaveBeenLastCalledWith(expect.anything(), { renew: 'renew-2', hold: 'secret-1' });
|
|
173
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Tu reserva venció; elige la hora de nuevo.');
|
|
174
|
+
expect(screen.getByRole('button', { name: 'Agendar cita' })).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
it('reloads the offer and retries once when a person token went stale', async () => {
|
|
177
|
+
const user = userEvent.setup();
|
|
178
|
+
vi.mocked(fetchBookingOffer).mockImplementation((_route, query) => Promise.resolve(query?.person
|
|
179
|
+
? { ...OFFER, days: [TODAY] }
|
|
180
|
+
: {
|
|
181
|
+
...OFFER,
|
|
182
|
+
personSelection: 'REQUIRED',
|
|
183
|
+
persons: [
|
|
184
|
+
{
|
|
185
|
+
token: vi.mocked(fetchBookingOffer).mock.calls.length > 1 ? 'p-new' : 'p-old',
|
|
186
|
+
name: 'Dra. Ruiz',
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
}));
|
|
190
|
+
vi.mocked(fetchBookingSlots)
|
|
191
|
+
.mockRejectedValueOnce({
|
|
192
|
+
response: { status: 403, data: { code: 'STALE_OFFER', message: 'Esa persona ya no está disponible' } },
|
|
193
|
+
})
|
|
194
|
+
.mockResolvedValueOnce([SLOT]);
|
|
195
|
+
renderStep(bookingStep({ personSelectionMode: PersonSelectionMode.REQUIRED }), {
|
|
196
|
+
'email-step': 'ana@correo.com',
|
|
197
|
+
'name-step': 'Ana Gómez',
|
|
198
|
+
});
|
|
199
|
+
const open = await screen.findByRole('button', { name: 'Agendar cita' });
|
|
200
|
+
await waitFor(() => expect(open).toBeEnabled());
|
|
201
|
+
await user.click(open);
|
|
202
|
+
await user.click(await screen.findByText('Dra. Ruiz'));
|
|
203
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
204
|
+
await waitFor(() => expect(today()).toBeEnabled());
|
|
205
|
+
await user.click(today());
|
|
206
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
207
|
+
expect(await screen.findByRole('button', { name: /^09:00/ })).toBeInTheDocument();
|
|
208
|
+
expect(vi.mocked(fetchBookingSlots).mock.calls.map((call) => call[2]?.person)).toEqual(['p-old', 'p-new']);
|
|
209
|
+
});
|
|
210
|
+
it('lets the requester pick the event template and books with its handle and duration', async () => {
|
|
211
|
+
const user = userEvent.setup();
|
|
212
|
+
vi.mocked(fetchBookingOffer).mockResolvedValue({
|
|
213
|
+
...OFFER,
|
|
214
|
+
requesterPicksType: true,
|
|
215
|
+
eventTemplates: [
|
|
216
|
+
{ token: 'tt-1', name: 'Valoración', durationMinutes: 30 },
|
|
217
|
+
{ token: 'tt-2', name: 'Asesoría de crédito', description: 'Con un asesor', durationMinutes: 45 },
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
vi.mocked(holdBookingSlot).mockResolvedValue({
|
|
221
|
+
...HELD,
|
|
222
|
+
eventTemplate: { name: 'Asesoría de crédito', durationMinutes: 45 },
|
|
223
|
+
});
|
|
224
|
+
renderStep(bookingStep(), { 'email-step': 'ana@correo.com', 'name-step': 'Ana Gómez' });
|
|
225
|
+
const open = await screen.findByRole('button', { name: 'Agendar cita' });
|
|
226
|
+
await waitFor(() => expect(open).toBeEnabled());
|
|
227
|
+
await user.click(open);
|
|
228
|
+
await user.click(await screen.findByRole('button', { name: /Asesoría de crédito/ }));
|
|
229
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
230
|
+
await waitFor(() => expect(today()).toBeEnabled());
|
|
231
|
+
await user.click(today());
|
|
232
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
233
|
+
await user.click(await screen.findByRole('button', { name: /^09:00/ }));
|
|
234
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
235
|
+
await user.click(await screen.findByRole('button', { name: 'Reservar' }));
|
|
236
|
+
expect(fetchBookingOffer).toHaveBeenLastCalledWith(expect.anything(), { eventTemplate: 'tt-2' });
|
|
237
|
+
expect(fetchBookingSlots).toHaveBeenLastCalledWith(expect.anything(), TODAY, { eventTemplate: 'tt-2' });
|
|
238
|
+
expect(holdBookingSlot).toHaveBeenCalledWith(expect.anything(), {
|
|
239
|
+
slot: 'slot-1',
|
|
240
|
+
contact: { name: 'Ana Gómez', email: 'ana@correo.com' },
|
|
241
|
+
eventTemplate: 'tt-2',
|
|
242
|
+
});
|
|
243
|
+
expect(await screen.findByText('Asesoría de crédito · 45 min')).toBeInTheDocument();
|
|
244
|
+
});
|
|
245
|
+
it('asks for no type when the step does not let the requester pick one', async () => {
|
|
246
|
+
const user = userEvent.setup();
|
|
247
|
+
vi.mocked(fetchBookingOffer).mockResolvedValue({
|
|
248
|
+
...OFFER,
|
|
249
|
+
requesterPicksType: false,
|
|
250
|
+
eventTemplates: [
|
|
251
|
+
{ token: 'tt-1', name: 'Valoración', durationMinutes: 30 },
|
|
252
|
+
{ token: 'tt-2', name: 'Asesoría de crédito', durationMinutes: 45 },
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
renderStep(bookingStep(), { 'email-step': 'ana@correo.com', 'name-step': 'Ana Gómez' });
|
|
256
|
+
await reserveFirstHour(user);
|
|
257
|
+
expect(holdBookingSlot).toHaveBeenCalledWith(expect.anything(), {
|
|
258
|
+
slot: 'slot-1',
|
|
259
|
+
contact: { name: 'Ana Gómez', email: 'ana@correo.com' },
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
it('offers to retry when the offer does not load, and loads it on retry', async () => {
|
|
263
|
+
const user = userEvent.setup();
|
|
264
|
+
vi.mocked(fetchBookingOffer)
|
|
265
|
+
.mockRejectedValueOnce({ response: { status: 500, data: { code: 'INTERNAL', message: 'Algo falló' } } })
|
|
266
|
+
.mockResolvedValue(OFFER);
|
|
267
|
+
renderStep(bookingStep(), { 'email-step': 'ana@correo.com', 'name-step': 'Ana Gómez' });
|
|
268
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Algo falló');
|
|
269
|
+
expect(screen.getByRole('button', { name: 'Agendar cita' })).toBeDisabled();
|
|
270
|
+
await user.click(screen.getByRole('button', { name: 'Reintentar' }));
|
|
271
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Agendar cita' })).toBeEnabled());
|
|
272
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
273
|
+
expect(fetchBookingOffer).toHaveBeenCalledTimes(2);
|
|
274
|
+
});
|
|
275
|
+
});
|