@arquimedes.co/eureka-forms 3.0.68 → 3.0.69

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