@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.
- package/.github/workflows/build-paper.yml +39 -0
- package/.github/workflows/ci.yml +37 -0
- package/Dockerfile +53 -0
- package/README.md +103 -0
- package/docs/blog-post.mdx +240 -0
- package/docs/paper/IEEEtran.bst +2409 -0
- package/docs/paper/IEEEtran.cls +6347 -0
- package/docs/paper/acuity-middleware-paper.tex +375 -0
- package/docs/paper/balance.sty +87 -0
- package/docs/paper/references.bib +231 -0
- package/docs/paper.md +400 -0
- package/flake.nix +32 -0
- package/modal-app.py +82 -0
- package/package.json +48 -0
- package/src/adapters/acuity-scraper.ts +543 -0
- package/src/adapters/types.ts +193 -0
- package/src/core/types.ts +325 -0
- package/src/index.ts +75 -0
- package/src/middleware/acuity-wizard.ts +456 -0
- package/src/middleware/browser-service.ts +183 -0
- package/src/middleware/errors.ts +70 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/remote-adapter.ts +246 -0
- package/src/middleware/selectors.ts +308 -0
- package/src/middleware/server.ts +372 -0
- package/src/middleware/steps/bypass-payment.ts +226 -0
- package/src/middleware/steps/extract.ts +174 -0
- package/src/middleware/steps/fill-form.ts +359 -0
- package/src/middleware/steps/index.ts +27 -0
- package/src/middleware/steps/navigate.ts +537 -0
- package/src/middleware/steps/read-availability.ts +399 -0
- package/src/middleware/steps/read-slots.ts +405 -0
- package/src/middleware/steps/submit.ts +168 -0
- package/src/server.ts +5 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware Module - Server-only Acuity wizard automation
|
|
3
|
+
*
|
|
4
|
+
* This module provides the Effect TS-based browser middleware for
|
|
5
|
+
* puppeteering the Acuity scheduling wizard. It is a SEPARATE subpath
|
|
6
|
+
* export (`@tummycrypt/scheduling-kit/middleware`) and should NOT be
|
|
7
|
+
* imported in client-side code (it depends on Playwright).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createWizardAdapter } from '@tummycrypt/scheduling-kit/middleware';
|
|
12
|
+
* import { createSchedulingKit } from '@tummycrypt/scheduling-kit';
|
|
13
|
+
* import { createVenmoAdapter } from '@tummycrypt/scheduling-kit/payments';
|
|
14
|
+
*
|
|
15
|
+
* const scheduler = createWizardAdapter({
|
|
16
|
+
* baseUrl: process.env.ACUITY_BASE_URL,
|
|
17
|
+
* couponCode: process.env.ACUITY_BYPASS_COUPON,
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* const venmo = createVenmoAdapter({ ... });
|
|
21
|
+
* const kit = createSchedulingKit(scheduler, [venmo]);
|
|
22
|
+
*
|
|
23
|
+
* // Full booking with Venmo payment
|
|
24
|
+
* const result = await kit.completeBooking(request, 'venmo')();
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Adapter factories
|
|
29
|
+
export { createWizardAdapter, type WizardAdapterConfig } from './acuity-wizard.js';
|
|
30
|
+
export { createRemoteWizardAdapter, type RemoteAdapterConfig } from './remote-adapter.js';
|
|
31
|
+
|
|
32
|
+
// Browser service (for custom Layer composition)
|
|
33
|
+
export {
|
|
34
|
+
BrowserService,
|
|
35
|
+
BrowserServiceLive,
|
|
36
|
+
BrowserServiceTest,
|
|
37
|
+
defaultBrowserConfig,
|
|
38
|
+
type BrowserConfig,
|
|
39
|
+
type BrowserServiceShape,
|
|
40
|
+
} from './browser-service.js';
|
|
41
|
+
|
|
42
|
+
// Error types and bridge
|
|
43
|
+
export {
|
|
44
|
+
BrowserError,
|
|
45
|
+
SelectorError,
|
|
46
|
+
WizardStepError,
|
|
47
|
+
CouponError,
|
|
48
|
+
toSchedulingError,
|
|
49
|
+
type MiddlewareError,
|
|
50
|
+
} from './errors.js';
|
|
51
|
+
|
|
52
|
+
// Selector registry
|
|
53
|
+
export {
|
|
54
|
+
Selectors,
|
|
55
|
+
resolveSelector,
|
|
56
|
+
resolve,
|
|
57
|
+
probeSelector,
|
|
58
|
+
probe,
|
|
59
|
+
healthCheck,
|
|
60
|
+
type SelectorKey,
|
|
61
|
+
type ResolvedSelector,
|
|
62
|
+
} from './selectors.js';
|
|
63
|
+
|
|
64
|
+
// Individual wizard steps (for advanced composition)
|
|
65
|
+
export {
|
|
66
|
+
navigateToBooking,
|
|
67
|
+
fillFormFields,
|
|
68
|
+
bypassPayment,
|
|
69
|
+
generateCouponCode,
|
|
70
|
+
submitBooking,
|
|
71
|
+
extractConfirmation,
|
|
72
|
+
toBooking,
|
|
73
|
+
type NavigateParams,
|
|
74
|
+
type NavigateResult,
|
|
75
|
+
type FillFormParams,
|
|
76
|
+
type FillFormResult,
|
|
77
|
+
type BypassPaymentResult,
|
|
78
|
+
type SubmitResult,
|
|
79
|
+
type ConfirmationData,
|
|
80
|
+
} from './steps/index.js';
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Wizard Adapter
|
|
3
|
+
*
|
|
4
|
+
* SchedulingAdapter implementation backed by HTTP calls to a remote
|
|
5
|
+
* middleware server running Playwright + Chromium (e.g., Modal Labs,
|
|
6
|
+
* Fly.io, or any Docker host).
|
|
7
|
+
*
|
|
8
|
+
* This adapter is the client-side counterpart to `middleware/server.ts`.
|
|
9
|
+
* It serializes requests, sends them over HTTP, and deserializes
|
|
10
|
+
* responses back into fp-ts TaskEither types.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const adapter = createRemoteWizardAdapter({
|
|
15
|
+
* baseUrl: process.env.MODAL_MIDDLEWARE_URL,
|
|
16
|
+
* authToken: process.env.MODAL_AUTH_TOKEN,
|
|
17
|
+
* });
|
|
18
|
+
* const kit = createSchedulingKit(adapter, [venmoAdapter]);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as TE from 'fp-ts/TaskEither';
|
|
23
|
+
import { pipe } from 'fp-ts/function';
|
|
24
|
+
import type { SchedulingAdapter } from '../adapters/types.js';
|
|
25
|
+
import type {
|
|
26
|
+
Booking,
|
|
27
|
+
BookingRequest,
|
|
28
|
+
Service,
|
|
29
|
+
Provider,
|
|
30
|
+
TimeSlot,
|
|
31
|
+
AvailableDate,
|
|
32
|
+
SlotReservation,
|
|
33
|
+
ClientInfo,
|
|
34
|
+
SchedulingError,
|
|
35
|
+
SchedulingResult,
|
|
36
|
+
} from '../core/types.js';
|
|
37
|
+
import { Errors } from '../core/types.js';
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// CONFIGURATION
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export interface RemoteAdapterConfig {
|
|
44
|
+
/** Base URL of the middleware server (e.g., https://scheduling-middleware--org.modal.run) */
|
|
45
|
+
readonly baseUrl: string;
|
|
46
|
+
/** Auth token for the middleware server */
|
|
47
|
+
readonly authToken?: string;
|
|
48
|
+
/** Request timeout in ms (default: 60000 - wizard flow can take 30s+) */
|
|
49
|
+
readonly timeout?: number;
|
|
50
|
+
/** Coupon code for payment bypass */
|
|
51
|
+
readonly couponCode?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// HTTP HELPERS
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
interface RemoteResponse<T> {
|
|
59
|
+
readonly success: boolean;
|
|
60
|
+
readonly data?: T;
|
|
61
|
+
readonly error?: {
|
|
62
|
+
readonly tag: string;
|
|
63
|
+
readonly code: string;
|
|
64
|
+
readonly message: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const makeRequest = <T>(
|
|
69
|
+
config: RemoteAdapterConfig,
|
|
70
|
+
path: string,
|
|
71
|
+
method: 'GET' | 'POST',
|
|
72
|
+
body?: unknown,
|
|
73
|
+
): SchedulingResult<T> =>
|
|
74
|
+
TE.tryCatch(
|
|
75
|
+
async () => {
|
|
76
|
+
const url = `${config.baseUrl}${path}`;
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
};
|
|
80
|
+
if (config.authToken) {
|
|
81
|
+
headers['Authorization'] = `Bearer ${config.authToken}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = await fetch(url, {
|
|
85
|
+
method,
|
|
86
|
+
headers,
|
|
87
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
88
|
+
signal: AbortSignal.timeout(config.timeout ?? 60000),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorBody = await response.json().catch(() => ({})) as RemoteResponse<never>;
|
|
93
|
+
throw Object.assign(new Error(errorBody.error?.message ?? `HTTP ${response.status}`), {
|
|
94
|
+
tag: errorBody.error?.tag ?? 'InfrastructureError',
|
|
95
|
+
code: errorBody.error?.code ?? 'NETWORK',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const json = (await response.json()) as RemoteResponse<T>;
|
|
100
|
+
|
|
101
|
+
if (!json.success && json.error) {
|
|
102
|
+
throw Object.assign(new Error(json.error.message), {
|
|
103
|
+
tag: json.error.tag,
|
|
104
|
+
code: json.error.code,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return json.data as T;
|
|
109
|
+
},
|
|
110
|
+
(e): SchedulingError => {
|
|
111
|
+
if (e instanceof Error && 'tag' in e) {
|
|
112
|
+
const tagged = e as Error & { tag: string; code: string };
|
|
113
|
+
return mapRemoteError(tagged.tag, tagged.code, tagged.message);
|
|
114
|
+
}
|
|
115
|
+
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
116
|
+
return Errors.infrastructure('TIMEOUT', 'Middleware server request timed out');
|
|
117
|
+
}
|
|
118
|
+
return Errors.infrastructure(
|
|
119
|
+
'NETWORK',
|
|
120
|
+
`Middleware server error: ${e instanceof Error ? e.message : String(e)}`,
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const mapRemoteError = (tag: string, code: string, message: string): SchedulingError => {
|
|
126
|
+
switch (tag) {
|
|
127
|
+
case 'AcuityError':
|
|
128
|
+
return Errors.acuity(code, message);
|
|
129
|
+
case 'PaymentError':
|
|
130
|
+
return Errors.payment(code, message, 'remote');
|
|
131
|
+
case 'ValidationError':
|
|
132
|
+
return Errors.validation(code, message);
|
|
133
|
+
case 'ReservationError':
|
|
134
|
+
return Errors.reservation(code as 'SLOT_TAKEN' | 'BLOCK_FAILED' | 'TIMEOUT', message);
|
|
135
|
+
case 'InfrastructureError':
|
|
136
|
+
default:
|
|
137
|
+
return Errors.infrastructure(
|
|
138
|
+
(code as 'NETWORK' | 'TIMEOUT' | 'REDIS' | 'UNKNOWN') ?? 'UNKNOWN',
|
|
139
|
+
message,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// ADAPTER FACTORY
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a SchedulingAdapter that proxies all operations to a remote
|
|
150
|
+
* middleware server via HTTP. The remote server runs Playwright + Chromium
|
|
151
|
+
* and executes the actual wizard automation.
|
|
152
|
+
*/
|
|
153
|
+
export const createRemoteWizardAdapter = (config: RemoteAdapterConfig): SchedulingAdapter => ({
|
|
154
|
+
name: 'acuity-wizard-remote',
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Read operations - proxied to remote scraper
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
getServices: () =>
|
|
161
|
+
makeRequest<Service[]>(config, '/services', 'GET'),
|
|
162
|
+
|
|
163
|
+
getService: (serviceId) =>
|
|
164
|
+
makeRequest<Service>(config, `/services/${encodeURIComponent(serviceId)}`, 'GET'),
|
|
165
|
+
|
|
166
|
+
getProviders: () =>
|
|
167
|
+
TE.right([{
|
|
168
|
+
id: '1',
|
|
169
|
+
name: 'Default Provider',
|
|
170
|
+
email: 'provider@example.com',
|
|
171
|
+
description: 'Primary provider',
|
|
172
|
+
timezone: 'America/New_York',
|
|
173
|
+
}]),
|
|
174
|
+
|
|
175
|
+
getProvider: () =>
|
|
176
|
+
TE.right({
|
|
177
|
+
id: '1',
|
|
178
|
+
name: 'Default Provider',
|
|
179
|
+
email: 'provider@example.com',
|
|
180
|
+
description: 'Primary provider',
|
|
181
|
+
timezone: 'America/New_York',
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
getProvidersForService: () =>
|
|
185
|
+
TE.right([{
|
|
186
|
+
id: '1',
|
|
187
|
+
name: 'Default Provider',
|
|
188
|
+
email: 'provider@example.com',
|
|
189
|
+
description: 'Primary provider',
|
|
190
|
+
timezone: 'America/New_York',
|
|
191
|
+
}]),
|
|
192
|
+
|
|
193
|
+
getAvailableDates: (params) =>
|
|
194
|
+
makeRequest<AvailableDate[]>(config, '/availability/dates', 'POST', params),
|
|
195
|
+
|
|
196
|
+
getAvailableSlots: (params) =>
|
|
197
|
+
makeRequest<TimeSlot[]>(config, '/availability/slots', 'POST', params),
|
|
198
|
+
|
|
199
|
+
checkSlotAvailability: (params) =>
|
|
200
|
+
makeRequest<boolean>(config, '/availability/check', 'POST', params),
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Reservation - not supported (pipeline has graceful fallback)
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
createReservation: () =>
|
|
207
|
+
TE.left(Errors.reservation('BLOCK_FAILED', 'Reservations not supported by remote wizard adapter')),
|
|
208
|
+
|
|
209
|
+
releaseReservation: () => TE.right(undefined),
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Write operations - proxied to remote wizard
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
createBooking: (request) =>
|
|
216
|
+
makeRequest<Booking>(config, '/booking/create', 'POST', {
|
|
217
|
+
request,
|
|
218
|
+
couponCode: config.couponCode,
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
createBookingWithPaymentRef: (request, paymentRef, paymentProcessor) =>
|
|
222
|
+
makeRequest<Booking>(config, '/booking/create-with-payment', 'POST', {
|
|
223
|
+
request,
|
|
224
|
+
paymentRef,
|
|
225
|
+
paymentProcessor,
|
|
226
|
+
couponCode: config.couponCode,
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
getBooking: () =>
|
|
230
|
+
TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Get booking not yet supported via wizard')),
|
|
231
|
+
|
|
232
|
+
cancelBooking: () =>
|
|
233
|
+
TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Cancel not yet supported via wizard')),
|
|
234
|
+
|
|
235
|
+
rescheduleBooking: () =>
|
|
236
|
+
TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Reschedule not yet supported via wizard')),
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Client - pass-through
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
findOrCreateClient: (client) =>
|
|
243
|
+
TE.right({ id: `local-${client.email}`, isNew: true }),
|
|
244
|
+
|
|
245
|
+
getClientByEmail: () => TE.right(null),
|
|
246
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acuity CSS Selector Registry
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for all CSS selectors used by the wizard middleware.
|
|
5
|
+
* Each selector has a primary pattern and fallback chain.
|
|
6
|
+
* When Acuity changes their DOM, fix this ONE file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect } from 'effect';
|
|
10
|
+
import type { Page, ElementHandle } from 'playwright-core';
|
|
11
|
+
import { SelectorError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// SELECTOR DEFINITIONS
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Acuity Scheduling (2026 React SPA) CSS Selector Registry
|
|
19
|
+
*
|
|
20
|
+
* Verified against live DOM: 2026-02-25
|
|
21
|
+
* Uses Emotion CSS-in-JS (css-* hashes are UNSTABLE — prefer semantic classes)
|
|
22
|
+
*
|
|
23
|
+
* Wizard flow:
|
|
24
|
+
* 1. Service page: massageithaca.as.me → <li.select-item> list with "Book" buttons
|
|
25
|
+
* 2. Calendar page: /schedule/<hash>/appointment/<aptId>/calendar/<calId>
|
|
26
|
+
* - react-calendar month grid + available-times-container
|
|
27
|
+
* 3. Client form: (after selecting time slot) → input fields
|
|
28
|
+
* 4. Payment/coupon: certificate input → Apply → verify $0
|
|
29
|
+
* 5. Submit → confirmation
|
|
30
|
+
*
|
|
31
|
+
* URL pattern (no query params):
|
|
32
|
+
* /schedule/<hash>/appointment/<appointmentTypeId>/calendar/<calendarId>
|
|
33
|
+
*/
|
|
34
|
+
export const Selectors = {
|
|
35
|
+
// -- Service selection page --
|
|
36
|
+
// Services are <li class="select-item select-item-box"> with NO <a> links
|
|
37
|
+
// Categories use <div class="select-type"> with <div class="select-label">
|
|
38
|
+
serviceList: ['.select-item', '.select-item-box', '.appointment-type-item'],
|
|
39
|
+
serviceName: ['.appointment-type-name', '.type-name', 'h3'],
|
|
40
|
+
serviceLink: ['.select-item', '.select-item-box'],
|
|
41
|
+
// Price & duration are combined: <span>30 minutes @ $150.00</span> inside .duration-container
|
|
42
|
+
servicePrice: ['.duration-container', '.duration-container span', '.price', '.cost'],
|
|
43
|
+
serviceDuration: ['.duration-container', '.duration-container span', '.duration', '.time-duration'],
|
|
44
|
+
serviceDescription: ['.type-description', '.description', 'p.type-description'],
|
|
45
|
+
// "Book" button inside each service item
|
|
46
|
+
serviceBookButton: ['button.btn', '.select-item button.btn'],
|
|
47
|
+
// Category labels
|
|
48
|
+
serviceCategory: ['.select-label', '.select-label p', '.select-type .select-label'],
|
|
49
|
+
|
|
50
|
+
// -- Calendar page (react-calendar component) --
|
|
51
|
+
// Wrapper: .monthly-calendar-v2 > .react-calendar.monthly-calendar-react-calendar
|
|
52
|
+
calendar: ['.monthly-calendar-v2', '.react-calendar', '.monthly-calendar-react-calendar'],
|
|
53
|
+
calendarMonth: ['.react-calendar__navigation__label', '.react-calendar__navigation__label__labelText'],
|
|
54
|
+
calendarPrev: ['.react-calendar__navigation__prev-button'],
|
|
55
|
+
calendarNext: ['.react-calendar__navigation__next-button'],
|
|
56
|
+
// Day tiles are buttons: <button class="react-calendar__tile react-calendar__month-view__days__day">1</button>
|
|
57
|
+
calendarDay: [
|
|
58
|
+
'.react-calendar__tile',
|
|
59
|
+
'.react-calendar__month-view__days__day',
|
|
60
|
+
'button.react-calendar__tile',
|
|
61
|
+
],
|
|
62
|
+
// Active/selected day: react-calendar__tile--active + custom "activeday" class
|
|
63
|
+
activeDay: [
|
|
64
|
+
'.react-calendar__tile--active',
|
|
65
|
+
'.activeday',
|
|
66
|
+
'.react-calendar__tile:not(:disabled)',
|
|
67
|
+
],
|
|
68
|
+
|
|
69
|
+
// -- Time slot selection --
|
|
70
|
+
// Container: .available-times-container
|
|
71
|
+
// Slots: <button class="time-selection">10:00 AM1 spot left</button>
|
|
72
|
+
// Selected: <button class="time-selection selected-time">
|
|
73
|
+
timeSlotContainer: ['.available-times-container'],
|
|
74
|
+
timeSlot: ['button.time-selection', '.time-selection', '.time-slot', '[data-time]'],
|
|
75
|
+
timeSlotSelected: ['button.time-selection.selected-time', '.selected-time'],
|
|
76
|
+
// "Select and continue" is an <li role="menuitem"> NOT a button
|
|
77
|
+
selectAndContinue: [
|
|
78
|
+
'li[role="menuitem"]',
|
|
79
|
+
'[data-keyboard-navigable="keyboard-navigable-list-item"]',
|
|
80
|
+
'text=Select and continue',
|
|
81
|
+
],
|
|
82
|
+
|
|
83
|
+
// -- Client form --
|
|
84
|
+
// Field names use "client." prefix: client.firstName, client.lastName, etc.
|
|
85
|
+
firstNameInput: ['input[name="client.firstName"]', '#client\\.firstName', 'input[name="firstName"]'],
|
|
86
|
+
lastNameInput: ['input[name="client.lastName"]', '#client\\.lastName', 'input[name="lastName"]'],
|
|
87
|
+
emailInput: ['input[name="client.email"]', '#client\\.email', 'input[name="email"]'],
|
|
88
|
+
phoneInput: ['input[name="client.phone"]', '#client\\.phone', 'input[name="phone"]'],
|
|
89
|
+
// "Continue to Payment" button on the form page
|
|
90
|
+
continueToPayment: [
|
|
91
|
+
'button.btn:has-text("Continue to Payment")',
|
|
92
|
+
'button:has-text("Continue to Payment")',
|
|
93
|
+
'button.btn[type="submit"]',
|
|
94
|
+
],
|
|
95
|
+
// "Check Code Balance" button for entering coupon codes
|
|
96
|
+
checkCodeBalance: [
|
|
97
|
+
'button:has-text("Check Code Balance")',
|
|
98
|
+
'button.css-9zfkvr',
|
|
99
|
+
],
|
|
100
|
+
// Terms agreement checkbox (custom field)
|
|
101
|
+
termsCheckbox: [
|
|
102
|
+
'input[type="checkbox"][name*="field-13933959"]',
|
|
103
|
+
'input[id*="13933959"]',
|
|
104
|
+
],
|
|
105
|
+
|
|
106
|
+
// -- Client form intake fields --
|
|
107
|
+
// Radio buttons have NO name or id attrs; are purely React-controlled.
|
|
108
|
+
// Strategy: click <label> wrapping the radio via locator().nth().
|
|
109
|
+
// 3 yes/no question groups, each with aria-required="true".
|
|
110
|
+
radioNoLabel: ['label:has(input[type="radio"][value="no"])'],
|
|
111
|
+
radioYesLabel: ['label:has(input[type="radio"][value="yes"])'],
|
|
112
|
+
// "How did you hear" multi-checkbox (REQUIRED — at least 1 must be checked)
|
|
113
|
+
// Names: "Internet search", "google maps", "referral from Noha Acupuncture",
|
|
114
|
+
// "referral from dentist", "referral from PT or other practitioner"
|
|
115
|
+
howDidYouHearCheckbox: [
|
|
116
|
+
'input[type="checkbox"][name="Internet search"]',
|
|
117
|
+
'label:has(input[type="checkbox"][name="Internet search"])',
|
|
118
|
+
],
|
|
119
|
+
// Medication textarea
|
|
120
|
+
medicationField: [
|
|
121
|
+
'textarea[name="fields[field-16606770]"]',
|
|
122
|
+
'#fields\\[field-16606770\\]',
|
|
123
|
+
],
|
|
124
|
+
|
|
125
|
+
// -- Payment / coupon --
|
|
126
|
+
// PAYMENT IS A SEPARATE PAGE at URL .../datetime/<ISO>/payment
|
|
127
|
+
// Verified 2026-02-26: Square-powered (NOT Stripe).
|
|
128
|
+
//
|
|
129
|
+
// "Check Code Balance" modal on client form page is INFORMATIONAL ONLY.
|
|
130
|
+
// The REAL coupon entry is on the PAYMENT page:
|
|
131
|
+
// "Package, gift, or coupon code" expandable section
|
|
132
|
+
//
|
|
133
|
+
// Client form modal selectors (kept for reference):
|
|
134
|
+
couponField: ['#code', 'input#code', 'input[id="code"]'],
|
|
135
|
+
couponTabByCode: [
|
|
136
|
+
'button:has-text("Check by code")',
|
|
137
|
+
'button.css-1jjp8vb:has-text("Check by code")',
|
|
138
|
+
],
|
|
139
|
+
couponConfirmButton: [
|
|
140
|
+
'[role="dialog"] button:has-text("Confirm")',
|
|
141
|
+
'button.css-qgmcoe',
|
|
142
|
+
'button:has-text("Confirm")',
|
|
143
|
+
],
|
|
144
|
+
couponCloseButton: ['button:has-text("Close")', 'button.css-ve50y1'],
|
|
145
|
+
couponError: [
|
|
146
|
+
'[role="dialog"] p:has-text("weren\'t able to recognize")',
|
|
147
|
+
'[role="dialog"] p:has-text("try entering it again")',
|
|
148
|
+
'p.css-7bwtx1',
|
|
149
|
+
],
|
|
150
|
+
couponSuccess: [
|
|
151
|
+
'[role="dialog"] p:has-text("balance")',
|
|
152
|
+
'[role="dialog"] [class*="success"]',
|
|
153
|
+
'.coupon-applied',
|
|
154
|
+
'.certificate-success',
|
|
155
|
+
],
|
|
156
|
+
|
|
157
|
+
// -- Payment page (Square checkout) --
|
|
158
|
+
// URL pattern: .../datetime/<ISO>/payment
|
|
159
|
+
// "Package, gift, or coupon code" expandable section is the coupon entry point.
|
|
160
|
+
paymentCouponToggle: [
|
|
161
|
+
'button:has-text("Package, gift, or coupon code")',
|
|
162
|
+
'text=Package, gift, or coupon code',
|
|
163
|
+
],
|
|
164
|
+
// After expanding: input placeholder="Enter code" (React id unstable like :r9:)
|
|
165
|
+
// and an "Apply" button. Verified 2026-02-26.
|
|
166
|
+
paymentCouponInput: ['input[placeholder="Enter code"]', 'input[placeholder*="code" i]', 'input[name*="coupon"]'],
|
|
167
|
+
paymentCouponApply: ['button:has-text("Apply")', 'button:has-text("Redeem")'],
|
|
168
|
+
// After applying, the certificate shows with a "REMOVE" link
|
|
169
|
+
paymentCouponRemove: ['text=REMOVE', 'a:has-text("REMOVE")', 'button:has-text("REMOVE")'],
|
|
170
|
+
// Order summary on payment page
|
|
171
|
+
paymentTotal: ['.order-total', '.payment-total', '.total-amount', 'text=$0.00'],
|
|
172
|
+
paymentSubtotal: ['text=Subtotal'],
|
|
173
|
+
// Pay & Confirm button (the final submit on payment page)
|
|
174
|
+
payAndConfirm: [
|
|
175
|
+
'button:has-text("Pay & Confirm")',
|
|
176
|
+
'button:has-text("PAY & CONFIRM")',
|
|
177
|
+
'button:has-text("Confirm Appointment")',
|
|
178
|
+
],
|
|
179
|
+
|
|
180
|
+
// -- Checkout / submit (legacy — use payAndConfirm for payment page) --
|
|
181
|
+
submitButton: [
|
|
182
|
+
'button:has-text("Pay & Confirm")',
|
|
183
|
+
'button:has-text("PAY & CONFIRM")',
|
|
184
|
+
'button[type="submit"].confirm',
|
|
185
|
+
'.complete-booking',
|
|
186
|
+
'#submit-booking',
|
|
187
|
+
'button:has-text("Complete Appointment")',
|
|
188
|
+
'button:has-text("Book Now")',
|
|
189
|
+
'button:has-text("Schedule")',
|
|
190
|
+
],
|
|
191
|
+
|
|
192
|
+
// -- Confirmation page --
|
|
193
|
+
confirmationPage: ['.confirmation', '.booking-confirmed', '.thank-you', '#confirmation'],
|
|
194
|
+
confirmationId: ['.confirmation-number', '.appointment-id', '[data-confirmation]'],
|
|
195
|
+
confirmationService: ['.appointment-type', '.service-name', '.booked-service'],
|
|
196
|
+
confirmationDatetime: ['.appointment-datetime', '.booked-time', '.booking-date'],
|
|
197
|
+
} as const;
|
|
198
|
+
|
|
199
|
+
// =============================================================================
|
|
200
|
+
// SELECTOR TYPE
|
|
201
|
+
// =============================================================================
|
|
202
|
+
|
|
203
|
+
export type SelectorKey = keyof typeof Selectors;
|
|
204
|
+
|
|
205
|
+
export interface ResolvedSelector {
|
|
206
|
+
readonly selector: string;
|
|
207
|
+
readonly element: ElementHandle;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// =============================================================================
|
|
211
|
+
// RESOLUTION UTILITIES
|
|
212
|
+
// =============================================================================
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Try selectors in order, return the first match.
|
|
216
|
+
* Fails with SelectorError if none match.
|
|
217
|
+
*/
|
|
218
|
+
export const resolveSelector = (
|
|
219
|
+
page: Page,
|
|
220
|
+
candidates: readonly string[],
|
|
221
|
+
timeout = 3000,
|
|
222
|
+
): Effect.Effect<ResolvedSelector, SelectorError> =>
|
|
223
|
+
Effect.gen(function* () {
|
|
224
|
+
for (const selector of candidates) {
|
|
225
|
+
const el = yield* Effect.tryPromise({
|
|
226
|
+
try: () =>
|
|
227
|
+
page.waitForSelector(selector, { timeout, state: 'attached' }).then(
|
|
228
|
+
(handle) => handle,
|
|
229
|
+
() => null,
|
|
230
|
+
),
|
|
231
|
+
catch: () => null,
|
|
232
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
233
|
+
|
|
234
|
+
if (el) {
|
|
235
|
+
return { selector, element: el } as ResolvedSelector;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return yield* Effect.fail(
|
|
240
|
+
new SelectorError({
|
|
241
|
+
candidates,
|
|
242
|
+
message: `None of [${candidates.join(', ')}] found within ${timeout}ms`,
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolve a selector from the registry by key name.
|
|
249
|
+
*/
|
|
250
|
+
export const resolve = (
|
|
251
|
+
page: Page,
|
|
252
|
+
key: SelectorKey,
|
|
253
|
+
timeout?: number,
|
|
254
|
+
): Effect.Effect<ResolvedSelector, SelectorError> =>
|
|
255
|
+
resolveSelector(page, Selectors[key], timeout);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Check if any selector in the candidates list exists on the page (non-blocking).
|
|
259
|
+
* Returns the matching selector string or null.
|
|
260
|
+
*/
|
|
261
|
+
export const probeSelector = (
|
|
262
|
+
page: Page,
|
|
263
|
+
candidates: readonly string[],
|
|
264
|
+
): Effect.Effect<string | null, never> =>
|
|
265
|
+
Effect.gen(function* () {
|
|
266
|
+
for (const selector of candidates) {
|
|
267
|
+
const exists = yield* Effect.tryPromise({
|
|
268
|
+
try: () => page.$(selector).then((el) => el !== null),
|
|
269
|
+
catch: () => false,
|
|
270
|
+
}).pipe(Effect.orElseSucceed(() => false));
|
|
271
|
+
|
|
272
|
+
if (exists) return selector;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Probe a selector from the registry by key name.
|
|
279
|
+
*/
|
|
280
|
+
export const probe = (page: Page, key: SelectorKey): Effect.Effect<string | null, never> =>
|
|
281
|
+
probeSelector(page, Selectors[key]);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Validate that all critical selectors can be resolved on the current page.
|
|
285
|
+
* Returns a report of which selectors passed/failed.
|
|
286
|
+
*/
|
|
287
|
+
export const healthCheck = (
|
|
288
|
+
page: Page,
|
|
289
|
+
keys: readonly SelectorKey[],
|
|
290
|
+
): Effect.Effect<
|
|
291
|
+
{ passed: SelectorKey[]; failed: SelectorKey[] },
|
|
292
|
+
never
|
|
293
|
+
> =>
|
|
294
|
+
Effect.gen(function* () {
|
|
295
|
+
const passed: SelectorKey[] = [];
|
|
296
|
+
const failed: SelectorKey[] = [];
|
|
297
|
+
|
|
298
|
+
for (const key of keys) {
|
|
299
|
+
const found = yield* probe(page, key);
|
|
300
|
+
if (found) {
|
|
301
|
+
passed.push(key);
|
|
302
|
+
} else {
|
|
303
|
+
failed.push(key);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { passed, failed };
|
|
308
|
+
});
|