@arquimedes.co/eureka-forms 3.0.71 → 3.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -10,8 +10,6 @@
10
10
  */
11
11
  import { GBaseStep } from './GenericFormSteps';
12
12
  import FormStepTypes from '../constants/FormStepTypes';
13
- import { DynamicRef } from './StepRef';
14
- import { CalendarValue } from './CalendarFormStep';
15
13
  /**
16
14
  * Modalidad de la cita.
17
15
  */
@@ -41,6 +39,37 @@ export declare enum PersonSelectionMode {
41
39
  */
42
40
  REQUIRED = "REQUIRED"
43
41
  }
42
+ /**
43
+ * De dónde sale el correo al que se envía la confirmación de la cita.
44
+ */
45
+ export declare enum BookingContactEmailSourceTypes {
46
+ /** Un paso de correo anterior del formulario: el picker lo lee y no pide el correo. */
47
+ STEP = "STEP",
48
+ /** El picker pide el correo en el agendamiento, validado y obligatorio. */
49
+ FIELD = "FIELD"
50
+ }
51
+ /**
52
+ * El correo de confirmación del paso. `stepId` es obligatorio cuando `source === STEP`.
53
+ */
54
+ export interface BookingContactEmail {
55
+ source: BookingContactEmailSourceTypes;
56
+ stepId?: string;
57
+ }
58
+ /**
59
+ * Whose mailbox organizes the meeting of a confirmed appointment. The backend resolves it; forms only
60
+ * mirrors the field.
61
+ */
62
+ export declare enum BookingOrganizerSourceTypes {
63
+ /** The mailbox of the agent who takes the appointment. */
64
+ AGENT = "AGENT",
65
+ /** A fixed mailbox, `email`. */
66
+ MAILBOX = "MAILBOX"
67
+ }
68
+ /** The organizer of the step's meetings. `email` is required when `source === MAILBOX`. */
69
+ export interface BookingOrganizer {
70
+ source: BookingOrganizerSourceTypes;
71
+ email?: string;
72
+ }
44
73
  /**
45
74
  * Perfil de una persona cuyo calendario se puede agendar.
46
75
  */
@@ -88,6 +117,14 @@ export interface BookingStep extends GBaseStep {
88
117
  * @default false
89
118
  */
90
119
  fallbackToGeneral?: boolean;
120
+ /**
121
+ * With several event templates on offer, the requester picks one before the day and the hour.
122
+ * With a single template there is nothing to pick.
123
+ * @default true
124
+ */
125
+ requesterPicksType?: boolean;
126
+ /** Whose mailbox organizes the meeting; absent means the agent's. */
127
+ organizer?: BookingOrganizer;
91
128
  /**
92
129
  * Cómo se presenta la selección de persona al usuario.
93
130
  * @default PersonSelectionMode.NONE
@@ -99,17 +136,10 @@ export interface BookingStep extends GBaseStep {
99
136
  */
100
137
  persons?: BookablePerson[];
101
138
  /**
102
- * Permite que el formulario sea enviado sin identificar al usuario (agendamiento anónimo).
103
- * El backend genera un token temporal para rastrear la reserva.
104
- * @default false
105
- */
106
- allowAnonymous?: boolean;
107
- /**
108
- * Si el paso recopila datos de contacto (nombre/correo) del que agenda.
109
- * Desactívalo cuando el booking va dentro de un formulario mayor que ya los captura.
110
- * @default false
139
+ * De dónde sale el correo de confirmación. El nombre del que agenda se pide siempre (o se reutiliza
140
+ * de un paso de nombre ya diligenciado); el correo lo lee de un paso anterior o lo pide el picker.
111
141
  */
112
- collectContact?: boolean;
142
+ contactEmail: BookingContactEmail;
113
143
  /** Duración en minutos de cada slot de cita. */
114
144
  durationMinutes: number;
115
145
  /**
@@ -134,13 +164,28 @@ export interface BookingStep extends GBaseStep {
134
164
  */
135
165
  temporaryLockMinutes?: number;
136
166
  /**
137
- * Referencia a un paso `CALENDAR` en el mismo formulario cuya disponibilidad
138
- * se usa para filtrar los slots. Útil cuando el dueño del calendario también
139
- * llena el formulario y define su disponibilidad en el mismo flujo.
167
+ * Separación entre las horas de inicio ofrecidas, anclada al reloj; divisor de 60.
168
+ * @default 15
140
169
  */
141
- availabilityRef?: DynamicRef<CalendarValue, 'AVAILABILITY'>;
170
+ slotIntervalMinutes?: number;
171
+ /**
172
+ * Ofrece también el inicio de cada hueco que deja una reunión o una cita. Ausente cuenta como `true`.
173
+ * @default true
174
+ */
175
+ fillGaps?: boolean;
142
176
  size: 1 | 2 | 3 | 4;
143
177
  }
