@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,4 +1,4 @@
1
- import { BookingSlot } from '../@Types/BookingFormStep';
1
+ import { BookingSlot, BookingValue } from '../@Types/BookingFormStep';
2
2
  /**
3
3
  * Cómo llegar al paso BOOKING de un formulario publicado. Misma regla que el envío del formulario:
4
4
  * un form embebido o interno nombra su organización y sale por el dominio de forms; uno servido por
@@ -18,6 +18,20 @@ export interface BookingOfferPerson {
18
18
  avatarUrl?: string;
19
19
  description?: string;
20
20
  }
21
+ /** An event template of the step as the offer publishes it: a signed handle, never the template's id. */
22
+ export interface BookingOfferEventTemplate {
23
+ token: string;
24
+ name: string;
25
+ description?: string;
26
+ /** The template's own duration, or the step's when the template has none. */
27
+ durationMinutes: number;
28
+ bookingMode?: string;
29
+ }
30
+ /** The event template an appointment was booked with: what the summary and the manage page show. */
31
+ export interface BookingEventTemplateSummary {
32
+ name: string;
33
+ durationMinutes: number;
34
+ }
21
35
  /** Lo que el picker necesita para dibujarse: en qué zona horaria leer las horas y qué días tienen cupo. */
22
36
  export interface BookingOffer {
23
37
  timeZone: string;
@@ -29,6 +43,15 @@ export interface BookingOffer {
29
43
  days: string[];
30
44
  from: string;
31
45
  to: string;
46
+ /** The step's event templates with something on offer; empty when the step has none. */
47
+ eventTemplates?: BookingOfferEventTemplate[];
48
+ /** The requester picks an event template first: the step lets them and there are two or more. */
49
+ requesterPicksType?: boolean;
50
+ }
51
+ /** What narrows a read of the offer: one person's calendar, one event template's slots and duration. */
52
+ export interface BookingOfferQuery {
53
+ person?: string;
54
+ eventTemplate?: string;
32
55
  }
33
56
  /** Un slot tal como lo ofrece el backend: instantes ISO y el handle firmado que lo nombra. */
34
57
  export interface BookingOfferSlot {
@@ -36,29 +59,119 @@ export interface BookingOfferSlot {
36
59
  end: string;
37
60
  token: string;
38
61
  }
39
- /** Un slot retirado de la oferta mientras se termina el formulario. */
62
+ /**
63
+ * Un slot retirado de la oferta mientras se termina el formulario. `start`/`end` son los instantes
64
+ * reales reservados y `person` es quien atenderá (el servidor la elige cuando hay varias libres).
65
+ */
40
66
  export interface BookingHold {
41
67
  appointment: string;
68
+ /** Secreto del dueño de la reserva: viaja con el envío del formulario. */
42
69
  hold: string;
43
70
  lockedUntil: string;
71
+ /** Token para la próxima renovación; vive hasta `lockedUntil` y cada renovación devuelve uno nuevo. */
72
+ renew: string;
73
+ start: string;
74
+ end: string;
75
+ /** Falta solo si el agente dejó la organización. */
76
+ person?: BookingOfferPerson;
77
+ /** The event template the hold was made with, when the requester picked one. */
78
+ eventTemplate?: BookingEventTemplateSummary;
44
79
  }
45
80
  export interface BookingContact {
46
81
  name: string;
47
- email?: string;
82
+ email: string;
48
83
  phone?: string;
49
84
  }
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>;
85
+ /** Los códigos con los que el borde de agendamiento explica un rechazo. */
86
+ export declare enum BookingErrorCodes {
87
+ INVALID_REQUEST = "INVALID_REQUEST",
88
+ NOT_FOUND = "NOT_FOUND",
89
+ FORBIDDEN = "FORBIDDEN",
90
+ /** Un token de persona o de cupo venció: hay que recargar la oferta (o las horas). */
91
+ STALE_OFFER = "STALE_OFFER",
92
+ CONTACT_REQUIRED = "CONTACT_REQUIRED",
93
+ SLOT_TAKEN = "SLOT_TAKEN",
94
+ /** La renovación llegó tarde: la reserva ya venció. */
95
+ HOLD_EXPIRED = "HOLD_EXPIRED",
96
+ /** El enlace de la cita venció, fue alterado o ya no nombra ninguna cita. */
97
+ INVALID_LINK = "INVALID_LINK",
98
+ /** La cita ya no se puede cancelar ni mover: está cancelada o su paso ya no existe. */
99
+ APPOINTMENT_CLOSED = "APPOINTMENT_CLOSED",
100
+ RATE_LIMITED = "RATE_LIMITED",
101
+ METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED",
102
+ INTERNAL = "INTERNAL"
103
+ }
104
+ /** Un rechazo del borde ya leído: el mensaje es apto para mostrarse a quien agenda. */
105
+ export declare class BookingEdgeError extends Error {
106
+ readonly code?: string;
107
+ readonly status?: number;
108
+ constructor(message: string, code?: string, status?: number);
109
+ }
110
+ /** El texto que muestra el picker cuando la renovación de la reserva falla para siempre. */
111
+ export declare const HOLD_LAPSED_MESSAGE = "Tu reserva venci\u00F3; elige la hora de nuevo.";
112
+ /**
113
+ * Una cita ya agendada, vista desde el enlace de sus correos: cuándo, con quién y cómo, y lo que quien
114
+ * la agendó todavía puede hacer con ella.
115
+ */
116
+ export interface BookingAppointment {
117
+ /** El handle del enlace para esta cita; cambia cuando la cita se mueve, y el siguiente pedido usa este. */
118
+ token: string;
119
+ status: 'CONFIRMED' | 'CANCELLED';
120
+ start: string;
121
+ end: string;
122
+ /** La zona horaria de la organización, en la que se muestran `start` y `end`. */
123
+ timeZone: string;
124
+ person?: Omit<BookingOfferPerson, 'token'>;
125
+ bookingMode?: string;
126
+ eventTemplateName?: string;
127
+ eventTemplate?: BookingEventTemplateSummary;
128
+ joinUrl?: string;
129
+ /**
130
+ * Cómo ofrecer otra hora con la misma persona; falta cuando la cita ya no se puede mover desde aquí.
131
+ * `eventTemplate` is the handle of the appointment's template, so the new hour keeps its type.
132
+ */
133
+ reschedule?: {
134
+ apiKey: string;
135
+ idStep: string;
136
+ person: string;
137
+ durationMinutes: number;
138
+ eventTemplate?: string;
139
+ };
140
+ }
141
+ /** La cita que nombra el enlace. Un enlace vencido o alterado responde 404 `INVALID_LINK`. */
142
+ export declare function fetchBookingAppointment(token: string, idOrganization?: string): Promise<BookingAppointment>;
143
+ /** Cancela la cita; responde la cita ya cancelada. Una cita que ya no está vigente responde 409 `APPOINTMENT_CLOSED`. */
144
+ export declare function cancelBookingAppointment(token: string, idOrganization?: string): Promise<BookingAppointment>;
145
+ /**
146
+ * Mueve la cita al cupo `slot` (un handle de la oferta del mismo paso y la misma persona). Responde la
147
+ * cita movida con su handle nuevo; 409 `SLOT_TAKEN` si alguien tomó la hora primero.
148
+ */
149
+ export declare function moveBookingAppointment(token: string, slot: string, idOrganization?: string): Promise<BookingAppointment>;
150
+ /**
151
+ * La oferta del paso; con `person`, la oferta de esa persona (los días que ella tiene libres), y con
152
+ * `eventTemplate`, los días de ese tipo de atención con su duración.
153
+ */
154
+ export declare function fetchBookingOffer(route: BookingRoute, query?: BookingOfferQuery): Promise<BookingOffer>;
52
155
  /** 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[]>;
156
+ export declare function fetchBookingSlots(route: BookingRoute, date: string, query?: BookingOfferQuery): Promise<BookingOfferSlot[]>;
54
157
  /**
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ó.
158
+ * Bloquea un slot a nombre de quien agenda. El borde responde 409 `SLOT_TAKEN` cuando nadie quedó
159
+ * libre a esa hora y 422 `CONTACT_REQUIRED` sin nombre o sin correo válido. `eventTemplate` is the
160
+ * handle of the type the requester picked; the appointment takes that type's duration.
57
161
  */
58
162
  export declare function holdBookingSlot(route: BookingRoute, body: {
59
163
  slot: string;
60
- hold?: string;
61
- contact?: BookingContact;
164
+ contact: BookingContact;
165
+ eventTemplate?: string;
166
+ }): Promise<BookingHold>;
167
+ /**
168
+ * Extiende una reserva propia con el `renew` de la última respuesta. No depende del token del cupo,
169
+ * así que una reserva renovada a tiempo vive lo que tarde el formulario. Una reserva ya vencida
170
+ * responde 409 `HOLD_EXPIRED`.
171
+ */
172
+ export declare function renewBookingHold(route: BookingRoute, body: {
173
+ renew: string;
174
+ hold: string;
62
175
  }): Promise<BookingHold>;
63
176
  /** Devuelve un slot antes de que venza su bloqueo, para que se ofrezca de nuevo de inmediato. */
64
177
  export declare function releaseBookingSlot(route: BookingRoute, body: {
@@ -71,9 +184,43 @@ export declare function releaseBookingSlot(route: BookingRoute, body: {
71
184
  * que agenda esté en Madrid.
72
185
  */
73
186
  export declare function clockIn(iso: string, timeZone: string): string;
187
+ /** El día `YYYY-MM-DD` de un instante en una zona horaria. */
188
+ export declare function dayIn(iso: string, timeZone: string): string;
189
+ /** La zona horaria del navegador, o `undefined` si no se puede leer. */
190
+ export declare function browserTimeZone(): string | undefined;
191
+ /**
192
+ * Cómo se nombra la zona de la organización junto a las horas: "hora de Bogotá (GMT-5)". El desfase
193
+ * se calcula en `at`, porque en las zonas con horario de verano cambia según el día.
194
+ */
195
+ export declare function zoneLabel(timeZone: string, at?: Date): string;
74
196
  /** 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
197
  export declare function toPickerSlot(slot: BookingOfferSlot, timeZone: string): BookingSlot;
198
+ /**
199
+ * Las horas de un día como las muestra el picker: una por instante y en orden. El borde ya agrupa por
200
+ * instante; si llegara un instante repetido, gana el primero y el servidor elige a la persona al reservar.
201
+ */
202
+ export declare function toPickerSlots(slots: BookingOfferSlot[], timeZone: string): BookingSlot[];
203
+ /**
204
+ * Lo que la reserva aporta al valor del paso: día y horas reales en el reloj de la organización, la
205
+ * persona asignada y el tipo de atención elegido.
206
+ */
207
+ export declare function holdToValue(held: BookingHold, timeZone: string): Pick<BookingValue, 'appointment' | 'hold' | 'lockedUntil' | 'date' | 'startTime' | 'endTime'> & Partial<Pick<BookingValue, 'personId' | 'personName' | 'eventTemplate'>>;
76
208
  /** Un texto opcional de un campo de contacto: vacío o solo espacios cuenta como no dado. */
77
209
  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;
210
+ /** Si un texto es un correo con forma válida (lo mismo que exige el borde al reservar). */
211
+ export declare function isValidEmail(text?: string): boolean;
212
+ /** El código HTTP de un error del cliente HTTP, si lo hay. */
213
+ export declare function statusOf(error: unknown): number | undefined;
214
+ /**
215
+ * Lee un rechazo del borde. El borde responde `{ code, message }` con el mensaje en español; los
216
+ * errores que el gateway responde antes de la lambda siguen llegando como texto plano.
217
+ */
218
+ export declare function toBookingError(error: unknown, fallback?: string): BookingEdgeError;
219
+ /** El motivo de un rechazo del borde, o un mensaje genérico. */
220
+ export declare function bookingErrorMessage(error: unknown, fallback?: string): string;
221
+ /**
222
+ * Si una renovación fallida significa que la reserva ya no existe: el borde lo dice (`HOLD_EXPIRED`,
223
+ * o el token venció), o el plazo pasó sin una renovación que llegara. Un corte de red o un 429 antes
224
+ * del plazo se reintenta en la siguiente vuelta.
225
+ */
226
+ export declare function holdLapsed(error: BookingEdgeError, lockedUntil: string | undefined, now?: number): boolean;
@@ -1,5 +1,48 @@
1
1
  import axiosInstance from '../Utils/AxiosAPI';
2
2
  import widgetInstance from '../Utils/AxiosWidget';
3
+ /** Los códigos con los que el borde de agendamiento explica un rechazo. */
4
+ export var BookingErrorCodes;
5
+ (function (BookingErrorCodes) {
6
+ BookingErrorCodes["INVALID_REQUEST"] = "INVALID_REQUEST";
7
+ BookingErrorCodes["NOT_FOUND"] = "NOT_FOUND";
8
+ BookingErrorCodes["FORBIDDEN"] = "FORBIDDEN";
9
+ /** Un token de persona o de cupo venció: hay que recargar la oferta (o las horas). */
10
+ BookingErrorCodes["STALE_OFFER"] = "STALE_OFFER";
11
+ BookingErrorCodes["CONTACT_REQUIRED"] = "CONTACT_REQUIRED";
12
+ BookingErrorCodes["SLOT_TAKEN"] = "SLOT_TAKEN";
13
+ /** La renovación llegó tarde: la reserva ya venció. */
14
+ BookingErrorCodes["HOLD_EXPIRED"] = "HOLD_EXPIRED";
15
+ /** El enlace de la cita venció, fue alterado o ya no nombra ninguna cita. */
16
+ BookingErrorCodes["INVALID_LINK"] = "INVALID_LINK";
17
+ /** La cita ya no se puede cancelar ni mover: está cancelada o su paso ya no existe. */
18
+ BookingErrorCodes["APPOINTMENT_CLOSED"] = "APPOINTMENT_CLOSED";
19
+ BookingErrorCodes["RATE_LIMITED"] = "RATE_LIMITED";
20
+ BookingErrorCodes["METHOD_NOT_ALLOWED"] = "METHOD_NOT_ALLOWED";
21
+ BookingErrorCodes["INTERNAL"] = "INTERNAL";
22
+ })(BookingErrorCodes || (BookingErrorCodes = {}));
23
+ /** Un rechazo del borde ya leído: el mensaje es apto para mostrarse a quien agenda. */
24
+ export class BookingEdgeError extends Error {
25
+ constructor(message, code, status) {
26
+ super(message);
27
+ Object.defineProperty(this, "code", {
28
+ enumerable: true,
29
+ configurable: true,
30
+ writable: true,
31
+ value: void 0
32
+ });
33
+ Object.defineProperty(this, "status", {
34
+ enumerable: true,
35
+ configurable: true,
36
+ writable: true,
37
+ value: void 0
38
+ });
39
+ this.name = 'BookingEdgeError';
40
+ this.code = code;
41
+ this.status = status;
42
+ }
43
+ }
44
+ /** El texto que muestra el picker cuando la renovación de la reserva falla para siempre. */
45
+ export const HOLD_LAPSED_MESSAGE = 'Tu reserva venció; elige la hora de nuevo.';
3
46
  function client(route) {
4
47
  return route.idOrganization || route.internal ? widgetInstance : axiosInstance;
5
48
  }
@@ -14,24 +57,62 @@ function url(route, suffix, params = {}) {
14
57
  const query = search.toString();
15
58
  return `/booking/${route.apiKey}/${route.idStep}${suffix}${query ? `?${query}` : ''}`;
16
59
  }
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 }));
60
+ function appointmentUrl(token, idOrganization, suffix = '') {
61
+ const query = idOrganization ? `?${new URLSearchParams({ idOrganization }).toString()}` : '';
62
+ return `/booking/appointment/${encodeURIComponent(token)}${suffix}${query}`;
63
+ }
64
+ function appointmentClient(idOrganization) {
65
+ return idOrganization ? widgetInstance : axiosInstance;
66
+ }
67
+ /** La cita que nombra el enlace. Un enlace vencido o alterado responde 404 `INVALID_LINK`. */
68
+ export async function fetchBookingAppointment(token, idOrganization) {
69
+ const response = await appointmentClient(idOrganization).get(appointmentUrl(token, idOrganization));
70
+ return response.data;
71
+ }
72
+ /** Cancela la cita; responde la cita ya cancelada. Una cita que ya no está vigente responde 409 `APPOINTMENT_CLOSED`. */
73
+ export async function cancelBookingAppointment(token, idOrganization) {
74
+ const response = await appointmentClient(idOrganization).post(appointmentUrl(token, idOrganization, '/cancel'));
75
+ return response.data;
76
+ }
77
+ /**
78
+ * Mueve la cita al cupo `slot` (un handle de la oferta del mismo paso y la misma persona). Responde la
79
+ * cita movida con su handle nuevo; 409 `SLOT_TAKEN` si alguien tomó la hora primero.
80
+ */
81
+ export async function moveBookingAppointment(token, slot, idOrganization) {
82
+ const response = await appointmentClient(idOrganization).post(appointmentUrl(token, idOrganization, '/move'), { slot });
83
+ return response.data;
84
+ }
85
+ /**
86
+ * La oferta del paso; con `person`, la oferta de esa persona (los días que ella tiene libres), y con
87
+ * `eventTemplate`, los días de ese tipo de atención con su duración.
88
+ */
89
+ export async function fetchBookingOffer(route, query = {}) {
90
+ const response = await client(route).get(url(route, '', { person: query.person, eventTemplate: query.eventTemplate }));
20
91
  return response.data;
21
92
  }
22
93
  /** 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 }));
94
+ export async function fetchBookingSlots(route, date, query = {}) {
95
+ const response = await client(route).get(url(route, '/slots', { date, person: query.person, eventTemplate: query.eventTemplate }));
25
96
  return response.data.slots;
26
97
  }
27
98
  /**
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ó.
99
+ * Bloquea un slot a nombre de quien agenda. El borde responde 409 `SLOT_TAKEN` cuando nadie quedó
100
+ * libre a esa hora y 422 `CONTACT_REQUIRED` sin nombre o sin correo válido. `eventTemplate` is the
101
+ * handle of the type the requester picked; the appointment takes that type's duration.
30
102
  */
31
103
  export async function holdBookingSlot(route, body) {
32
104
  const response = await client(route).post(url(route, '/hold'), body);
33
105
  return response.data;
34
106
  }
107
+ /**
108
+ * Extiende una reserva propia con el `renew` de la última respuesta. No depende del token del cupo,
109
+ * así que una reserva renovada a tiempo vive lo que tarde el formulario. Una reserva ya vencida
110
+ * responde 409 `HOLD_EXPIRED`.
111
+ */
112
+ export async function renewBookingHold(route, body) {
113
+ const response = await client(route).post(url(route, '/hold'), body);
114
+ return response.data;
115
+ }
35
116
  /** Devuelve un slot antes de que venza su bloqueo, para que se ofrezca de nuevo de inmediato. */
36
117
  export async function releaseBookingSlot(route, body) {
37
118
  await client(route).post(url(route, '/release'), body);
@@ -44,6 +125,49 @@ export async function releaseBookingSlot(route, body) {
44
125
  export function clockIn(iso, timeZone) {
45
126
  return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone }).format(new Date(iso));
46
127
  }
128
+ /** El día `YYYY-MM-DD` de un instante en una zona horaria. */
129
+ export function dayIn(iso, timeZone) {
130
+ return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone }).format(new Date(iso));
131
+ }
132
+ /** La zona horaria del navegador, o `undefined` si no se puede leer. */
133
+ export function browserTimeZone() {
134
+ try {
135
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ /** Ciudades de zonas IANA cuyo nombre en español lleva tilde o cambia. */
142
+ const CITY_NAMES = {
143
+ Bogota: 'Bogotá',
144
+ Mexico_City: 'Ciudad de México',
145
+ Panama: 'Panamá',
146
+ Asuncion: 'Asunción',
147
+ Sao_Paulo: 'São Paulo',
148
+ New_York: 'Nueva York',
149
+ Los_Angeles: 'Los Ángeles',
150
+ Merida: 'Mérida',
151
+ Cancun: 'Cancún',
152
+ };
153
+ /**
154
+ * Cómo se nombra la zona de la organización junto a las horas: "hora de Bogotá (GMT-5)". El desfase
155
+ * se calcula en `at`, porque en las zonas con horario de verano cambia según el día.
156
+ */
157
+ export function zoneLabel(timeZone, at = new Date()) {
158
+ const id = timeZone.split('/').pop() ?? timeZone;
159
+ const city = CITY_NAMES[id] ?? id.replace(/_/g, ' ');
160
+ let offset;
161
+ try {
162
+ offset = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'shortOffset' })
163
+ .formatToParts(at)
164
+ .find((part) => part.type === 'timeZoneName')?.value;
165
+ }
166
+ catch {
167
+ offset = undefined;
168
+ }
169
+ return offset ? `hora de ${city} (${offset})` : `hora de ${city}`;
170
+ }
47
171
  /** 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
172
  export function toPickerSlot(slot, timeZone) {
49
173
  return {
@@ -51,16 +175,90 @@ export function toPickerSlot(slot, timeZone) {
51
175
  startTime: clockIn(slot.start, timeZone),
52
176
  endTime: clockIn(slot.end, timeZone),
53
177
  available: true,
178
+ start: slot.start,
179
+ };
180
+ }
181
+ /**
182
+ * Las horas de un día como las muestra el picker: una por instante y en orden. El borde ya agrupa por
183
+ * instante; si llegara un instante repetido, gana el primero y el servidor elige a la persona al reservar.
184
+ */
185
+ export function toPickerSlots(slots, timeZone) {
186
+ const seen = new Set();
187
+ return [...slots]
188
+ .sort((a, b) => a.start.localeCompare(b.start))
189
+ .filter((slot) => {
190
+ if (seen.has(slot.start))
191
+ return false;
192
+ seen.add(slot.start);
193
+ return true;
194
+ })
195
+ .map((slot) => toPickerSlot(slot, timeZone));
196
+ }
197
+ /**
198
+ * Lo que la reserva aporta al valor del paso: día y horas reales en el reloj de la organización, la
199
+ * persona asignada y el tipo de atención elegido.
200
+ */
201
+ export function holdToValue(held, timeZone) {
202
+ return {
203
+ appointment: held.appointment,
204
+ hold: held.hold,
205
+ lockedUntil: held.lockedUntil,
206
+ date: dayIn(held.start, timeZone),
207
+ startTime: clockIn(held.start, timeZone),
208
+ endTime: clockIn(held.end, timeZone),
209
+ ...(held.person ? { personId: held.person.token, personName: held.person.name } : {}),
210
+ ...(held.eventTemplate
211
+ ? { eventTemplate: { name: held.eventTemplate.name, durationMinutes: held.eventTemplate.durationMinutes } }
212
+ : {}),
54
213
  };
55
214
  }
56
215
  /** Un texto opcional de un campo de contacto: vacío o solo espacios cuenta como no dado. */
57
216
  export function nonEmpty(text) {
58
- return text?.trim() ? text : undefined;
217
+ return text?.trim() ? text.trim() : undefined;
218
+ }
219
+ /** Si un texto es un correo con forma válida (lo mismo que exige el borde al reservar). */
220
+ export function isValidEmail(text) {
221
+ return !!text && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text.trim());
222
+ }
223
+ /** El código HTTP de un error del cliente HTTP, si lo hay. */
224
+ export function statusOf(error) {
225
+ return error?.response?.status;
59
226
  }
