@tummycrypt/acuity-middleware 0.1.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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Wizard Step: Extract Confirmation Data
3
+ *
4
+ * Reads the confirmation page to extract booking details:
5
+ * appointment ID, confirmation code, service, date/time, provider.
6
+ * Maps the scraped data to the Booking type.
7
+ */
8
+
9
+ import { Effect } from 'effect';
10
+ import type { Page } from 'playwright-core';
11
+ import { BrowserService } from '../browser-service.js';
12
+ import { WizardStepError } from '../errors.js';
13
+ import { probe, Selectors } from '../selectors.js';
14
+ import type { Booking, BookingRequest, ClientInfo } from '../../core/types.js';
15
+
16
+ // =============================================================================
17
+ // TYPES
18
+ // =============================================================================
19
+
20
+ export interface ConfirmationData {
21
+ readonly appointmentId: string | null;
22
+ readonly confirmationCode: string | null;
23
+ readonly serviceName: string | null;
24
+ readonly datetime: string | null;
25
+ readonly providerName: string | null;
26
+ readonly rawText: string;
27
+ }
28
+
29
+ // =============================================================================
30
+ // IMPLEMENTATION
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Extract booking confirmation data from the current page.
35
+ * Assumes we're already on the confirmation page.
36
+ */
37
+ export const extractConfirmation = () =>
38
+ Effect.gen(function* () {
39
+ const { acquirePage, screenshot, config } = yield* BrowserService;
40
+ const page: Page = yield* acquirePage;
41
+
42
+ // Verify we're on the confirmation page (check selectors, URL, and body text)
43
+ const onConfirmation = yield* probe(page, 'confirmationPage');
44
+ const urlMatch = /\/(confirmation|confirmed|thank-you|complete)/i.test(page.url());
45
+ const bodyMatch = yield* Effect.tryPromise({
46
+ try: () =>
47
+ page.$eval('body', (el) => {
48
+ const text = el.textContent?.toLowerCase() ?? '';
49
+ return text.includes('booking confirmed') ||
50
+ text.includes('appointment confirmed') ||
51
+ text.includes('successfully booked') ||
52
+ text.includes('your appointment is scheduled');
53
+ }).catch(() => false),
54
+ catch: () => false,
55
+ }).pipe(Effect.orElseSucceed(() => false));
56
+
57
+ if (!onConfirmation && !urlMatch && !bodyMatch) {
58
+ return yield* Effect.fail(
59
+ new WizardStepError({
60
+ step: 'extract',
61
+ message: 'Not on confirmation page - cannot extract booking data',
62
+ }),
63
+ );
64
+ }
65
+
66
+ // Extract each piece of data
67
+ const appointmentId = yield* extractText(page, Selectors.confirmationId);
68
+ const serviceName = yield* extractText(page, Selectors.confirmationService);
69
+ const datetime = yield* extractText(page, Selectors.confirmationDatetime);
70
+
71
+ // Get the full page text as fallback for parsing
72
+ const rawText = yield* Effect.tryPromise({
73
+ try: () => page.textContent('body').then((t) => t?.trim() ?? ''),
74
+ catch: () => '',
75
+ }).pipe(Effect.orElseSucceed(() => ''));
76
+
77
+ // Try to extract confirmation code from raw text if not found via selector
78
+ const confirmationCode =
79
+ appointmentId ?? extractConfirmationFromText(rawText);
80
+
81
+ // Take success screenshot for audit trail
82
+ if (config.screenshotOnFailure) {
83
+ yield* screenshot('booking-confirmation').pipe(Effect.ignore);
84
+ }
85
+
86
+ return {
87
+ appointmentId,
88
+ confirmationCode,
89
+ serviceName,
90
+ datetime,
91
+ providerName: null, // Not extracted from confirmation page
92
+ rawText,
93
+ } satisfies ConfirmationData;
94
+ });
95
+
96
+ // =============================================================================
97
+ // MAPPING
98
+ // =============================================================================
99
+
100
+ /**
101
+ * Map extracted confirmation data + original request into a Booking object.
102
+ */
103
+ export const toBooking = (
104
+ confirmation: ConfirmationData,
105
+ request: BookingRequest,
106
+ paymentRef: string,
107
+ paymentProcessor: string,
108
+ service?: { name: string; duration: number; price: number; currency: string },
109
+ ): Booking => ({
110
+ id: confirmation.appointmentId ?? `wizard-${Date.now()}`,
111
+ serviceId: request.serviceId,
112
+ serviceName: confirmation.serviceName ?? service?.name ?? 'Unknown Service',
113
+ providerId: request.providerId,
114
+ providerName: confirmation.providerName ?? undefined,
115
+ datetime: request.datetime,
116
+ endTime: computeEndTime(request.datetime, service?.duration ?? 60),
117
+ duration: service?.duration ?? 60,
118
+ price: service?.price ?? 0,
119
+ currency: service?.currency ?? 'USD',
120
+ client: request.client,
121
+ status: 'confirmed',
122
+ confirmationCode: confirmation.confirmationCode ?? undefined,
123
+ paymentStatus: 'paid',
124
+ paymentRef: `[${paymentProcessor.toUpperCase()}] Transaction: ${paymentRef}`,
125
+ createdAt: new Date().toISOString(),
126
+ });
127
+
128
+ // =============================================================================
129
+ // HELPERS
130
+ // =============================================================================
131
+
132
+ const extractText = (
133
+ page: Page,
134
+ candidates: readonly string[],
135
+ ): Effect.Effect<string | null, never> =>
136
+ Effect.gen(function* () {
137
+ for (const selector of candidates) {
138
+ const text = yield* Effect.tryPromise({
139
+ try: () =>
140
+ page.$eval(selector, (el) => el.textContent?.trim() ?? null).catch(() => null),
141
+ catch: () => null,
142
+ }).pipe(Effect.orElseSucceed(() => null));
143
+
144
+ if (text) return text;
145
+ }
146
+ return null;
147
+ });
148
+
149
+ const extractConfirmationFromText = (text: string): string | null => {
150
+ // Look for patterns like "Confirmation #12345" or "Appointment ID: 12345"
151
+ const patterns = [
152
+ /confirmation\s*#?\s*(\w+)/i,
153
+ /appointment\s*id\s*:?\s*(\w+)/i,
154
+ /booking\s*#?\s*(\w+)/i,
155
+ /reference\s*:?\s*(\w+)/i,
156
+ ];
157
+
158
+ for (const pattern of patterns) {
159
+ const match = text.match(pattern);
160
+ if (match?.[1]) return match[1];
161
+ }
162
+
163
+ return null;
164
+ };
165
+
166
+ const computeEndTime = (datetime: string, durationMinutes: number): string => {
167
+ try {
168
+ const start = new Date(datetime);
169
+ const end = new Date(start.getTime() + durationMinutes * 60 * 1000);
170
+ return end.toISOString();
171
+ } catch {
172
+ return datetime;
173
+ }
174
+ };
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Wizard Step: Fill Client Form Fields
3
+ *
4
+ * Fills standard fields (name, email, phone), custom intake fields
5
+ * (radio buttons, "How did you hear" checkboxes, medication, terms),
6
+ * and advances past the client info step to the payment page.
7
+ *
8
+ * Acuity form requirements (verified 2026-02-26):
9
+ * - Standard: firstName, lastName, email, phone
10
+ * - 3 yes/no radio groups (aria-required, NO name/id attrs)
11
+ * - "How did you hear" multi-checkbox (at least 1 required)
12
+ * - Medication textarea (fields[field-16606770])
13
+ * - Terms checkbox (fields[field-13933959])
14
+ * - ALL must be filled before "Continue to Payment" advances
15
+ */
16
+
17
+ import { Effect } from 'effect';
18
+ import type { Page } from 'playwright-core';
19
+ import { BrowserService } from '../browser-service.js';
20
+ import { WizardStepError } from '../errors.js';
21
+ import { resolveSelector, Selectors } from '../selectors.js';
22
+ import type { ClientInfo } from '../../core/types.js';
23
+
24
+ // =============================================================================
25
+ // TYPES
26
+ // =============================================================================
27
+
28
+ export interface FillFormParams {
29
+ readonly client: ClientInfo;
30
+ readonly customFields?: Record<string, string>;
31
+ /** Answer for yes/no radio questions (default: "no") */
32
+ readonly intakeRadioAnswer?: 'yes' | 'no';
33
+ /** Which "How did you hear" checkbox to select (default: "Internet search") */
34
+ readonly howDidYouHear?: string;
35
+ /** Medication text (default: "None") */
36
+ readonly medication?: string;
37
+ }
38
+
39
+ export interface FillFormResult {
40
+ readonly fieldsCompleted: string[];
41
+ readonly customFieldsCompleted: string[];
42
+ readonly intakeFieldsCompleted: string[];
43
+ readonly advanced: boolean;
44
+ }
45
+
46
+ // =============================================================================
47
+ // IMPLEMENTATION
48
+ // =============================================================================
49
+
50
+ /**
51
+ * Fill the client information form and advance to the payment page.
52
+ */
53
+ export const fillFormFields = (params: FillFormParams) =>
54
+ Effect.gen(function* () {
55
+ const { acquirePage } = yield* BrowserService;
56
+ const page: Page = yield* acquirePage;
57
+
58
+ const fieldsCompleted: string[] = [];
59
+ const intakeFieldsCompleted: string[] = [];
60
+
61
+ // Fill or verify each standard field
62
+ yield* fillField(page, Selectors.firstNameInput, params.client.firstName, 'firstName');
63
+ fieldsCompleted.push('firstName');
64
+
65
+ yield* fillField(page, Selectors.lastNameInput, params.client.lastName, 'lastName');
66
+ fieldsCompleted.push('lastName');
67
+
68
+ yield* fillField(page, Selectors.emailInput, params.client.email, 'email');
69
+ fieldsCompleted.push('email');
70
+
71
+ if (params.client.phone) {
72
+ yield* fillField(page, Selectors.phoneInput, params.client.phone, 'phone');
73
+ fieldsCompleted.push('phone');
74
+ }
75
+
76
+ // Fill custom intake fields (by field ID)
77
+ const customFieldsCompleted: string[] = [];
78
+ if (params.customFields) {
79
+ for (const [fieldId, value] of Object.entries(params.customFields)) {
80
+ const filled = yield* fillCustomField(page, fieldId, value);
81
+ if (filled) customFieldsCompleted.push(fieldId);
82
+ }
83
+ }
84
+
85
+ // Fill intake radio buttons (yes/no questions)
86
+ const radioAnswer = params.intakeRadioAnswer ?? 'no';
87
+ yield* fillIntakeRadios(page, radioAnswer);
88
+ intakeFieldsCompleted.push('radioButtons');
89
+
90
+ // Fill "How did you hear" checkbox
91
+ const hearOption = params.howDidYouHear ?? 'Internet search';
92
+ yield* fillHowDidYouHear(page, hearOption);
93
+ intakeFieldsCompleted.push('howDidYouHear');
94
+
95
+ // Fill medication textarea
96
+ const medication = params.medication ?? 'None';
97
+ yield* fillMedication(page, medication);
98
+ intakeFieldsCompleted.push('medication');
99
+
100
+ // Fill terms checkbox
101
+ yield* fillTermsCheckbox(page);
102
+ intakeFieldsCompleted.push('termsCheckbox');
103
+
104
+ // Click continue/next to advance past client form
105
+ const advanced = yield* advancePastForm(page);
106
+
107
+ return {
108
+ fieldsCompleted,
109
+ customFieldsCompleted,
110
+ intakeFieldsCompleted,
111
+ advanced,
112
+ } satisfies FillFormResult;
113
+ }).pipe(
114
+ Effect.catchTag('SelectorError', (e) =>
115
+ Effect.fail(
116
+ new WizardStepError({
117
+ step: 'fill-form',
118
+ message: `Form field not found: ${e.message}`,
119
+ cause: e,
120
+ }),
121
+ ),
122
+ ),
123
+ );
124
+
125
+ // =============================================================================
126
+ // HELPERS
127
+ // =============================================================================
128
+
129
+ /**
130
+ * Fill a form field. If the field already has the correct value
131
+ * (from URL pre-fill), skip it. Otherwise clear and fill.
132
+ */
133
+ const fillField = (
134
+ page: Page,
135
+ candidates: readonly string[],
136
+ value: string,
137
+ fieldName: string,
138
+ ) =>
139
+ Effect.gen(function* () {
140
+ const { element, selector } = yield* resolveSelector(page, candidates, 5000);
141
+
142
+ // Check current value
143
+ const currentValue = yield* Effect.tryPromise({
144
+ try: () => page.$eval(selector, (el) => (el as HTMLInputElement).value),
145
+ catch: () => '',
146
+ }).pipe(Effect.orElseSucceed(() => ''));
147
+
148
+ // Skip if already correct
149
+ if (currentValue.trim().toLowerCase() === value.trim().toLowerCase()) {
150
+ return;
151
+ }
152
+
153
+ // Clear and fill
154
+ yield* Effect.tryPromise({
155
+ try: async () => {
156
+ await element.click({ clickCount: 3 }); // Select all
157
+ await element.fill(value);
158
+ },
159
+ catch: (e) =>
160
+ new WizardStepError({
161
+ step: 'fill-form',
162
+ message: `Failed to fill ${fieldName}: ${e instanceof Error ? e.message : String(e)}`,
163
+ cause: e,
164
+ }),
165
+ });
166
+ });
167
+
168
+ /**
169
+ * Fill a custom Acuity intake field by field ID.
170
+ * Acuity custom fields use `field:XXXX` name pattern.
171
+ */
172
+ const fillCustomField = (
173
+ page: Page,
174
+ fieldId: string,
175
+ value: string,
176
+ ): Effect.Effect<boolean, never> =>
177
+ Effect.gen(function* () {
178
+ const selectors = [
179
+ `[name="fields[field-${fieldId}]"]`,
180
+ `input[id*="${fieldId}"]`,
181
+ `textarea[name*="${fieldId}"]`,
182
+ `[data-field-id="${fieldId}"]`,
183
+ ];
184
+
185
+ const result = yield* resolveSelector(page, selectors, 2000).pipe(
186
+ Effect.map((resolved) => resolved),
187
+ Effect.orElseSucceed(() => null),
188
+ );
189
+
190
+ if (!result) return false;
191
+
192
+ yield* Effect.tryPromise({
193
+ try: async () => {
194
+ const tagName = await result.element.evaluate((el) => (el as Element).tagName.toLowerCase());
195
+ if (tagName === 'select') {
196
+ await page.selectOption(result.selector, value);
197
+ } else if (tagName === 'textarea') {
198
+ await result.element.fill(value);
199
+ } else {
200
+ const inputType = await result.element.evaluate(
201
+ (el) => (el as HTMLInputElement).type,
202
+ );
203
+ if (inputType === 'checkbox') {
204
+ const checked = await result.element.isChecked();
205
+ if ((value === 'true') !== checked) {
206
+ await result.element.click();
207
+ }
208
+ } else {
209
+ await result.element.fill(value);
210
+ }
211
+ }
212
+ },
213
+ catch: () => null,
214
+ }).pipe(Effect.orElseSucceed(() => null));
215
+
216
+ return true;
217
+ });
218
+
219
+ /**
220
+ * Fill intake radio buttons.
221
+ *
222
+ * Acuity's radio buttons have NO name or id attributes — they are purely
223
+ * React-controlled. The proven strategy is to click the <label> element
224
+ * wrapping each radio via Playwright's locator().nth() API, which dispatches
225
+ * OS-level mouse events that React's event delegation handles correctly.
226
+ */
227
+ const fillIntakeRadios = (
228
+ page: Page,
229
+ answer: 'yes' | 'no',
230
+ ): Effect.Effect<void, WizardStepError> =>
231
+ Effect.tryPromise({
232
+ try: async () => {
233
+ const selectorKey = answer === 'no' ? Selectors.radioNoLabel : Selectors.radioYesLabel;
234
+ const labelLocator = page.locator(selectorKey[0]);
235
+ const count = await labelLocator.count();
236
+
237
+ for (let i = 0; i < count; i++) {
238
+ await labelLocator.nth(i).scrollIntoViewIfNeeded();
239
+ await labelLocator.nth(i).click({ timeout: 5000 });
240
+ await page.waitForTimeout(200);
241
+ }
242
+ },
243
+ catch: (e) =>
244
+ new WizardStepError({
245
+ step: 'fill-form',
246
+ message: `Failed to fill radio buttons: ${e instanceof Error ? e.message : String(e)}`,
247
+ cause: e,
248
+ }),
249
+ });
250
+
251
+ /**
252
+ * Select at least one "How did you hear" checkbox.
253
+ *
254
+ * These checkboxes have plain-text name attributes like "Internet search".
255
+ * Uses the same label-click locator strategy as radio buttons.
256
+ */
257
+ const fillHowDidYouHear = (
258
+ page: Page,
259
+ option: string,
260
+ ): Effect.Effect<void, WizardStepError> =>
261
+ Effect.tryPromise({
262
+ try: async () => {
263
+ const labelLocator = page.locator(`label:has(input[type="checkbox"][name="${option}"])`);
264
+ const count = await labelLocator.count();
265
+ if (count > 0) {
266
+ await labelLocator.first().scrollIntoViewIfNeeded();
267
+ await labelLocator.first().click({ timeout: 3000 });
268
+ } else {
269
+ // Fallback: click the first non-terms checkbox
270
+ const fallback = page.locator('input[type="checkbox"]:not([name*="field-13933959"])');
271
+ const fallbackCount = await fallback.count();
272
+ if (fallbackCount > 0) {
273
+ const parent = page.locator('label:has(input[type="checkbox"]:not([name*="field-13933959"]))');
274
+ await parent.first().scrollIntoViewIfNeeded();
275
+ await parent.first().click({ timeout: 3000 });
276
+ }
277
+ }
278
+ },
279
+ catch: (e) =>
280
+ new WizardStepError({
281
+ step: 'fill-form',
282
+ message: `Failed to fill "How did you hear": ${e instanceof Error ? e.message : String(e)}`,
283
+ cause: e,
284
+ }),
285
+ });
286
+
287
+ /**
288
+ * Fill the medication textarea.
289
+ */
290
+ const fillMedication = (
291
+ page: Page,
292
+ text: string,
293
+ ): Effect.Effect<void, never> =>
294
+ Effect.tryPromise({
295
+ try: async () => {
296
+ for (const selector of Selectors.medicationField) {
297
+ const el = await page.$(selector);
298
+ if (el) {
299
+ await el.fill(text);
300
+ return;
301
+ }
302
+ }
303
+ },
304
+ catch: () => undefined,
305
+ }).pipe(Effect.orElseSucceed(() => undefined));
306
+
307
+ /**
308
+ * Check the terms agreement checkbox via label click.
309
+ */
310
+ const fillTermsCheckbox = (page: Page): Effect.Effect<void, never> =>
311
+ Effect.tryPromise({
312
+ try: async () => {
313
+ const isChecked = await page
314
+ .$eval(Selectors.termsCheckbox[0], (el) => (el as HTMLInputElement).checked)
315
+ .catch(() => false);
316
+ if (!isChecked) {
317
+ const label = page.locator(`label:has(${Selectors.termsCheckbox[0]})`);
318
+ await label.scrollIntoViewIfNeeded();
319
+ await label.click({ timeout: 3000 });
320
+ }
321
+ },
322
+ catch: () => undefined,
323
+ }).pipe(Effect.orElseSucceed(() => undefined));
324
+
325
+ /**
326
+ * Click "Continue to Payment" to advance past the client form.
327
+ *
328
+ * Verified 2026-02-26: "Continue to Payment" navigates to a SEPARATE
329
+ * payment page at URL .../datetime/<ISO>/payment.
330
+ */
331
+ const advancePastForm = (page: Page): Effect.Effect<boolean, WizardStepError> =>
332
+ Effect.gen(function* () {
333
+ const continueBtn = yield* resolveSelector(page, Selectors.continueToPayment, 5000).pipe(
334
+ Effect.catchTag('SelectorError', () =>
335
+ Effect.fail(
336
+ new WizardStepError({
337
+ step: 'fill-form',
338
+ message: '"Continue to Payment" button not found after filling form',
339
+ }),
340
+ ),
341
+ ),
342
+ );
343
+
344
+ yield* Effect.tryPromise({
345
+ try: async () => {
346
+ await continueBtn.element.click();
347
+ // Wait for navigation to payment page (URL ends in /payment)
348
+ await page.waitForURL((url) => url.href.includes('/payment'), { timeout: 15000 });
349
+ },
350
+ catch: (e) =>
351
+ new WizardStepError({
352
+ step: 'fill-form',
353
+ message: `Failed to advance to payment page: ${e instanceof Error ? e.message : String(e)}`,
354
+ cause: e,
355
+ }),
356
+ });
357
+
358
+ return true;
359
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Wizard Steps - Individual programs for each stage of the Acuity wizard.
3
+ */
4
+
5
+ export { navigateToBooking, type NavigateParams, type NavigateResult } from './navigate.js';
6
+ export { fillFormFields, type FillFormParams, type FillFormResult } from './fill-form.js';
7
+ export {
8
+ bypassPayment,
9
+ generateCouponCode,
10
+ type BypassPaymentResult,
11
+ } from './bypass-payment.js';
12
+ export { submitBooking, type SubmitResult } from './submit.js';
13
+ export {
14
+ extractConfirmation,
15
+ toBooking,
16
+ type ConfirmationData,
17
+ } from './extract.js';
18
+ export {
19
+ readAvailableDates,
20
+ type ReadAvailabilityParams,
21
+ type AvailableDateResult,
22
+ } from './read-availability.js';
23
+ export {
24
+ readTimeSlots,
25
+ type ReadSlotsParams,
26
+ type SlotResult,
27
+ } from './read-slots.js';