178
+ /**
179
+ * An event template the requester can pick, as the picker shows it. `id` is the offer's signed handle,
180
+ * never the template's id.
181
+ */
182
+ export interface BookingEventTemplateOption {
183
+ id: string;
184
+ name: string;
185
+ description?: string;
186
+ durationMinutes: number;
187
+ bookingMode?: BookingMode;
188
+ }
144
189
  /**
145
190
  * Un slot de tiempo disponible para agendamiento.
146
191
  * El backend calcula esto cruzando el calendario del profesional con el horario configurado.
@@ -154,6 +199,8 @@ export interface BookingSlot {
154
199
  endTime: string;
155
200
  /** Si el slot sigue disponible para ser reservado. */
156
201
  available: boolean;
202
+ /** Instante ISO de inicio, cuando el host lo conoce; permite mostrar la hora en el reloj de quien agenda. */
203
+ start?: string;
157
204
  }
158
205
  /**
159
206
  * Valor que se guarda tras completar un paso de tipo `BOOKING`.
@@ -172,9 +219,14 @@ export interface BookingValue {
172
219
  personId?: string;
173
220
  /** Nombre de la persona (incluido si `personSelectionMode !== NONE`). */
174
221
  personName?: string;
175
- /** Nombre de contacto del que agenda (solo si `collectContact`). */
222
+ /** The event template the requester picked, as the hold answered it. */
223
+ eventTemplate?: {
224
+ name: string;
225
+ durationMinutes: number;
226
+ };
227
+ /** Nombre de contacto del que agenda (escrito en el picker o tomado de un paso de nombre). */
176
228
  contactName?: string;
177
- /** Correo de contacto del que agenda (solo si `collectContact`). */
229
+ /** Correo de contacto del que agenda (escrito en el picker o tomado del paso de correo). */
178
230
  contactEmail?: string;
179
231
  /**
180
232
  * Estado del slot en el ciclo de vida del formulario.
@@ -42,3 +42,24 @@ export var PersonSelectionMode;
42
42
  */
43
43
  PersonSelectionMode["REQUIRED"] = "REQUIRED";
44
44
  })(PersonSelectionMode || (PersonSelectionMode = {}));