60
- /** El motivo de un rechazo del backend, que llega como texto plano, o un mensaje genérico. */
61
- export function bookingErrorMessage(error) {
227
+ /**
228
+ * Lee un rechazo del borde. El borde responde `{ code, message }` con el mensaje en español; los
229
+ * errores que el gateway responde antes de la lambda siguen llegando como texto plano.
230
+ */
231
+ export function toBookingError(error, fallback = 'No pudimos reservar ese horario. Intenta de nuevo.') {
232
+ if (error instanceof BookingEdgeError)
233
+ return error;
234
+ const status = statusOf(error);
62
235
  const data = error?.response?.data;
236
+ if (data && typeof data === 'object') {
237
+ const { code, message } = data;
238
+ if (typeof message === 'string' && message.trim()) {
239
+ return new BookingEdgeError(message, typeof code === 'string' ? code : undefined, status);
240
+ }
241
+ }
63
242
  if (typeof data === 'string' && data.trim())
64
- return data;
65
- return 'No pudimos reservar ese horario. Intenta de nuevo.';
243
+ return new BookingEdgeError(data, undefined, status);
244
+ if (status === 429) {
245
+ return new BookingEdgeError('Hay muchas consultas en este momento. Intenta de nuevo en unos segundos.', BookingErrorCodes.RATE_LIMITED, status);
246
+ }
247
+ return new BookingEdgeError(fallback, undefined, status);
248
+ }
249
+ /** El motivo de un rechazo del borde, o un mensaje genérico. */
250
+ export function bookingErrorMessage(error, fallback) {
251
+ return toBookingError(error, fallback).message;
252
+ }
253
+ /**
254
+ * Si una renovación fallida significa que la reserva ya no existe: el borde lo dice (`HOLD_EXPIRED`,
255
+ * o el token venció), o el plazo pasó sin una renovación que llegara. Un corte de red o un 429 antes
256
+ * del plazo se reintenta en la siguiente vuelta.
257
+ */
258
+ export function holdLapsed(error, lockedUntil, now = Date.now()) {
259
+ if (error.code === BookingErrorCodes.HOLD_EXPIRED || error.code === BookingErrorCodes.STALE_OFFER)
260
+ return true;
261
+ if (error.code === BookingErrorCodes.NOT_FOUND || error.code === BookingErrorCodes.INVALID_REQUEST)
262
+ return true;
263
+ return !!lockedUntil && new Date(lockedUntil).getTime() <= now;
66
264
  }
@@ -2,7 +2,7 @@
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import axiosInstance from '../Utils/AxiosAPI';
4
4
  import widgetInstance from '../Utils/AxiosWidget';
5
- import { bookingErrorMessage, clockIn, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, releaseBookingSlot, toPickerSlot, } from './BookingService';
5
+ import { BookingEdgeError, BookingErrorCodes, bookingErrorMessage, cancelBookingAppointment, clockIn, dayIn, fetchBookingAppointment, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, holdLapsed, holdToValue, isValidEmail, moveBookingAppointment, releaseBookingSlot, renewBookingHold, toBookingError, toPickerSlot, toPickerSlots, zoneLabel, } from './BookingService';
6
6
  vi.mock('../Utils/AxiosWidget', () => ({
7
7
  default: { get: vi.fn(), post: vi.fn() },
8
8
  }));
@@ -20,13 +20,93 @@ describe('BookingService', () => {
20
20
  expect(clockIn('2026-09-22T14:00:00.000Z', 'America/Bogota')).toBe('09:00');
21
21
  expect(clockIn('2026-09-22T14:00:00.000Z', 'Europe/Madrid')).toBe('16:00');
22
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 });
23
+ expect(toPickerSlot({ start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'tok' }, 'America/Bogota')).toEqual({
24
+ id: 'tok',
25
+ startTime: '09:00',
26
+ endTime: '09:30',
27
+ available: true,
28
+ start: '2026-09-22T14:00:00.000Z',
29
+ });
30
+ });
31
+ it('shows one hour per instant, in order', () => {
32
+ const slots = toPickerSlots([
33
+ { start: '2026-09-22T14:15:00.000Z', end: '2026-09-22T14:45:00.000Z', token: 'b' },
34
+ { start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'a' },
35
+ { start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'a2' },
36
+ ], 'America/Bogota');
37
+ expect(slots.map((slot) => [slot.id, slot.startTime])).toEqual([
38
+ ['a', '09:00'],
39
+ ['b', '09:15'],
40
+ ]);
41
+ });
42
+ it('names the organization clock with its offset on the given day', () => {
43
+ expect(zoneLabel('America/Bogota', new Date('2026-10-14T12:00:00Z'))).toBe('hora de Bogotá (GMT-5)');
44
+ expect(zoneLabel('Europe/Madrid', new Date('2026-07-01T12:00:00Z'))).toBe('hora de Madrid (GMT+2)');
45
+ expect(zoneLabel('Europe/Madrid', new Date('2026-12-01T12:00:00Z'))).toBe('hora de Madrid (GMT+1)');
46
+ });
47
+ it('takes the day, hours and assigned person of a hold from the hold itself', () => {
48
+ // 04:30Z is still the previous day in Bogotá.
49
+ expect(dayIn('2026-10-15T04:30:00.000Z', 'America/Bogota')).toBe('2026-10-14');
50
+ expect(holdToValue({
51
+ appointment: 'a1',
52
+ hold: 'h1',
53
+ lockedUntil: '2026-10-14T15:30:00.000Z',
54
+ renew: 'r1',
55
+ start: '2026-10-15T14:00:00.000Z',
56
+ end: '2026-10-15T14:30:00.000Z',
57
+ person: { token: 'pt', name: 'Bruno Pérez', role: 'Asesor' },
58
+ }, 'America/Bogota')).toEqual({
59
+ appointment: 'a1',
60
+ hold: 'h1',
61
+ lockedUntil: '2026-10-14T15:30:00.000Z',
62
+ date: '2026-10-15',
63
+ startTime: '09:00',
64
+ endTime: '09:30',
65
+ personId: 'pt',
66
+ personName: 'Bruno Pérez',
67
+ });
68
+ });
69
+ it('keeps the event template the hold was made with', () => {
70
+ expect(holdToValue({
71
+ appointment: 'a1',
72
+ hold: 'h1',
73
+ lockedUntil: '2026-10-14T15:30:00.000Z',
74
+ renew: 'r1',
75
+ start: '2026-10-15T14:00:00.000Z',
76
+ end: '2026-10-15T14:45:00.000Z',
77
+ eventTemplate: { name: 'Asesoría de crédito', durationMinutes: 45 },
78
+ }, 'America/Bogota').eventTemplate).toEqual({ name: 'Asesoría de crédito', durationMinutes: 45 });
79
+ });
80
+ it('narrows the offer, the slots and the hold to the picked event template', async () => {
81
+ vi.mocked(axiosInstance.get).mockResolvedValue({ data: { days: [], slots: [] } });
82
+ vi.mocked(axiosInstance.post).mockResolvedValue({ data: {} });
83
+ await fetchBookingOffer(ROUTE, { eventTemplate: 'tt-1' });
84
+ expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1?eventTemplate=tt-1');
85
+ await fetchBookingSlots(ROUTE, '2026-09-22', { person: 'p', eventTemplate: 'tt-1' });
86
+ expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/slots?date=2026-09-22&person=p&eventTemplate=tt-1');
87
+ await holdBookingSlot(ROUTE, {
88
+ slot: 's',
89
+ contact: { name: 'Ana', email: 'ana@correo.com' },
90
+ eventTemplate: 'tt-1',
91
+ });
92
+ expect(axiosInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/hold', {
93
+ slot: 's',
94
+ contact: { name: 'Ana', email: 'ana@correo.com' },
95
+ eventTemplate: 'tt-1',
96
+ });
97
+ });
98
+ it('accepts only well-formed emails', () => {
99
+ expect(isValidEmail('juan@correo.com')).toBe(true);
100
+ expect(isValidEmail(' juan@correo.com ')).toBe(true);
101
+ expect(isValidEmail('juan@')).toBe(false);
102
+ expect(isValidEmail('juan correo.com')).toBe(false);
103
+ expect(isValidEmail(undefined)).toBe(false);
24
104
  });
