@tummycrypt/acuity-middleware 0.1.0 → 0.1.1

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 (148) hide show
  1. package/dist/adapters/acuity-scraper.d.ts +8 -0
  2. package/dist/adapters/acuity-scraper.d.ts.map +1 -0
  3. package/dist/adapters/acuity-scraper.js +8 -0
  4. package/dist/adapters/acuity-scraper.js.map +1 -0
  5. package/dist/adapters/types.d.ts +8 -0
  6. package/dist/adapters/types.d.ts.map +1 -0
  7. package/dist/adapters/types.js +8 -0
  8. package/dist/adapters/types.js.map +1 -0
  9. package/dist/core/types.d.ts +10 -0
  10. package/dist/core/types.d.ts.map +1 -0
  11. package/dist/core/types.js +2 -0
  12. package/dist/core/types.js.map +1 -0
  13. package/dist/index.d.ts +17 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +18 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/middleware/acuity-wizard.d.ts +49 -0
  18. package/dist/middleware/acuity-wizard.d.ts.map +1 -0
  19. package/dist/middleware/acuity-wizard.js +265 -0
  20. package/dist/middleware/acuity-wizard.js.map +1 -0
  21. package/dist/middleware/browser-service.d.ts +53 -0
  22. package/dist/middleware/browser-service.d.ts.map +1 -0
  23. package/dist/middleware/browser-service.js +105 -0
  24. package/dist/middleware/browser-service.js.map +1 -0
  25. package/dist/middleware/errors.d.ts +58 -0
  26. package/dist/middleware/errors.d.ts.map +1 -0
  27. package/dist/middleware/errors.js +43 -0
  28. package/dist/middleware/errors.js.map +1 -0
  29. package/{src/middleware/index.ts → dist/middleware/index.d.ts} +5 -52
  30. package/dist/middleware/index.d.ts.map +1 -0
  31. package/dist/middleware/index.js +38 -0
  32. package/dist/middleware/index.js.map +1 -0
  33. package/dist/middleware/logger.d.ts +26 -0
  34. package/dist/middleware/logger.d.ts.map +1 -0
  35. package/dist/middleware/logger.js +65 -0
  36. package/dist/middleware/logger.js.map +1 -0
  37. package/dist/middleware/remote-adapter.d.ts +45 -0
  38. package/dist/middleware/remote-adapter.d.ts.map +1 -0
  39. package/dist/middleware/remote-adapter.js +178 -0
  40. package/dist/middleware/remote-adapter.js.map +1 -0
  41. package/dist/middleware/selector-health.d.ts +44 -0
  42. package/dist/middleware/selector-health.d.ts.map +1 -0
  43. package/dist/middleware/selector-health.js +144 -0
  44. package/dist/middleware/selector-health.js.map +1 -0
  45. package/dist/middleware/selectors.d.ts +108 -0
  46. package/dist/middleware/selectors.d.ts.map +1 -0
  47. package/dist/middleware/selectors.js +249 -0
  48. package/dist/middleware/selectors.js.map +1 -0
  49. package/dist/middleware/server.d.ts +34 -0
  50. package/dist/middleware/server.d.ts.map +1 -0
  51. package/dist/middleware/server.js +377 -0
  52. package/dist/middleware/server.js.map +1 -0
  53. package/dist/middleware/service-resolver.d.ts +46 -0
  54. package/dist/middleware/service-resolver.d.ts.map +1 -0
  55. package/dist/middleware/service-resolver.js +274 -0
  56. package/dist/middleware/service-resolver.js.map +1 -0
  57. package/dist/middleware/slot-parser.d.ts +29 -0
  58. package/dist/middleware/slot-parser.d.ts.map +1 -0
  59. package/dist/middleware/slot-parser.js +50 -0
  60. package/dist/middleware/slot-parser.js.map +1 -0
  61. package/dist/middleware/steps/__tests__/fixtures.d.ts +14 -0
  62. package/dist/middleware/steps/__tests__/fixtures.d.ts.map +1 -0
  63. package/dist/middleware/steps/__tests__/fixtures.js +204 -0
  64. package/dist/middleware/steps/__tests__/fixtures.js.map +1 -0
  65. package/dist/middleware/steps/bypass-payment.d.ts +54 -0
  66. package/dist/middleware/steps/bypass-payment.d.ts.map +1 -0
  67. package/dist/middleware/steps/bypass-payment.js +164 -0
  68. package/dist/middleware/steps/bypass-payment.js.map +1 -0
  69. package/dist/middleware/steps/extract-business.d.ts +93 -0
  70. package/dist/middleware/steps/extract-business.d.ts.map +1 -0
  71. package/dist/middleware/steps/extract-business.js +170 -0
  72. package/dist/middleware/steps/extract-business.js.map +1 -0
  73. package/dist/middleware/steps/extract.d.ts +41 -0
  74. package/dist/middleware/steps/extract.d.ts.map +1 -0
  75. package/dist/middleware/steps/extract.js +128 -0
  76. package/dist/middleware/steps/extract.js.map +1 -0
  77. package/dist/middleware/steps/fill-form.d.ts +45 -0
  78. package/dist/middleware/steps/fill-form.d.ts.map +1 -0
  79. package/dist/middleware/steps/fill-form.js +262 -0
  80. package/dist/middleware/steps/fill-form.js.map +1 -0
  81. package/dist/middleware/steps/index.d.ts +12 -0
  82. package/dist/middleware/steps/index.d.ts.map +1 -0
  83. package/dist/middleware/steps/index.js +12 -0
  84. package/dist/middleware/steps/index.js.map +1 -0
  85. package/dist/middleware/steps/navigate.d.ts +51 -0
  86. package/dist/middleware/steps/navigate.d.ts.map +1 -0
  87. package/dist/middleware/steps/navigate.js +391 -0
  88. package/dist/middleware/steps/navigate.js.map +1 -0
  89. package/dist/middleware/steps/read-availability.d.ts +37 -0
  90. package/dist/middleware/steps/read-availability.d.ts.map +1 -0
  91. package/dist/middleware/steps/read-availability.js +298 -0
  92. package/dist/middleware/steps/read-availability.js.map +1 -0
  93. package/dist/middleware/steps/read-slots.d.ts +33 -0
  94. package/dist/middleware/steps/read-slots.d.ts.map +1 -0
  95. package/dist/middleware/steps/read-slots.js +295 -0
  96. package/dist/middleware/steps/read-slots.js.map +1 -0
  97. package/dist/middleware/steps/read-via-url.d.ts +39 -0
  98. package/dist/middleware/steps/read-via-url.d.ts.map +1 -0
  99. package/dist/middleware/steps/read-via-url.js +141 -0
  100. package/dist/middleware/steps/read-via-url.js.map +1 -0
  101. package/dist/middleware/steps/submit.d.ts +22 -0
  102. package/dist/middleware/steps/submit.d.ts.map +1 -0
  103. package/dist/middleware/steps/submit.js +112 -0
  104. package/dist/middleware/steps/submit.js.map +1 -0
  105. package/dist/middleware/wizard-calendar.d.ts +37 -0
  106. package/dist/middleware/wizard-calendar.d.ts.map +1 -0
  107. package/dist/middleware/wizard-calendar.js +177 -0
  108. package/dist/middleware/wizard-calendar.js.map +1 -0
  109. package/dist/middleware/wizard-service.d.ts +30 -0
  110. package/dist/middleware/wizard-service.d.ts.map +1 -0
  111. package/dist/middleware/wizard-service.js +89 -0
  112. package/dist/middleware/wizard-service.js.map +1 -0
  113. package/dist/server.d.ts +6 -0
  114. package/dist/server.d.ts.map +1 -0
  115. package/{src/server.ts → dist/server.js} +1 -0
  116. package/dist/server.js.map +1 -0
  117. package/package.json +16 -4
  118. package/.github/workflows/build-paper.yml +0 -39
  119. package/.github/workflows/ci.yml +0 -37
  120. package/Dockerfile +0 -53
  121. package/docs/blog-post.mdx +0 -240
  122. package/docs/paper/IEEEtran.bst +0 -2409
  123. package/docs/paper/IEEEtran.cls +0 -6347
  124. package/docs/paper/acuity-middleware-paper.tex +0 -375
  125. package/docs/paper/balance.sty +0 -87
  126. package/docs/paper/references.bib +0 -231
  127. package/docs/paper.md +0 -400
  128. package/flake.nix +0 -32
  129. package/modal-app.py +0 -82
  130. package/src/adapters/acuity-scraper.ts +0 -543
  131. package/src/adapters/types.ts +0 -193
  132. package/src/core/types.ts +0 -325
  133. package/src/index.ts +0 -75
  134. package/src/middleware/acuity-wizard.ts +0 -456
  135. package/src/middleware/browser-service.ts +0 -183
  136. package/src/middleware/errors.ts +0 -70
  137. package/src/middleware/remote-adapter.ts +0 -246
  138. package/src/middleware/selectors.ts +0 -308
  139. package/src/middleware/server.ts +0 -372
  140. package/src/middleware/steps/bypass-payment.ts +0 -226
  141. package/src/middleware/steps/extract.ts +0 -174
  142. package/src/middleware/steps/fill-form.ts +0 -359
  143. package/src/middleware/steps/index.ts +0 -27
  144. package/src/middleware/steps/navigate.ts +0 -537
  145. package/src/middleware/steps/read-availability.ts +0 -399
  146. package/src/middleware/steps/read-slots.ts +0 -405
  147. package/src/middleware/steps/submit.ts +0 -168
  148. package/tsconfig.json +0 -25
