@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 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
+ });