25
105
  it('talks to the same-origin API for a form served under the organization', async () => {
26
106
  vi.mocked(axiosInstance.get).mockResolvedValue({ data: { days: ['2026-09-22'] } });
27
107
  await fetchBookingOffer(ROUTE);
28
108
  expect(axiosInstance.get).toHaveBeenCalledWith('/booking/form-key/BOOKING-1');
29
- await fetchBookingSlots(ROUTE, '2026-09-22', 'person-token');
109
+ await fetchBookingSlots(ROUTE, '2026-09-22', { person: 'person-token' });
30
110
  expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/slots?date=2026-09-22&person=person-token');
31
111
  expect(widgetInstance.get).not.toHaveBeenCalled();
32
112
  });
@@ -34,14 +114,23 @@ describe('BookingService', () => {
34
114
  vi.mocked(widgetInstance.get).mockResolvedValue({ data: { days: [] } });
35
115
  vi.mocked(widgetInstance.post).mockResolvedValue({ data: { appointment: 'a1', hold: 'h1', lockedUntil: 'x' } });
36
116
  const route = { ...ROUTE, idOrganization: 'acme' };
37
- await fetchBookingOffer(route, 'p');
117
+ await fetchBookingOffer(route, { person: 'p' });
38
118
  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' } });
119
+ const held = await holdBookingSlot(route, {
120
+ slot: 'slot-token',
121
+ contact: { name: 'Ana', email: 'ana@correo.com' },
122
+ });
40
123
  expect(widgetInstance.post).toHaveBeenCalledWith('/booking/form-key/BOOKING-1/hold?idOrganization=acme', {
41
124
  slot: 'slot-token',
42
- contact: { name: 'Ana' },
125
+ contact: { name: 'Ana', email: 'ana@correo.com' },
43
126
  });
44
127
  expect(held).toEqual({ appointment: 'a1', hold: 'h1', lockedUntil: 'x' });
128
+ // A renewal carries only the renewal token and the hold secret: no slot, no contact.
129
+ await renewBookingHold(route, { renew: 'r1', hold: 'h1' });
130
+ expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/hold?idOrganization=acme', {
131
+ renew: 'r1',
132
+ hold: 'h1',
133
+ });
45
134
  await releaseBookingSlot(route, { appointment: 'a1', hold: 'h1' });
