@devpablocristo/modules-scheduling 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +49 -0
- package/src/BlockedRangeModal.tsx +256 -0
- package/src/PublicSchedulingFlow.test.tsx +192 -0
- package/src/PublicSchedulingFlow.tsx +570 -0
- package/src/QueueOperatorBoard.test.tsx +169 -0
- package/src/QueueOperatorBoard.tsx +650 -0
- package/src/SchedulingBookingModal.test.tsx +241 -0
- package/src/SchedulingBookingModal.tsx +551 -0
- package/src/SchedulingCalendar.test.tsx +830 -0
- package/src/SchedulingCalendarBoard.tsx +1596 -0
- package/src/SchedulingDaySummary.test.tsx +136 -0
- package/src/SchedulingDaySummary.tsx +190 -0
- package/src/client.test.ts +105 -0
- package/src/client.ts +300 -0
- package/src/index.ts +66 -0
- package/src/locale.test.ts +36 -0
- package/src/locale.ts +91 -0
- package/src/next.ts +66 -0
- package/src/schedulingCalendarLogic.ts +373 -0
- package/src/styles.css +649 -0
- package/src/styles.next.css +1 -0
- package/src/types.ts +653 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devpablocristo/modules-scheduling",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Scheduling calendar, queue operator, and public booking flow for Pymes (React + FullCalendar).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./next": "./src/next.ts",
|
|
9
|
+
"./styles.css": "./src/styles.css",
|
|
10
|
+
"./styles.next.css": "./src/styles.next.css"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@devpablocristo/core-browser": "^0.2.0",
|
|
20
|
+
"@devpablocristo/modules-calendar-board": "^0.1.0",
|
|
21
|
+
"@fullcalendar/core": "^6.1.20",
|
|
22
|
+
"@fullcalendar/interaction": "^6.1.20",
|
|
23
|
+
"@fullcalendar/react": "^6.1.20",
|
|
24
|
+
"@tanstack/react-query": "^5.66.0",
|
|
25
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
26
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run --globals --passWithNoTests"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@testing-library/react": "^14.3.1",
|
|
34
|
+
"@devpablocristo/core-browser": "^0.2.0",
|
|
35
|
+
"@devpablocristo/modules-calendar-board": "^0.1.0",
|
|
36
|
+
"@fullcalendar/core": "^6.1.20",
|
|
37
|
+
"@fullcalendar/interaction": "^6.1.20",
|
|
38
|
+
"@fullcalendar/react": "^6.1.20",
|
|
39
|
+
"@tanstack/react-query": "^5.66.0",
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"@types/react": "^18.3.0",
|
|
42
|
+
"@types/react-dom": "^18.3.0",
|
|
43
|
+
"jsdom": "^24.1.3",
|
|
44
|
+
"react": "^18.3.1",
|
|
45
|
+
"react-dom": "^18.3.1",
|
|
46
|
+
"typescript": "^5.8.0",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
import { confirmAction } from '@devpablocristo/core-browser';
|
|
3
|
+
import type { BlockedRange, BlockedRangeKind, SchedulingCalendarCopy } from './types';
|
|
4
|
+
|
|
5
|
+
export type BlockedRangeModalState =
|
|
6
|
+
| { open: false }
|
|
7
|
+
| {
|
|
8
|
+
open: true;
|
|
9
|
+
mode: 'create';
|
|
10
|
+
branchId: string;
|
|
11
|
+
resourceId: string | null;
|
|
12
|
+
resourceOptions: Array<{ id: string; name: string }>;
|
|
13
|
+
initial: BlockedRangeDraft;
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
open: true;
|
|
17
|
+
mode: 'edit';
|
|
18
|
+
id: string;
|
|
19
|
+
branchId: string;
|
|
20
|
+
resourceOptions: Array<{ id: string; name: string }>;
|
|
21
|
+
initial: BlockedRangeDraft;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type BlockedRangeDraft = {
|
|
25
|
+
kind: BlockedRangeKind;
|
|
26
|
+
reason: string;
|
|
27
|
+
date: string; // YYYY-MM-DD
|
|
28
|
+
startTime: string; // HH:MM
|
|
29
|
+
endTime: string; // HH:MM
|
|
30
|
+
resourceId: string; // '' === all resources
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Props = {
|
|
34
|
+
state: BlockedRangeModalState;
|
|
35
|
+
copy: SchedulingCalendarCopy;
|
|
36
|
+
saving?: boolean;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
onSave: (draft: BlockedRangeDraft) => Promise<void> | void;
|
|
39
|
+
onDelete?: (id: string) => Promise<void> | void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const BLOCKED_KINDS: BlockedRangeKind[] = ['manual', 'holiday', 'maintenance', 'leave'];
|
|
43
|
+
|
|
44
|
+
export function blockedRangeDraftFromRange(range: BlockedRange): BlockedRangeDraft {
|
|
45
|
+
const start = new Date(range.start_at);
|
|
46
|
+
const end = new Date(range.end_at);
|
|
47
|
+
return {
|
|
48
|
+
kind: range.kind,
|
|
49
|
+
reason: range.reason ?? '',
|
|
50
|
+
date: toDateInputValue(start),
|
|
51
|
+
startTime: toTimeInputValue(start),
|
|
52
|
+
endTime: toTimeInputValue(end),
|
|
53
|
+
resourceId: range.resource_id ?? '',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function emptyBlockedRangeDraft(date: string): BlockedRangeDraft {
|
|
58
|
+
return {
|
|
59
|
+
kind: 'manual',
|
|
60
|
+
reason: '',
|
|
61
|
+
date,
|
|
62
|
+
startTime: '09:00',
|
|
63
|
+
endTime: '10:00',
|
|
64
|
+
resourceId: '',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toDateInputValue(value: Date): string {
|
|
69
|
+
const year = value.getFullYear();
|
|
70
|
+
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
71
|
+
const day = String(value.getDate()).padStart(2, '0');
|
|
72
|
+
return `${year}-${month}-${day}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toTimeInputValue(value: Date): string {
|
|
76
|
+
const hours = String(value.getHours()).padStart(2, '0');
|
|
77
|
+
const minutes = String(value.getMinutes()).padStart(2, '0');
|
|
78
|
+
return `${hours}:${minutes}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function BlockedRangeModal({ state, copy, saving = false, onClose, onSave, onDelete }: Props) {
|
|
82
|
+
const [draft, setDraft] = useState<BlockedRangeDraft>(() =>
|
|
83
|
+
state.open ? state.initial : emptyBlockedRangeDraft(toDateInputValue(new Date())),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (state.open) {
|
|
88
|
+
setDraft(state.initial);
|
|
89
|
+
}
|
|
90
|
+
}, [state]);
|
|
91
|
+
|
|
92
|
+
if (!state.open) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
if (!draft.date || !draft.startTime || !draft.endTime) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (draft.startTime >= draft.endTime) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await onSave(draft);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleDeleteClick = async () => {
|
|
108
|
+
if (state.mode !== 'edit' || !onDelete) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const confirmed = await confirmAction({
|
|
112
|
+
title: copy.blockedRangeDeleteTitle,
|
|
113
|
+
description: copy.blockedRangeDeleteDescription,
|
|
114
|
+
confirmLabel: copy.blockedRangeDelete,
|
|
115
|
+
cancelLabel: copy.close,
|
|
116
|
+
tone: 'danger',
|
|
117
|
+
});
|
|
118
|
+
if (!confirmed) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await onDelete(state.id);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const isEdit = state.mode === 'edit';
|
|
125
|
+
const title = isEdit ? copy.blockedRangeEditTitle : copy.blockedRangeCreateTitle;
|
|
126
|
+
const submitLabel = saving ? copy.saving : isEdit ? copy.blockedRangeUpdate : copy.blockedRangeCreate;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="modules-scheduling__backdrop app-modal-backdrop" role="presentation" onClick={onClose}>
|
|
130
|
+
<div
|
|
131
|
+
className="modules-scheduling__modal app-modal"
|
|
132
|
+
role="dialog"
|
|
133
|
+
aria-modal="true"
|
|
134
|
+
onClick={(event) => event.stopPropagation()}
|
|
135
|
+
>
|
|
136
|
+
<div className="app-modal__header">
|
|
137
|
+
<div className="app-modal__title-block">
|
|
138
|
+
<p className="app-modal__eyebrow">{copy.blockedRangeEyebrow}</p>
|
|
139
|
+
<h3 className="app-modal__title">{title}</h3>
|
|
140
|
+
</div>
|
|
141
|
+
<button type="button" className="app-modal__close" aria-label={copy.close} onClick={onClose}>
|
|
142
|
+
×
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<form className="app-modal__body modules-scheduling__modal-form" onSubmit={handleSubmit}>
|
|
147
|
+
<div className="form-group">
|
|
148
|
+
<label htmlFor="blocked-range-kind">{copy.blockedRangeKindLabel}</label>
|
|
149
|
+
<select
|
|
150
|
+
id="blocked-range-kind"
|
|
151
|
+
value={draft.kind}
|
|
152
|
+
onChange={(event) => setDraft((current) => ({ ...current, kind: event.target.value as BlockedRangeKind }))}
|
|
153
|
+
>
|
|
154
|
+
{BLOCKED_KINDS.map((kind) => (
|
|
155
|
+
<option key={kind} value={kind}>
|
|
156
|
+
{copy.blockedRangeKindOptions[kind]}
|
|
157
|
+
</option>
|
|
158
|
+
))}
|
|
159
|
+
</select>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="form-group">
|
|
163
|
+
<label htmlFor="blocked-range-reason">{copy.blockedRangeReasonLabel}</label>
|
|
164
|
+
<input
|
|
165
|
+
id="blocked-range-reason"
|
|
166
|
+
value={draft.reason}
|
|
167
|
+
placeholder={copy.blockedRangeReasonPlaceholder}
|
|
168
|
+
onChange={(event) => setDraft((current) => ({ ...current, reason: event.target.value }))}
|
|
169
|
+
autoFocus
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="modules-scheduling__form-row">
|
|
174
|
+
<div className="form-group grow">
|
|
175
|
+
<label htmlFor="blocked-range-date">{copy.focusDateLabel}</label>
|
|
176
|
+
<input
|
|
177
|
+
id="blocked-range-date"
|
|
178
|
+
type="date"
|
|
179
|
+
value={draft.date}
|
|
180
|
+
onChange={(event) => setDraft((current) => ({ ...current, date: event.target.value }))}
|
|
181
|
+
required
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="form-group grow">
|
|
185
|
+
<label htmlFor="blocked-range-resource">{copy.resourceNameLabel}</label>
|
|
186
|
+
<select
|
|
187
|
+
id="blocked-range-resource"
|
|
188
|
+
value={draft.resourceId}
|
|
189
|
+
onChange={(event) => setDraft((current) => ({ ...current, resourceId: event.target.value }))}
|
|
190
|
+
>
|
|
191
|
+
<option value="">{copy.anyResource}</option>
|
|
192
|
+
{state.resourceOptions.map((resource) => (
|
|
193
|
+
<option key={resource.id} value={resource.id}>
|
|
194
|
+
{resource.name}
|
|
195
|
+
</option>
|
|
196
|
+
))}
|
|
197
|
+
</select>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div className="modules-scheduling__form-row">
|
|
202
|
+
<div className="form-group grow">
|
|
203
|
+
<label htmlFor="blocked-range-start">{copy.slotStartLabel}</label>
|
|
204
|
+
<input
|
|
205
|
+
id="blocked-range-start"
|
|
206
|
+
type="time"
|
|
207
|
+
value={draft.startTime}
|
|
208
|
+
onChange={(event) => setDraft((current) => ({ ...current, startTime: event.target.value }))}
|
|
209
|
+
required
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="form-group grow">
|
|
213
|
+
<label htmlFor="blocked-range-end">{copy.slotEndLabel}</label>
|
|
214
|
+
<input
|
|
215
|
+
id="blocked-range-end"
|
|
216
|
+
type="time"
|
|
217
|
+
value={draft.endTime}
|
|
218
|
+
onChange={(event) => setDraft((current) => ({ ...current, endTime: event.target.value }))}
|
|
219
|
+
required
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="app-modal__footer">
|
|
225
|
+
{isEdit && onDelete ? (
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className="btn-danger btn-sm app-modal__action"
|
|
229
|
+
onClick={() => void handleDeleteClick()}
|
|
230
|
+
disabled={saving}
|
|
231
|
+
>
|
|
232
|
+
{copy.blockedRangeDelete}
|
|
233
|
+
</button>
|
|
234
|
+
) : null}
|
|
235
|
+
<div className="app-modal__footer-spacer" aria-hidden />
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
className="btn-secondary btn-sm app-modal__action"
|
|
239
|
+
disabled={saving}
|
|
240
|
+
onClick={onClose}
|
|
241
|
+
>
|
|
242
|
+
{copy.close}
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
type="submit"
|
|
246
|
+
className="btn-primary btn-sm app-modal__action"
|
|
247
|
+
disabled={saving || draft.startTime >= draft.endTime}
|
|
248
|
+
>
|
|
249
|
+
{submitLabel}
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</form>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import type { ComponentProps } from 'react';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import type { PublicSchedulingClient } from './client';
|
|
8
|
+
import { PublicSchedulingFlow } from './PublicSchedulingFlow';
|
|
9
|
+
import type {
|
|
10
|
+
PublicAvailabilitySlot,
|
|
11
|
+
PublicBooking,
|
|
12
|
+
PublicBusinessInfo,
|
|
13
|
+
PublicQueueSummary,
|
|
14
|
+
PublicService,
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
function createClient(overrides?: Partial<Record<keyof PublicSchedulingClient, unknown>>): PublicSchedulingClient {
|
|
18
|
+
const business: PublicBusinessInfo = {
|
|
19
|
+
org_id: 'org-demo',
|
|
20
|
+
name: 'Demo Org',
|
|
21
|
+
slug: 'demo-org',
|
|
22
|
+
business_name: 'Demo Scheduling',
|
|
23
|
+
business_address: 'Main street 123',
|
|
24
|
+
business_phone: '+54 381 555 0101',
|
|
25
|
+
business_email: 'hello@example.com',
|
|
26
|
+
scheduling_enabled: true,
|
|
27
|
+
appointments_enabled: true,
|
|
28
|
+
};
|
|
29
|
+
const services: PublicService[] = [
|
|
30
|
+
{
|
|
31
|
+
id: 'service-1',
|
|
32
|
+
name: 'Consulta inicial',
|
|
33
|
+
type: 'schedule',
|
|
34
|
+
description: 'Consulta general',
|
|
35
|
+
unit: 'session',
|
|
36
|
+
price: 100,
|
|
37
|
+
currency: 'ARS',
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
const availability: PublicAvailabilitySlot[] = [
|
|
41
|
+
{
|
|
42
|
+
start_at: '2099-04-05T10:00:00Z',
|
|
43
|
+
end_at: '2099-04-05T10:30:00Z',
|
|
44
|
+
remaining: 1,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const queues: PublicQueueSummary[] = [];
|
|
48
|
+
const bookings: PublicBooking[] = [];
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
getBusinessInfo: vi.fn(async () => business),
|
|
52
|
+
listServices: vi.fn(async () => services),
|
|
53
|
+
getAvailability: vi.fn(async () => availability),
|
|
54
|
+
book: vi.fn(async () => ({
|
|
55
|
+
id: 'booking-1',
|
|
56
|
+
party_name: 'Ada Lovelace',
|
|
57
|
+
party_phone: '+54 381 555 0202',
|
|
58
|
+
title: 'Consulta inicial',
|
|
59
|
+
status: 'pending_confirmation',
|
|
60
|
+
start_at: availability[0].start_at,
|
|
61
|
+
end_at: availability[0].end_at,
|
|
62
|
+
duration: 30,
|
|
63
|
+
actions: {
|
|
64
|
+
confirm_token: 'confirm-token',
|
|
65
|
+
cancel_token: 'cancel-token',
|
|
66
|
+
},
|
|
67
|
+
})),
|
|
68
|
+
listMyBookings: vi.fn(async () => bookings),
|
|
69
|
+
listQueues: vi.fn(async () => queues),
|
|
70
|
+
createQueueTicket: vi.fn(),
|
|
71
|
+
getQueuePosition: vi.fn(),
|
|
72
|
+
joinWaitlist: vi.fn(),
|
|
73
|
+
confirmBooking: vi.fn(),
|
|
74
|
+
cancelBooking: vi.fn(),
|
|
75
|
+
...(overrides ?? {}),
|
|
76
|
+
} as PublicSchedulingClient;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderFlow(client: PublicSchedulingClient, props?: Partial<ComponentProps<typeof PublicSchedulingFlow>>) {
|
|
80
|
+
cleanup();
|
|
81
|
+
const queryClient = new QueryClient({
|
|
82
|
+
defaultOptions: {
|
|
83
|
+
queries: { retry: false },
|
|
84
|
+
mutations: { retry: false },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return render(
|
|
89
|
+
<QueryClientProvider client={queryClient}>
|
|
90
|
+
<PublicSchedulingFlow
|
|
91
|
+
client={client}
|
|
92
|
+
orgRef="demo-org"
|
|
93
|
+
locale="es"
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
</QueryClientProvider>,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('PublicSchedulingFlow', () => {
|
|
101
|
+
it('shows the disabled state when public scheduling is turned off', async () => {
|
|
102
|
+
const client = createClient({
|
|
103
|
+
getBusinessInfo: vi.fn(async () => ({
|
|
104
|
+
org_id: 'org-demo',
|
|
105
|
+
name: 'Demo Org',
|
|
106
|
+
slug: 'demo-org',
|
|
107
|
+
business_name: 'Demo Scheduling',
|
|
108
|
+
business_address: '',
|
|
109
|
+
business_phone: '',
|
|
110
|
+
business_email: '',
|
|
111
|
+
scheduling_enabled: false,
|
|
112
|
+
appointments_enabled: false,
|
|
113
|
+
})),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
renderFlow(client);
|
|
117
|
+
|
|
118
|
+
expect(await screen.findByText('La agenda pública está deshabilitada')).toBeTruthy();
|
|
119
|
+
expect(screen.getByText('Activá la agenda de esta organización para exponer el flujo público.')).toBeTruthy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('books the selected slot with the canonical public payload', async () => {
|
|
123
|
+
const client = createClient();
|
|
124
|
+
|
|
125
|
+
renderFlow(client);
|
|
126
|
+
|
|
127
|
+
const selectSlotButton = await screen.findByRole('button', { name: /Elegir slot/i });
|
|
128
|
+
const bookingPhoneInput = document.getElementById('public-scheduling-phone');
|
|
129
|
+
|
|
130
|
+
fireEvent.click(selectSlotButton);
|
|
131
|
+
fireEvent.change(screen.getByLabelText('Cliente'), { target: { value: 'Ada Lovelace' } });
|
|
132
|
+
fireEvent.change(bookingPhoneInput as HTMLElement, { target: { value: '+54 381 555 0202' } });
|
|
133
|
+
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } });
|
|
134
|
+
fireEvent.change(screen.getByLabelText('Notas'), { target: { value: 'Primera visita' } });
|
|
135
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reservar' }));
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(client.book).toHaveBeenCalledWith('demo-org', {
|
|
139
|
+
service_id: 'service-1',
|
|
140
|
+
customer_name: 'Ada Lovelace',
|
|
141
|
+
customer_phone: '+54 381 555 0202',
|
|
142
|
+
customer_email: 'ada@example.com',
|
|
143
|
+
start_at: '2099-04-05T10:00:00Z',
|
|
144
|
+
notes: 'Primera visita',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
expect(await screen.findByText('Reserva creada')).toBeTruthy();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('keeps spanish copy when using a regional spanish locale', async () => {
|
|
151
|
+
const client = createClient();
|
|
152
|
+
|
|
153
|
+
renderFlow(client, { locale: 'es-AR' });
|
|
154
|
+
|
|
155
|
+
expect(await screen.findByRole('button', { name: /Elegir slot/i })).toBeTruthy();
|
|
156
|
+
expect(screen.getByText(/\d{1,2}\/\d{1,2}\/\d{4}/)).toBeTruthy();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('localizes booking action buttons when using english regional locales', async () => {
|
|
160
|
+
const client = createClient({
|
|
161
|
+
listMyBookings: vi.fn(async () => [
|
|
162
|
+
{
|
|
163
|
+
id: 'booking-1',
|
|
164
|
+
party_name: 'Ada Lovelace',
|
|
165
|
+
party_phone: '+54 381 555 0202',
|
|
166
|
+
title: 'Initial consult',
|
|
167
|
+
status: 'pending_confirmation',
|
|
168
|
+
start_at: '2099-04-05T10:00:00Z',
|
|
169
|
+
end_at: '2099-04-05T10:30:00Z',
|
|
170
|
+
duration: 30,
|
|
171
|
+
actions: {
|
|
172
|
+
confirm_token: 'confirm-token',
|
|
173
|
+
cancel_token: 'cancel-token',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
]),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
renderFlow(client, { locale: 'en-US' });
|
|
180
|
+
|
|
181
|
+
const lookupPhoneInput = await screen.findByLabelText('Phone', {
|
|
182
|
+
selector: 'input#public-scheduling-bookings-phone',
|
|
183
|
+
});
|
|
184
|
+
fireEvent.change(lookupPhoneInput, {
|
|
185
|
+
target: { value: '+54 381 555 0202' },
|
|
186
|
+
});
|
|
187
|
+
fireEvent.click(screen.getByRole('button', { name: 'Find bookings' }));
|
|
188
|
+
|
|
189
|
+
expect(await screen.findByRole('button', { name: 'Confirm' })).toBeTruthy();
|
|
190
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy();
|
|
191
|
+
});
|
|
192
|
+
});
|