@arquimedes.co/eureka-forms 3.0.71 → 3.0.72-test

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.
Files changed (45) hide show
  1. package/dist/@Types/BookingFormStep.d.ts +70 -18
  2. package/dist/@Types/BookingFormStep.js +21 -0
  3. package/dist/App/App.submitError.test.d.ts +1 -0
  4. package/dist/App/App.submitError.test.js +53 -0
  5. package/dist/BookingManage/AppointmentText.d.ts +5 -0
  6. package/dist/BookingManage/AppointmentText.js +20 -0
  7. package/dist/BookingManage/BookingManagePage.d.ts +17 -0
  8. package/dist/BookingManage/BookingManagePage.js +177 -0
  9. package/dist/BookingManage/BookingManagePage.module.css +115 -0
  10. package/dist/BookingManage/BookingManagePage.test.d.ts +1 -0
  11. package/dist/BookingManage/BookingManagePage.test.js +149 -0
  12. package/dist/Form/ConfirmationDialog/ConfirmationDialog.d.ts +14 -5
  13. package/dist/Form/ConfirmationDialog/ConfirmationDialog.js +8 -1
  14. package/dist/Form/ConfirmationDialog/ConfirmationDialog.module.css +12 -0
  15. package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.d.ts +1 -0
  16. package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.js +21 -0
  17. package/dist/Form/Form.d.ts +3 -0
  18. package/dist/Form/Form.js +9 -2
  19. package/dist/Form/FormTypes/ColumnForm/ColumnForm.d.ts +1 -1
  20. package/dist/Form/FormTypes/ColumnForm/ColumnForm.js +3 -2
  21. package/dist/Form/FormTypes/StepperForm/StepperForm.d.ts +1 -1
  22. package/dist/Form/FormTypes/StepperForm/StepperForm.js +3 -2
  23. package/dist/Form/SubmitError/SubmitError.d.ts +11 -0
  24. package/dist/Form/SubmitError/SubmitError.js +9 -0
  25. package/dist/Form/SubmitError/SubmitError.module.css +25 -0
  26. package/dist/Form/SubmitError/SubmitError.test.d.ts +1 -0
  27. package/dist/Form/SubmitError/SubmitError.test.js +34 -0
  28. package/dist/Form/SubmitError/SubmitErrorReason.d.ts +13 -0
  29. package/dist/Form/SubmitError/SubmitErrorReason.js +20 -0
  30. package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.d.ts +8 -1
  31. package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.js +197 -41
  32. package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.d.ts +1 -0
  33. package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.js +275 -0
  34. package/dist/Services/BookingService.d.ts +159 -12
  35. package/dist/Services/BookingService.js +210 -12
  36. package/dist/Services/BookingService.test.js +130 -9
  37. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.d.ts +63 -12
  38. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.js +141 -36
  39. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.module.css +41 -3
  40. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.d.ts +11 -3
  41. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.js +69 -9
  42. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.test.js +13 -15
  43. package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.js +331 -62
  44. package/dist/main.js +4 -1
  45. 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 { PersonSelectionMode } from '../../../@Types/BookingFormStep';
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 { bookingErrorMessage, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, nonEmpty, releaseBookingSlot, toPickerSlot, } from '../../../Services/BookingService';
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. Confirmar en el picker bloquea el slot;
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
- setOffer(loaded);
134
+ adoptOffer(loaded);
55
135
  })
56
136
  .catch((e) => {
57
137
  if (!cancelled)
58
- setOfferError(bookingErrorMessage(e));
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 fetchDays = useCallback(async ({ personId }) => {
72
- if (!route || !offer)
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"; 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)
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
- 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 }) => {
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
- return await holdBookingSlot(route, {
217
+ held = await holdBookingSlot(route, {
88
218
  slot: slot.id,
89
- contact: contact?.name ? { name: contact.name, email: nonEmpty(contact.email) } : undefined,
219
+ contact: { name: contact.name, email: contact.email ?? '' },
220
+ ...(template ? { eventTemplate: template } : {}),
90
221
  });
91
222
  }
92
223
  catch (e) {
93
- throw new Error(bookingErrorMessage(e));
224
+ throw toBookingError(e);
94
225
  }
95
- }, [route]);
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 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);
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
- clearInterval(renewal.current);
109
- if (!usesEdge || !route || !value?.appointment || !value.hold || !value.slotId || value.status !== 'PENDING') {
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 { 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);
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(renewal.current);
122
- }, [usesEdge, route, value, step.temporaryLockMinutes]);
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, bufferMinutes: step.bufferMinutes, bookingMode: step.bookingMode, temporaryLockMinutes: step.temporaryLockMinutes, personSelectionMode: personSelectionMode, persons: usesEdge ? persons : step.persons,
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
+ });