46
135
  expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/release?idOrganization=acme', {
47
136
  appointment: 'a1',
@@ -49,9 +138,41 @@ describe('BookingService', () => {
49
138
  });
50
139
  expect(axiosInstance.get).not.toHaveBeenCalled();
51
140
  });
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.');
141
+ it('talks about an appointment by its link, on the same origin or naming the organization', async () => {
142
+ vi.mocked(axiosInstance.get).mockResolvedValue({ data: { token: 't1' } });
143
+ vi.mocked(widgetInstance.post).mockResolvedValue({ data: { token: 't2' } });
144
+ await expect(fetchBookingAppointment('a.b')).resolves.toEqual({ token: 't1' });
145
+ expect(axiosInstance.get).toHaveBeenCalledWith('/booking/appointment/a.b');
146
+ await expect(cancelBookingAppointment('a.b', 'acme')).resolves.toEqual({ token: 't2' });
147
+ expect(widgetInstance.post).toHaveBeenCalledWith('/booking/appointment/a.b/cancel?idOrganization=acme');
148
+ await moveBookingAppointment('a.b', 'slot-1', 'acme');
149
+ expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/appointment/a.b/move?idOrganization=acme', {
150
+ slot: 'slot-1',
151
+ });
152
+ });
153
+ it('reads the edge’s { code, message } refusal, plain text, and a generic message otherwise', () => {
154
+ const stale = toBookingError({
155
+ response: { status: 403, data: { code: 'STALE_OFFER', message: 'Esa persona ya no está disponible' } },
156
+ });
157
+ expect(stale).toBeInstanceOf(BookingEdgeError);
158
+ expect(stale.code).toBe(BookingErrorCodes.STALE_OFFER);
159
+ expect(stale.status).toBe(403);
160
+ expect(stale.message).toBe('Esa persona ya no está disponible');
161
+ expect(bookingErrorMessage({ response: { status: 404, data: 'Organization not found' } })).toBe('Organization not found');
162
+ expect(toBookingError({ response: { status: 429, data: '' } }).code).toBe(BookingErrorCodes.RATE_LIMITED);
55
163
  expect(bookingErrorMessage(new Error('network'))).toBe('No pudimos reservar ese horario. Intenta de nuevo.');
164
+ expect(bookingErrorMessage(new Error('network'), 'No pudimos cargar los horarios.')).toBe('No pudimos cargar los horarios.');
165
+ });
166
+ it('gives a hold up only when the edge says so or its time ran out', () => {
167
+ const now = new Date('2026-10-14T15:00:00.000Z').getTime();
168
+ const later = '2026-10-14T15:10:00.000Z';
169
+ const earlier = '2026-10-14T14:59:00.000Z';
170
+ const refusal = (code) => new BookingEdgeError('x', code);
171
+ expect(holdLapsed(refusal(BookingErrorCodes.HOLD_EXPIRED), later, now)).toBe(true);
172
+ expect(holdLapsed(refusal(BookingErrorCodes.STALE_OFFER), later, now)).toBe(true);
173
+ // A network failure or a rate limit is retried on the next round, while the lock still runs.
174
+ expect(holdLapsed(refusal(), later, now)).toBe(false);
175
+ expect(holdLapsed(refusal(BookingErrorCodes.RATE_LIMITED), later, now)).toBe(false);
176
+ expect(holdLapsed(refusal(), earlier, now)).toBe(true);
56
177
  });
57
178
  });