@@ -1,537 +0,0 @@
1
- /**
2
- * Wizard Step: Navigate Through Acuity Booking Wizard
3
- *
4
- * Acuity's React SPA (2026) does NOT support deep-linking via query params.
5
- * Instead, we click through the 5-step wizard:
6
- * 1. Service page (massageithaca.as.me) → find service → click "Book"
7
- * 2. Calendar page → navigate to target month → click target day
8
- * 3. Time slots → click matching slot → "Select and continue"
9
- * 4. Land on client form (fields empty — filling is a separate step)
10
- *
11
- * URL progression:
12
- * /schedule/<hash>
13
- * /schedule/<hash>/appointment/<aptId>/calendar/<calId>
14
- * /schedule/<hash>/appointment/<aptId>/calendar/<calId>/datetime/<ISO>
15
- */
16
-
17
- import { Effect } from 'effect';
18
- import type { Page, ElementHandle } from 'playwright-core';
19
- import { BrowserService } from '../browser-service.js';
20
- import { WizardStepError } from '../errors.js';
21
- import { resolveSelector, probe, Selectors } from '../selectors.js';
22
- import type { ClientInfo } from '../../core/types.js';
23
-
24
- // =============================================================================
25
- // TYPES
26
- // =============================================================================
27
-
28
- export interface NavigateParams {
29
- /** Appointment type name (matched against .appointment-type-name text) */
30
- readonly serviceName: string;
31
- /** Target datetime in ISO 8601 (e.g. "2026-03-15T10:00:00-05:00") */
32
- readonly datetime: string;
33
- /** Client info (not used for navigation — kept for API compat) */
34
- readonly client: ClientInfo;
35
- /** Appointment type ID — if known, verified against URL after "Book" click */
36
- readonly appointmentTypeId?: string;
37
- }
38
-
39
- export interface NavigateResult {
40
- readonly url: string;
41
- readonly landingStep: 'client-form' | 'service-selection' | 'calendar' | 'time-slots' | 'unknown';
42
- readonly appointmentTypeId: string | null;
43
- readonly calendarId: string | null;
44
- readonly selectedDate: string;
45
- readonly selectedTime: string;
46
- }
47
-
48
- // =============================================================================
49
- // IMPLEMENTATION
50
- // =============================================================================
51
-
52
- /**
53
- * Navigate through the Acuity wizard to reach the client form.
54
- *
55
- * Flow: Service page → Book → Calendar → Time slot → Select and continue
56
- */
57
- export const navigateToBooking = (params: NavigateParams) =>
58
- Effect.gen(function* () {
59
- const { acquirePage, config } = yield* BrowserService;
60
- const page: Page = yield* acquirePage;
61
-
62
- // Step 1: Load service selection page
63
- yield* Effect.tryPromise({
64
- try: () => page.goto(config.baseUrl, { waitUntil: 'networkidle', timeout: config.timeout }),
65
- catch: (e) =>
66
- new WizardStepError({
67
- step: 'navigate',
68
- message: `Failed to load service page: ${e instanceof Error ? e.message : String(e)}`,
69
- cause: e,
70
- }),
71
- });
72
-
73
- // Step 2: Find and click target service's "Book" button
74
- const { appointmentTypeId, calendarId } = yield* selectService(
75
- page,
76
- params.serviceName,
77
- params.appointmentTypeId,
78
- );
79
-
80
- // Step 3: Navigate calendar to target date and click
81
- const targetDate = parseDate(params.datetime);
82
- yield* navigateCalendar(page, targetDate);
83
- yield* selectDay(page, targetDate);
84
-
85
- // Step 4: Select matching time slot
86
- const targetTime = parseTime(params.datetime);
87
- yield* selectTimeSlot(page, targetTime);
88
-
89
- // Step 5: Click "Select and continue" → land on client form
90
- yield* clickSelectAndContinue(page);
91
-
92
- // Verify we landed on the client form
93
- const landingStep = yield* detectLandingStep(page);
94
-
95
- return {
96
- url: page.url(),
97
- landingStep,
98
- appointmentTypeId,
99
- calendarId,
100
- selectedDate: targetDate.toISOString().split('T')[0],
101
- selectedTime: targetTime,
102
- } satisfies NavigateResult;
103
- });
104
-
105
- // =============================================================================
106
- // STEP 2: SERVICE SELECTION
107
- // =============================================================================
108
-
109
- /**
110
- * Find the service by name and click its "Book" button.
111
- * After clicking, waits for URL to include /appointment/<id>/calendar/<id>.
112
- */
113
- const selectService = (
114
- page: Page,
115
- serviceName: string,
116
- expectedAppointmentTypeId?: string,
117
- ) =>
118
- Effect.gen(function* () {
119
- // Wait for service list to load
120
- yield* resolveSelector(page, Selectors.serviceList, 10000).pipe(
121
- Effect.catchTag('SelectorError', () =>
122
- Effect.fail(
123
- new WizardStepError({
124
- step: 'navigate',
125
- message: 'Service list did not load within timeout',
126
- }),
127
- ),
128
- ),
129
- );
130
-
131
- // Find the service item matching our target name
132
- const serviceItem: ElementHandle | null = yield* Effect.tryPromise({
133
- try: async () => {
134
- const items = await page.$$(Selectors.serviceList[0]);
135
- for (const item of items) {
136
- const nameEl = await item.$(Selectors.serviceName[0]);
137
- const name = await nameEl?.textContent();
138
- if (name && name.trim().toLowerCase().includes(serviceName.toLowerCase())) {
139
- return item;
140
- }
141
- }
142
- return null;
143
- },
144
- catch: (e) =>
145
- new WizardStepError({
146
- step: 'navigate',
147
- message: `Error searching services: ${e instanceof Error ? e.message : String(e)}`,
148
- cause: e,
149
- }),
150
- });
151
-
152
- if (!serviceItem) {
153
- return yield* Effect.fail(
154
- new WizardStepError({
155
- step: 'navigate',
156
- message: `Service "${serviceName}" not found on the page`,
157
- }),
158
- );
159
- }
160
-
161
- // Click the "Book" button within this service item
162
- const bookBtn = yield* Effect.tryPromise({
163
- try: () => serviceItem.$(Selectors.serviceBookButton[0]),
164
- catch: (e) =>
165
- new WizardStepError({
166
- step: 'navigate',
167
- message: `Error finding Book button: ${e instanceof Error ? e.message : String(e)}`,
168
- cause: e,
169
- }),
170
- });
171
-
172
- if (!bookBtn) {
173
- return yield* Effect.fail(
174
- new WizardStepError({
175
- step: 'navigate',
176
- message: `"Book" button not found for service "${serviceName}"`,
177
- }),
178
- );
179
- }
180
-
181
- yield* Effect.tryPromise({
182
- try: async () => {
183
- await bookBtn.click();
184
- await page.waitForURL(/\/appointment\/\d+\/calendar\/\d+/, { timeout: 10000 });
185
- },
186
- catch: (e) =>
187
- new WizardStepError({
188
- step: 'navigate',
189
- message: `Failed to navigate after clicking Book: ${e instanceof Error ? e.message : String(e)}`,
190
- cause: e,
191
- }),
192
- });
193
-
194
- // Extract IDs from URL: /appointment/<aptId>/calendar/<calId>
195
- const url = page.url();
196
- const appointmentMatch = url.match(/\/appointment\/(\d+)/);
197
- const calendarMatch = url.match(/\/calendar\/(\d+)/);
198
- const appointmentTypeId = appointmentMatch?.[1] ?? null;
199
- const calendarId = calendarMatch?.[1] ?? null;
200
-
201
- // Verify appointment type ID if caller provided an expected value
202
- if (expectedAppointmentTypeId && appointmentTypeId !== expectedAppointmentTypeId) {
203
- return yield* Effect.fail(
204
- new WizardStepError({
205
- step: 'navigate',
206
- message: `Expected appointment type ${expectedAppointmentTypeId} but got ${appointmentTypeId}`,
207
- }),
208
- );
209
- }
210
-
211
- return { appointmentTypeId, calendarId };
212
- });
213
-
214
- // =============================================================================
215
- // STEP 3: CALENDAR NAVIGATION
216
- // =============================================================================
217
-
218
- /**
219
- * Navigate the react-calendar to the target month using prev/next buttons.
220
- * Stops after 12 iterations to prevent infinite loops.
221
- */
222
- const navigateCalendar = (page: Page, targetDate: Date) =>
223
- Effect.gen(function* () {
224
- // Wait for calendar to load
225
- yield* resolveSelector(page, Selectors.calendar, 10000).pipe(
226
- Effect.catchTag('SelectorError', () =>
227
- Effect.fail(
228
- new WizardStepError({
229
- step: 'navigate',
230
- message: 'Calendar did not load within timeout',
231
- }),
232
- ),
233
- ),
234
- );
235
-
236
- // Wait for the month label to render (may lag behind calendar container)
237
- yield* Effect.tryPromise({
238
- try: () => page.waitForSelector(Selectors.calendarMonth[0], { timeout: 5000 }),
239
- catch: () => null,
240
- }).pipe(Effect.orElseSucceed(() => null));
241
-
242
- const targetMonth = targetDate.getMonth();
243
- const targetYear = targetDate.getFullYear();
244
-
245
- for (let i = 0; i < 12; i++) {
246
- // Retry month detection up to 3 times with brief waits
247
- let current: { month: number; year: number } | null = null;
248
- for (let retry = 0; retry < 3; retry++) {
249
- current = yield* getCurrentCalendarMonth(page);
250
- if (current) break;
251
- yield* Effect.tryPromise({
252
- try: () => page.waitForTimeout(500),
253
- catch: () => null,
254
- }).pipe(Effect.orElseSucceed(() => null));
255
- }
256
-
257
- if (!current) {
258
- return yield* Effect.fail(
259
- new WizardStepError({
260
- step: 'navigate',
261
- message: 'Could not determine current calendar month after retries',
262
- }),
263
- );
264
- }
265
-
266
- if (current.month === targetMonth && current.year === targetYear) {
267
- return; // Already on the correct month
268
- }
269
-
270
- // Determine navigation direction
271
- const currentFirst = new Date(current.year, current.month, 1);
272
- const targetFirst = new Date(targetYear, targetMonth, 1);
273
-
274
- if (targetFirst > currentFirst) {
275
- yield* clickCalendarNav(page, 'next');
276
- } else {
277
- yield* clickCalendarNav(page, 'prev');
278
- }
279
-
280
- // Wait for calendar to re-render
281
- yield* Effect.tryPromise({
282
- try: () => page.waitForTimeout(500),
283
- catch: () =>
284
- new WizardStepError({ step: 'navigate', message: 'Calendar nav wait interrupted' }),
285
- });
286
- }
287
- });
288
-
289
- const MONTH_NAMES = [
290
- 'january', 'february', 'march', 'april', 'may', 'june',
291
- 'july', 'august', 'september', 'october', 'november', 'december',
292
- ];
293
-
294
- const getCurrentCalendarMonth = (
295
- page: Page,
296
- ): Effect.Effect<{ month: number; year: number } | null, never> =>
297
- Effect.tryPromise({
298
- try: async () => {
299
- for (const selector of Selectors.calendarMonth) {
300
- const text = await page
301
- .$eval(selector, (el) => el.textContent?.trim() ?? null)
302
- .catch(() => null);
303
- if (text) {
304
- // react-calendar label may contain nested spans — extract visible text
305
- // Patterns: "March 2026", "March\n2026", "March 2026 "
306
- const match = text.match(/([A-Za-z]+)\s+(\d{4})/);
307
- if (match) {
308
- const monthIndex = MONTH_NAMES.indexOf(match[1].toLowerCase());
309
- if (monthIndex >= 0) {
310
- return { month: monthIndex, year: parseInt(match[2], 10) };
311
- }
312
- }
313
- }
314
- }
315
- return null;
316
- },
317
- catch: () => null,
318
- }).pipe(Effect.orElseSucceed(() => null));
319
-
320
- const clickCalendarNav = (
321
- page: Page,
322
- direction: 'prev' | 'next',
323
- ): Effect.Effect<void, WizardStepError> =>
324
- Effect.gen(function* () {
325
- const selectors = direction === 'prev' ? Selectors.calendarPrev : Selectors.calendarNext;
326
- const btn = yield* resolveSelector(page, selectors, 3000).pipe(
327
- Effect.catchTag('SelectorError', () =>
328
- Effect.fail(
329
- new WizardStepError({
330
- step: 'navigate',
331
- message: `Calendar ${direction} button not found`,
332
- }),
333
- ),
334
- ),
335
- );
336
- yield* Effect.tryPromise({
337
- try: () => btn.element.click(),
338
- catch: (e) =>
339
- new WizardStepError({
340
- step: 'navigate',
341
- message: `Failed to click calendar ${direction}: ${e instanceof Error ? e.message : String(e)}`,
342
- cause: e,
343
- }),
344
- });
345
- });
346
-
347
- // =============================================================================
348
- // STEP 3B: DAY SELECTION
349
- // =============================================================================
350
-
351
- /**
352
- * Click the calendar tile matching our target day number.
353
- * Skips disabled tiles and neighboring-month tiles.
354
- */
355
- const selectDay = (page: Page, targetDate: Date) =>
356
- Effect.gen(function* () {
357
- const dayOfMonth = targetDate.getDate();
358
-
359
- const clicked = yield* Effect.tryPromise({
360
- try: async () => {
361
- const tiles = await page.$$(Selectors.calendarDay[0]);
362
- for (const tile of tiles) {
363
- const isDisabled = await tile.evaluate((el) => (el as HTMLButtonElement).disabled);
364
- if (isDisabled) continue;
365
-
366
- // Skip neighboring-month tiles (e.g. Feb 28 showing in March view)
367
- const classes = await tile.getAttribute('class') ?? '';
368
- if (classes.includes('neighboringMonth')) continue;
369
-
370
- const text = await tile.textContent();
371
- const dayNum = parseInt(text?.trim() ?? '', 10);
372
- if (dayNum === dayOfMonth) {
373
- await tile.click();
374
- return true;
375
- }
376
- }
377
- return false;
378
- },
379
- catch: (e) =>
380
- new WizardStepError({
381
- step: 'navigate',
382
- message: `Error selecting day ${dayOfMonth}: ${e instanceof Error ? e.message : String(e)}`,
383
- cause: e,
384
- }),
385
- });
386
-
387
- if (!clicked) {
388
- return yield* Effect.fail(
389
- new WizardStepError({
390
- step: 'navigate',
391
- message: `Day ${dayOfMonth} not available on calendar`,
392
- }),
393
- );
394
- }
395
-
396
- // Wait for time slots to appear
397
- yield* resolveSelector(page, Selectors.timeSlotContainer, 10000).pipe(
398
- Effect.catchTag('SelectorError', () =>
399
- Effect.fail(
400
- new WizardStepError({
401
- step: 'navigate',
402
- message: 'Time slots did not appear after selecting day',
403
- }),
404
- ),
405
- ),
406
- );
407
- });
408
-
409
- // =============================================================================
410
- // STEP 4: TIME SLOT SELECTION
411
- // =============================================================================
412
-
413
- /**
414
- * Click the time slot matching our target time.
415
- * Slot text contains time + availability info: "10:00 AM1 spot left"
416
- */
417
- const selectTimeSlot = (page: Page, targetTime: string) =>
418
- Effect.gen(function* () {
419
- const clicked = yield* Effect.tryPromise({
420
- try: async () => {
421
- const slots = await page.$$(Selectors.timeSlot[0]);
422
- for (const slot of slots) {
423
- const text = await slot.textContent();
424
- if (text && text.includes(targetTime)) {
425
- await slot.click();
426
- return true;
427
- }
428
- }
429
- return false;
430
- },
431
- catch: (e) =>
432
- new WizardStepError({
433
- step: 'navigate',
434
- message: `Error selecting time slot: ${e instanceof Error ? e.message : String(e)}`,
435
- cause: e,
436
- }),
437
- });
438
-
439
- if (!clicked) {
440
- return yield* Effect.fail(
441
- new WizardStepError({
442
- step: 'navigate',
443
- message: `Time slot "${targetTime}" not available`,
444
- }),
445
- );
446
- }
447
-
448
- // Wait for the selection menu to appear
449
- yield* Effect.tryPromise({
450
- try: () => page.waitForTimeout(1000),
451
- catch: () =>
452
- new WizardStepError({ step: 'navigate', message: 'Timeout after time slot click' }),
453
- });
454
- });
455
-
456
- // =============================================================================
457
- // STEP 5: "SELECT AND CONTINUE"
458
- // =============================================================================
459
-
460
- /**
461
- * Click the "Select and continue" menu item.
462
- * This is an <li role="menuitem">, NOT a button.
463
- * After clicking, waits for URL to include /datetime/.
464
- */
465
- const clickSelectAndContinue = (page: Page): Effect.Effect<void, WizardStepError> =>
466
- Effect.gen(function* () {
467
- const menuItem = yield* resolveSelector(page, Selectors.selectAndContinue, 5000).pipe(
468
- Effect.catchTag('SelectorError', () =>
469
- Effect.fail(
470
- new WizardStepError({
471
- step: 'navigate',
472
- message: '"Select and continue" option not found after selecting time slot',
473
- }),
474
- ),
475
- ),
476
- );
477
-
478
- yield* Effect.tryPromise({
479
- try: async () => {
480
- await menuItem.element.click();
481
- // Wait for navigation to client form page
482
- await page.waitForURL(/\/datetime\//, { timeout: 10000 });
483
- },
484
- catch: (e) =>
485
- new WizardStepError({
486
- step: 'navigate',
487
- message: `Failed to advance to client form: ${e instanceof Error ? e.message : String(e)}`,
488
- cause: e,
489
- }),
490
- });
491
- });
492
-
493
- // =============================================================================
494
- // HELPERS
495
- // =============================================================================
496
-
497
- const detectLandingStep = (page: Page) =>
498
- Effect.gen(function* () {
499
- const hasClientForm = yield* probe(page, 'firstNameInput');
500
- if (hasClientForm) return 'client-form' as const;
501
-
502
- const hasTimeSlots = yield* probe(page, 'timeSlot');
503
- if (hasTimeSlots) return 'time-slots' as const;
504
-
505
- const hasCalendar = yield* probe(page, 'calendarDay');
506
- if (hasCalendar) return 'calendar' as const;
507
-
508
- const hasServiceList = yield* probe(page, 'serviceList');
509
- if (hasServiceList) return 'service-selection' as const;
510
-
511
- return 'unknown' as const;
512
- });
513
-
514
- /**
515
- * Parse a Date from ISO 8601 datetime string.
516
- */
517
- const parseDate = (datetime: string): Date => {
518
- const d = new Date(datetime);
519
- if (isNaN(d.getTime())) {
520
- throw new Error(`Invalid datetime: ${datetime}`);
521
- }
522
- return d;
523
- };
524
-
525
- /**
526
- * Extract formatted time from ISO 8601 for matching against slot text.
527
- * Returns "10:00 AM" format to match Acuity's "10:00 AM1 spot left" text.
528
- */
529
- const parseTime = (datetime: string): string => {
530
- const d = new Date(datetime);
531
- const hours = d.getHours();
532
- const minutes = d.getMinutes();
533
- const ampm = hours >= 12 ? 'PM' : 'AM';
534
- const h = hours % 12 || 12;
535
- const m = minutes.toString().padStart(2, '0');
536
- return `${h}:${m} ${ampm}`;
537
- };