45
+ /**
46
+ * De dónde sale el correo al que se envía la confirmación de la cita.
47
+ */
48
+ export var BookingContactEmailSourceTypes;
49
+ (function (BookingContactEmailSourceTypes) {
50
+ /** Un paso de correo anterior del formulario: el picker lo lee y no pide el correo. */
51
+ BookingContactEmailSourceTypes["STEP"] = "STEP";
52
+ /** El picker pide el correo en el agendamiento, validado y obligatorio. */
53
+ BookingContactEmailSourceTypes["FIELD"] = "FIELD";
54
+ })(BookingContactEmailSourceTypes || (BookingContactEmailSourceTypes = {}));
55
+ /**
56
+ * Whose mailbox organizes the meeting of a confirmed appointment. The backend resolves it; forms only
57
+ * mirrors the field.
58
+ */
59
+ export var BookingOrganizerSourceTypes;
60
+ (function (BookingOrganizerSourceTypes) {
61
+ /** The mailbox of the agent who takes the appointment. */
62
+ BookingOrganizerSourceTypes["AGENT"] = "AGENT";
63
+ /** A fixed mailbox, `email`. */
64
+ BookingOrganizerSourceTypes["MAILBOX"] = "MAILBOX";
65
+ })(BookingOrganizerSourceTypes || (BookingOrganizerSourceTypes = {}));
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import '@testing-library/jest-dom';
6
+ import App from './App';
7
+ import FormStepTypes from '../constants/FormStepTypes';
8
+ import { SUBMIT_ERROR_MESSAGE } from '../Form/SubmitError/SubmitErrorReason';
9
+ const { post } = vi.hoisted(() => ({ post: vi.fn() }));
10
+ vi.mock('../Utils/AxiosWidget', async (importOriginal) => {
11
+ const original = await importOriginal();
12
+ return { ...original, default: Object.assign(Object.create(original.default), { post }) };
13
+ });
14
+ const FORM = {
15
+ name: 'Contacto',
16
+ apiKey: 'form-key',
17
+ firstSection: 'FIRST',
18
+ sections: { FIRST: { id: 'FIRST', name: '', nextSection: null, steps: ['comment'] } },
19
+ steps: {
20
+ comment: {
21
+ id: 'comment',
22
+ idSection: 'FIRST',
23
+ stepPath: ['comment'],
24
+ type: FormStepTypes.TEXTINPUT,
25
+ label: 'Comentario',
26
+ description: null,
27
+ required: false,
28
+ size: 4,
29
+ editable: true,
30
+ },
31
+ },
32
+ size: { blockNum: 4, blockSize: 210, spacingSize: 20 },
33
+ };
34
+ describe('Form submission that fails', () => {
35
+ it('tells the requester, keeps what they filled in and sends it again on retry', async () => {
36
+ const user = userEvent.setup();
37
+ post.mockRejectedValueOnce({
38
+ response: { status: 409, data: 'La reserva expiró; elige un horario nuevamente' },
39
+ }).mockResolvedValueOnce({ data: { case: 'ABC123', url: 'https://example.com', idTicket: 't1' } });
40
+ render(_jsx(App, { formData: FORM, idOrganization: "acme", internal: true, isWidget: true, editable: true }));
41
+ const comment = await screen.findByLabelText(/Comentario/);
42
+ await user.type(comment, 'Necesito ayuda');
43
+ await user.click(screen.getByTestId('form__submit'));
44
+ const alert = await screen.findByRole('alert');
45
+ expect(alert).toHaveTextContent(SUBMIT_ERROR_MESSAGE);
46
+ expect(alert).toHaveTextContent('La reserva expiró; elige un horario nuevamente');
47
+ expect(screen.getByLabelText(/Comentario/)).toHaveValue('Necesito ayuda');
48
+ await user.click(screen.getByRole('button', { name: 'Reintentar' }));
49
+ await waitFor(() => expect(screen.queryByText(SUBMIT_ERROR_MESSAGE)).not.toBeInTheDocument());
50
+ expect(post).toHaveBeenCalledTimes(2);
51
+ expect(post.mock.calls[1][1]).toEqual(expect.objectContaining({ comment: 'Necesito ayuda' }));
52
+ });
53
+ });
@@ -0,0 +1,5 @@
1
+ import { BookingAppointment } from '../Services/BookingService';
2
+ /** "Martes 14 de octubre", en la zona de la organización. */
3
+ export declare function appointmentDay(iso: string, timeZone: string): string;
4
+ /** "Martes 14 de octubre, 09:00–09:30". */
5
+ export declare function appointmentWhen(appointment: Pick<BookingAppointment, 'start' | 'end' | 'timeZone'>): string;
@@ -0,0 +1,20 @@
1
+ import { clockIn } from '../Services/BookingService';
2
+ /** "Martes 14 de octubre", en la zona de la organización. */
3
+ export function appointmentDay(iso, timeZone) {
4
+ const parts = {};
5
+ for (const part of new Intl.DateTimeFormat('es-CO', {
6
+ weekday: 'long',
7
+ day: 'numeric',
8
+ month: 'long',
9
+ timeZone,
10
+ }).formatToParts(new Date(iso))) {
11
+ parts[part.type] = part.value;
12
+ }
13
+ const text = `${parts.weekday} ${parts.day} de ${parts.month}`;
14
+ return text.charAt(0).toUpperCase() + text.slice(1);
15
+ }
16
+ /** "Martes 14 de octubre, 09:00–09:30". */
17
+ export function appointmentWhen(appointment) {
18
+ const { start, end, timeZone } = appointment;
19
+ return `${appointmentDay(start, timeZone)}, ${clockIn(start, timeZone)}–${clockIn(end, timeZone)}`;
20
+ }
@@ -0,0 +1,17 @@
1
+ export interface BookingManagePageProps {
2
+ /** El handle del enlace del correo (`/booking/<token>`). */
3
+ token: string;
4
+ /** La organización, cuando la página no se sirve desde su subdominio de formularios. */
5
+ idOrganization?: string;
6
+ }
7
+ /**
8
+ * La página del enlace "Cancela o cambia la hora de tu cita" de los correos de agendamiento.
9
+ *
10
+ * Muestra la cita en el reloj de la organización y deja cancelarla (con confirmación) o moverla a otra
11
+ * hora con la misma persona. Las horas salen de la oferta del mismo paso del formulario, igual que la
12
+ * primera vez, con el mismo `ErkBookingPicker`; elegir una mueve la cita en el momento, sin formulario
13
+ * que enviar. Cada respuesta trae el handle vigente de la cita (cambia al moverla), y es el que se usa
14
+ * en el siguiente pedido.
15
+ */
16
+ declare function BookingManagePage({ token, idOrganization }: BookingManagePageProps): JSX.Element;
17
+ export default BookingManagePage;
@@ -0,0 +1,177 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Dialog } from '@mui/material';
4
+ import { BookingMode, PersonSelectionMode } from '../@Types/BookingFormStep';
5
+ import ErkBookingPicker from '../Shared/ErkBookingPicker/ErkBookingPicker';
6
+ import ErkButton from '../Shared/ErkButton/ErkButton';
7
+ import Loader from '../Shared/Loader/Loader';
8
+ import MaterialProviders from '../Utils/MaterialProviders';
9
+ import InternalFormStyle from '../constants/InternalFormStyle';
10
+ import { BookingErrorCodes, cancelBookingAppointment, clockIn, fetchBookingAppointment, fetchBookingOffer, fetchBookingSlots, moveBookingAppointment, toBookingError, toPickerSlots, zoneLabel, } from '../Services/BookingService';
11
+ import { appointmentDay, appointmentWhen } from './AppointmentText';
12
+ import styles from './BookingManagePage.module.css';
13
+ const MODE_LABEL = {
14
+ [BookingMode.IN_PERSON]: 'Presencial',
15
+ [BookingMode.VIRTUAL]: 'Virtual',
16
+ [BookingMode.HYBRID]: 'Presencial o virtual',
17
+ };
18
+ const MOVE_COPY = {
19
+ open: 'Elegir otra hora',
20
+ confirm: 'Cambiar la hora',
21
+ confirming: 'Cambiando…',
22
+ title: 'Revisa la nueva hora',
23
+ note: 'Moveremos tu cita a esta hora y te enviaremos la confirmación por correo.',
24
+ };
25
+ /**
26
+ * La página del enlace "Cancela o cambia la hora de tu cita" de los correos de agendamiento.
27
+ *
28
+ * Muestra la cita en el reloj de la organización y deja cancelarla (con confirmación) o moverla a otra
29
+ * hora con la misma persona. Las horas salen de la oferta del mismo paso del formulario, igual que la
30
+ * primera vez, con el mismo `ErkBookingPicker`; elegir una mueve la cita en el momento, sin formulario
31
+ * que enviar. Cada respuesta trae el handle vigente de la cita (cambia al moverla), y es el que se usa
32
+ * en el siguiente pedido.
33
+ */
34
+ function BookingManagePage({ token, idOrganization }) {
35
+ const [appointment, setAppointment] = useState(undefined);
36
+ const [loadError, setLoadError] = useState(undefined);
37
+ const [notice, setNotice] = useState(undefined);
38
+ const [confirmCancel, setConfirmCancel] = useState(false);
39
+ const [cancelling, setCancelling] = useState(false);
40
+ const [cancelError, setCancelError] = useState(undefined);
41
+ /** The appointment the callbacks read; the state copy only drives rendering. */
42
+ const current = useRef(undefined);
43
+ const adopt = useCallback((loaded) => {
44
+ current.current = loaded;
45
+ setAppointment(loaded);
46
+ }, []);
47
+ useEffect(() => {
48
+ let cancelled = false;
49
+ fetchBookingAppointment(token, idOrganization)
50
+ .then((loaded) => {
51
+ if (!cancelled)
52
+ adopt(loaded);
53
+ })
54
+ .catch((e) => {
55
+ if (!cancelled)
56
+ setLoadError(toBookingError(e, 'No pudimos cargar tu cita.').message);
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [token, idOrganization, adopt]);
62
+ const reschedule = appointment?.reschedule;
63
+ const route = useMemo(() => (reschedule ? { apiKey: reschedule.apiKey, idStep: reschedule.idStep, idOrganization } : undefined), [reschedule, idOrganization]);
64
+ /**
65
+ * Reads the step's offer with the appointment's person and, when it has one, its event template, so
66
+ * the new hour keeps the type and its duration. A person handle lives two hours; a stale one
67
+ * (403 `STALE_OFFER`) reloads the appointment, which hands out a fresh one, and the read is retried once.
68
+ */
69
+ const withPerson = useCallback(async (call, fallback) => {
70
+ const person = current.current?.reschedule?.person;
71
+ if (!person)
72
+ throw new Error(fallback);
73
+ try {
74
+ return await call(person);
75
+ }
76
+ catch (e) {
77
+ const refusal = toBookingError(e, fallback);
78
+ if (refusal.code !== BookingErrorCodes.STALE_OFFER || !current.current)
79
+ throw refusal;
80
+ try {
81
+ const reloaded = await fetchBookingAppointment(current.current.token, idOrganization);
82
+ adopt(reloaded);
83
+ if (!reloaded.reschedule)
84
+ throw refusal;
85
+ return await call(reloaded.reschedule.person);
86
+ }
87
+ catch (retryError) {
88
+ throw toBookingError(retryError, fallback);
89
+ }
90
+ }
91
+ }, [idOrganization, adopt]);
92
+ const fetchDays = useCallback(async () => {
93
+ if (!route)
94
+ return [];
95
+ return withPerson(async (person) => (await fetchBookingOffer(route, { person, eventTemplate: current.current?.reschedule?.eventTemplate }))
96
+ .days, 'No pudimos cargar los días disponibles.');
97
+ }, [route, withPerson]);
98
+ const fetchSlots = useCallback(async ({ date }) => {
99
+ if (!route)
100
+ return [];
101
+ return withPerson(async (person) => toPickerSlots(await fetchBookingSlots(route, date, {
102
+ person,
103
+ eventTemplate: current.current?.reschedule?.eventTemplate,
104
+ }), current.current?.timeZone ?? 'UTC'), 'No pudimos cargar los horarios de este día.');
105
+ }, [route, withPerson]);
106
+ const moveTo = useCallback(async ({ slot }) => {
107
+ const from = current.current;
108
+ if (!from)
109
+ throw new Error('No pudimos cambiar la hora de tu cita.');
110
+ try {
111
+ const moved = await moveBookingAppointment(from.token, slot.id, idOrganization);
112
+ adopt(moved);
113
+ setNotice(`Listo. Tu cita quedó para el ${appointmentWhen(moved)}. Te enviamos la confirmación por correo.`);
114
+ return {};
115
+ }
116
+ catch (e) {
117
+ throw toBookingError(e, 'No pudimos cambiar la hora de tu cita. Intenta de nuevo.');
118
+ }
119
+ }, [idOrganization, adopt]);
120
+ const cancel = async () => {
121
+ const from = current.current;
122
+ if (!from)
123
+ return;
124
+ setCancelling(true);
125
+ setCancelError(undefined);
126
+ try {
127
+ adopt(await cancelBookingAppointment(from.token, idOrganization));
128
+ setConfirmCancel(false);
129
+ setNotice('Tu cita quedó cancelada. Te enviamos la confirmación por correo.');
130
+ }
131
+ catch (e) {
132
+ setCancelError(toBookingError(e, 'No pudimos cancelar tu cita. Intenta de nuevo.').message);
133
+ }
134
+ finally {
135
+ setCancelling(false);
136
+ }
137
+ };
138
+ const renderBody = () => {
139
+ if (loadError) {
140
+ return (_jsx("p", { className: styles.error, role: "alert", children: loadError }));
141
+ }
142
+ if (!appointment) {
143
+ return (_jsx("div", { className: styles.loading, children: _jsx(Loader, { size: 32 }) }));
144
+ }
145
+ const confirmed = appointment.status === 'CONFIRMED';
146
+ const rows = [
147
+ { label: 'Fecha', value: appointmentDay(appointment.start, appointment.timeZone) },
148
+ {
149
+ label: 'Hora',
150
+ value: `${clockIn(appointment.start, appointment.timeZone)}–${clockIn(appointment.end, appointment.timeZone)} (${zoneLabel(appointment.timeZone, new Date(appointment.start))})`,
151
+ },
152
+ ];
153
+ if (appointment.person) {
154
+ const role = appointment.person.role ? `, ${appointment.person.role}` : '';
155
+ rows.push({ label: 'Con', value: `${appointment.person.name}${role}` });
156
+ }
157
+ const templateName = appointment.eventTemplate?.name ?? appointment.eventTemplateName;
158
+ if (templateName) {
159
+ const templateDuration = appointment.eventTemplate
160
+ ? ` (${appointment.eventTemplate.durationMinutes} min)`
161
+ : '';
162
+ rows.push({ label: 'Tipo de atención', value: `${templateName}${templateDuration}` });
163
+ }
164
+ if (appointment.bookingMode && MODE_LABEL[appointment.bookingMode]) {
165
+ rows.push({ label: 'Modalidad', value: MODE_LABEL[appointment.bookingMode] });
166
+ }
167
+ if (appointment.joinUrl) {
168
+ rows.push({ label: 'Reunión', value: 'Unirse a la reunión', href: appointment.joinUrl });
169
+ }
170
+ return (_jsxs(_Fragment, { children: [!confirmed && (_jsx("p", { className: styles.cancelled, role: "status", children: "Esta cita est\u00E1 cancelada." })), _jsx("dl", { className: confirmed ? styles.details : `${styles.details} ${styles.detailsCancelled}`, children: rows.map((row) => (_jsxs("div", { className: styles.row, children: [_jsx("dt", { children: row.label }), _jsx("dd", { children: row.href ? (_jsx("a", { href: row.href, target: "_blank", rel: "noreferrer", children: row.value })) : (row.value) })] }, row.label))) }), notice && (_jsx("p", { className: styles.notice, role: "status", children: notice })), confirmed && (_jsxs("div", { className: styles.actions, children: [reschedule && (_jsx(ErkBookingPicker, { value: null, onChange: () => undefined, label: "\u00BFNecesitas otra hora?", description: `Elige otro día u hora con ${appointment.person?.name ?? 'la misma persona'}.`, durationMinutes: reschedule.durationMinutes, bookingMode: appointment.bookingMode, personSelectionMode: PersonSelectionMode.NONE, timeZone: appointment.timeZone, fetchDays: fetchDays, fetchSlots: fetchSlots, holdSlot: moveTo, withoutContact: true, copy: MOVE_COPY }, appointment.token)), _jsxs("div", { className: styles.cancelRow, children: [_jsx("span", { children: "\u00BFNo puedes asistir?" }), _jsx(ErkButton, { text: "Cancelar cita", variant: "outlined", color: "error", onClick: () => {
171
+ setCancelError(undefined);
172
+ setConfirmCancel(true);
173
+ } })] })] }))] }));
174
+ };
175
+ return (_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsxs("main", { className: styles.page, children: [_jsxs("section", { className: styles.card, children: [_jsx("h1", { className: styles.title, children: "Tu cita" }), renderBody()] }), _jsx(Dialog, { open: confirmCancel, onClose: () => !cancelling && setConfirmCancel(false), maxWidth: "xs", children: _jsxs("div", { className: styles.dialog, children: [_jsx("h2", { className: styles.dialogTitle, children: "\u00BFCancelar tu cita?" }), _jsxs("p", { children: [appointment ? `${appointmentWhen(appointment)}. ` : '', "Liberaremos este horario y avisaremos a", ' ', appointment?.person?.name ?? 'la persona que te iba a atender', ". No se puede deshacer."] }), cancelError && (_jsx("p", { className: styles.error, role: "alert", children: cancelError })), _jsxs("div", { className: styles.dialogActions, children: [_jsx(ErkButton, { text: "Volver", variant: "outlined", disabled: cancelling, onClick: () => setConfirmCancel(false) }), _jsx(ErkButton, { text: "S\u00ED, cancelar", color: "error", loading: cancelling, onClick: () => void cancel() })] })] }) })] }) }));
176
+ }
177
+ export default BookingManagePage;
@@ -0,0 +1,115 @@
1
+ .page {
2
+ min-height: 100vh;
3
+ box-sizing: border-box;
4
+ padding: 32px 16px;
5
+ background: #f5f7fa;
6
+ color: #293241;
7
+ font-family: inherit;
8
+ }
9
+
10
+ .card {
11
+ max-width: 560px;
12
+ margin: 0 auto;
13
+ padding: 24px;
14
+ box-sizing: border-box;
15
+ border: 1px solid #d9dee6;
16
+ border-radius: 16px;
17
+ background: #fff;
18
+ box-shadow: 0 12px 34px rgba(43, 65, 93, 0.08);
19
+ }
20
+
21
+ .title {
22
+ margin: 0 0 16px;
23
+ font-size: 1.5rem;
24
+ font-weight: 600;
25
+ }
26
+
27
+ .loading {
28
+ display: flex;
29
+ justify-content: center;
30
+ padding: 24px 0;
31
+ }
32
+
33
+ .details {
34
+ margin: 0 0 16px;
35
+ }
36
+
37
+ .detailsCancelled {
38
+ opacity: 0.6;
39
+ text-decoration: line-through;
40
+ }
41
+
42
+ .row {
43
+ display: grid;
44
+ grid-template-columns: 140px 1fr;
45
+ gap: 8px;
46
+ padding: 6px 0;
47
+ border-bottom: 1px solid #eef1f5;
48
+ }
49
+
50
+ .row dt {
51
+ font-weight: 600;
52
+ }
53
+
54
+ .row dd {
55
+ margin: 0;
56
+ overflow-wrap: anywhere;
57
+ }
58
+
59
+ .cancelled {
60
+ margin: 0 0 12px;
61
+ padding: 10px 12px;
62
+ border-radius: 10px;
63
+ background: #fdecee;
64
+ color: #cc2936;
65
+ }
66
+
67
+ .notice {
68
+ margin: 0 0 16px;
69
+ padding: 10px 12px;
70
+ border-radius: 10px;
71
+ background: #e8f4ec;
72
+ color: #1e6b3a;
73
+ }
74
+
75
+ .error {
76
+ margin: 0 0 12px;
77
+ color: #cc2936;
78
+ }
79
+
80
+ .actions {
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: 16px;
84
+ }
85
+
86
+ .cancelRow {
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: space-between;
90
+ gap: 12px;
91
+ flex-wrap: wrap;
92
+ }
93
+
94
+ .dialog {
95
+ padding: 20px 24px;
96
+ }
97
+
98
+ .dialogTitle {
99
+ margin: 0 0 8px;
100
+ font-size: 1.25rem;
101
+ }
102
+
103
+ .dialogActions {
104
+ display: flex;
105
+ justify-content: flex-end;
106
+ gap: 8px;
107
+ margin-top: 16px;
108
+ }
109
+
110
+ @media (max-width: 480px) {
111
+ .row {
112
+ grid-template-columns: 1fr;
113
+ gap: 2px;
114
+ }
115
+ }
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';