@aglyn/plugins-bookings 1.0.0-beta.143

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 (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +53 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/book-meeting-action.d.ts +91 -0
  8. package/src/lib/components/book-meeting-action.js +296 -0
  9. package/src/lib/components/book-meeting-action.js.map +1 -0
  10. package/src/lib/components/booking.d.ts +33 -0
  11. package/src/lib/components/booking.js +463 -0
  12. package/src/lib/components/booking.js.map +1 -0
  13. package/src/lib/components/bookings-console-page.d.ts +12 -0
  14. package/src/lib/components/bookings-console-page.js +743 -0
  15. package/src/lib/components/bookings-console-page.js.map +1 -0
  16. package/src/lib/constants/bundle-common.d.ts +26 -0
  17. package/src/lib/constants/bundle-common.js +26 -0
  18. package/src/lib/constants/bundle-common.js.map +1 -0
  19. package/src/lib/model/booking-purchase-analytics.d.ts +129 -0
  20. package/src/lib/model/booking-purchase-analytics.js +197 -0
  21. package/src/lib/model/booking-purchase-analytics.js.map +1 -0
  22. package/src/lib/model/bookings.d.ts +141 -0
  23. package/src/lib/model/bookings.js +124 -0
  24. package/src/lib/model/bookings.js.map +1 -0
  25. package/src/lib/model/index.d.ts +22 -0
  26. package/src/lib/model/index.js +22 -0
  27. package/src/lib/model/index.js.map +1 -0
  28. package/src/lib/plugin-config.d.ts +24 -0
  29. package/src/lib/plugin-config.js +51 -0
  30. package/src/lib/plugin-config.js.map +1 -0
  31. package/src/lib/plugin.d.ts +22 -0
  32. package/src/lib/plugin.js +70 -0
  33. package/src/lib/plugin.js.map +1 -0
  34. package/src/lib/server/billing-webhook.d.ts +23 -0
  35. package/src/lib/server/billing-webhook.js +345 -0
  36. package/src/lib/server/billing-webhook.js.map +1 -0
  37. package/src/lib/server/booking-analytics.d.ts +68 -0
  38. package/src/lib/server/booking-analytics.js +115 -0
  39. package/src/lib/server/booking-analytics.js.map +1 -0
  40. package/src/lib/server/booking-crm.d.ts +39 -0
  41. package/src/lib/server/booking-crm.js +67 -0
  42. package/src/lib/server/booking-crm.js.map +1 -0
  43. package/src/lib/server/booking-figures.d.ts +32 -0
  44. package/src/lib/server/booking-figures.js +155 -0
  45. package/src/lib/server/booking-figures.js.map +1 -0
  46. package/src/lib/server/refund.d.ts +65 -0
  47. package/src/lib/server/refund.js +292 -0
  48. package/src/lib/server/refund.js.map +1 -0
  49. package/src/lib/server.d.ts +63 -0
  50. package/src/lib/server.js +968 -0
  51. package/src/lib/server.js.map +1 -0
  52. package/src/lib/site.d.ts +27 -0
  53. package/src/lib/site.js +50 -0
  54. package/src/lib/site.js.map +1 -0
  55. package/src/lib/testing/stand-in-tax-profile.d.ts +29 -0
  56. package/src/lib/testing/stand-in-tax-profile.js +64 -0
  57. package/src/lib/testing/stand-in-tax-profile.js.map +1 -0
  58. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  59. package/src/lib/utils/generate-preset-id.js +25 -0
  60. package/src/lib/utils/generate-preset-id.js.map +1 -0
  61. package/src/lib/utils/use-booking-purchase-event.d.ts +65 -0
  62. package/src/lib/utils/use-booking-purchase-event.js +147 -0
  63. package/src/lib/utils/use-booking-purchase-event.js.map +1 -0
@@ -0,0 +1,463 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
4
+ /**
5
+ * @license
6
+ * Copyright 2026 Aglyn LLC
7
+ *
8
+ * Licensed under the Apache License, Version 2.0 (the "License");
9
+ * you may not use this file except in compliance with the License.
10
+ * You may obtain a copy of the License at
11
+ *
12
+ * http://www.apache.org/licenses/LICENSE-2.0
13
+ *
14
+ * Unless required by applicable law or agreed to in writing, software
15
+ * distributed under the License is distributed on an "AS IS" BASIS,
16
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ * See the License for the specific language governing permissions and
18
+ * limitations under the License.
19
+ */ import * as Aglyn from "@aglyn/aglyn";
20
+ import { buildBeginCheckoutParams, trackEvent, trackEventBeforeNavigation } from "@aglyn/aglyn/app-utils/analytics-events";
21
+ import { campaignTouchField } from "@aglyn/aglyn/app-utils/campaign-touch";
22
+ import { BOOKING_SERVICE_PARAM, CRM_BOOKING_REF_PARAM, formatCrmBookingRef, parseCrmBookingRef } from "@aglyn/aglyn/app-utils/crm-booking";
23
+ import { mdiCalendarClock } from "@aglyn/shared-data-mdi";
24
+ import Alert from "@mui/material/Alert";
25
+ import Box from "@mui/material/Box";
26
+ import Button from "@mui/material/Button";
27
+ import Checkbox from "@mui/material/Checkbox";
28
+ import Chip from "@mui/material/Chip";
29
+ import CircularProgress from "@mui/material/CircularProgress";
30
+ import FormControlLabel from "@mui/material/FormControlLabel";
31
+ import MenuItem from "@mui/material/MenuItem";
32
+ import Stack from "@mui/material/Stack";
33
+ import TextField from "@mui/material/TextField";
34
+ import Typography from "@mui/material/Typography";
35
+ import { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
36
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
37
+ import { generatePresetId } from "../utils/generate-preset-id.js";
38
+ import { useBookingPurchaseEvent } from "../utils/use-booking-purchase-event.js";
39
+ // Component ids are persisted in screen documents; never rename.
40
+ export const ID = 'booking';
41
+ /**
42
+ * One query value off the page the widget is on, or `null` — on the server
43
+ * and in the besigner there is no page to read.
44
+ *
45
+ * A booking link built from a CRM record carries two (AGL-2660): the
46
+ * service it is about, and the record it was dropped from. Read at the
47
+ * moment each is needed rather than held in state, so a widget mounted
48
+ * before the URL settles still sees the values.
49
+ */ function readLinkParam(key) {
50
+ if (typeof window === 'undefined') return null;
51
+ return new URLSearchParams(window.location.search).get(key);
52
+ }
53
+ /**
54
+ * Booking widget (AGL-160): service picker → open-slot picker → contact
55
+ * form, driven by the tenant /api/bookings endpoints (server-validated,
56
+ * double-booking safe). Inert placeholder in the besigner/preview (no
57
+ * SiteContext host id) so editing never creates bookings.
58
+ */ const Booking = /*#__PURE__*/ forwardRef((props, ref)=>{
59
+ var _slotsByDay_day;
60
+ const { heading, successMessage } = props, rest = _object_without_properties_loose(props, [
61
+ "heading",
62
+ "successMessage"
63
+ ]);
64
+ // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).
65
+ const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [
66
+ props['sx']
67
+ ];
68
+ const { hostId } = Aglyn.useSite();
69
+ const siteFetch = Aglyn.useSiteFetch();
70
+ // The merchant's own `purchase` for a paid booking (AGL-2481). This widget
71
+ // is where Stripe returns the guest, because a tenant site has no booking
72
+ // confirmation route. No-ops on every ordinary render: the hook returns
73
+ // immediately unless the URL carries `?booking=paid&session_id=…`.
74
+ useBookingPurchaseEvent(hostId, siteFetch);
75
+ const [services, setServices] = useState(null);
76
+ const [serviceId, setServiceId] = useState('');
77
+ const [slots, setSlots] = useState(null);
78
+ const [slotMs, setSlotMs] = useState(null);
79
+ const [name, setName] = useState('');
80
+ const [email, setEmail] = useState('');
81
+ const [marketingConsent, setMarketingConsent] = useState(false);
82
+ const [status, setStatus] = useState('idle');
83
+ const [errorMessage, setErrorMessage] = useState(null);
84
+ const [alerts, setAlerts] = useState([]);
85
+ useEffect(()=>{
86
+ if (!hostId) return;
87
+ let active = true;
88
+ void fetch(`/api/bookings/slots?hostId=${encodeURIComponent(hostId)}`).then((response)=>response.json()).then((payload)=>{
89
+ var _ref;
90
+ if (!active) return;
91
+ const loaded = (_ref = payload == null ? void 0 : payload.services) != null ? _ref : [];
92
+ setServices(loaded);
93
+ // A booking link opens the widget on its service (AGL-2660). Only
94
+ // a service the site still offers: a link to one since deleted
95
+ // leaves the picker for the visitor rather than failing on it.
96
+ const wanted = readLinkParam(BOOKING_SERVICE_PARAM);
97
+ if (wanted && loaded.some((one)=>one.$id === wanted)) {
98
+ setServiceId(wanted);
99
+ }
100
+ }).catch(()=>{
101
+ if (active) setServices([]);
102
+ });
103
+ return ()=>{
104
+ active = false;
105
+ };
106
+ }, [
107
+ hostId
108
+ ]);
109
+ useEffect(()=>{
110
+ if (!hostId || !serviceId) return setSlots(null);
111
+ let active = true;
112
+ setSlots(null);
113
+ setSlotMs(null);
114
+ void fetch(`/api/bookings/slots?hostId=${encodeURIComponent(hostId)}` + `&serviceId=${encodeURIComponent(serviceId)}`).then((response)=>response.json()).then((payload)=>{
115
+ var _ref;
116
+ if (active) setSlots((_ref = payload == null ? void 0 : payload.slots) != null ? _ref : []);
117
+ }).catch(()=>{
118
+ if (active) setSlots([]);
119
+ });
120
+ return ()=>{
121
+ active = false;
122
+ };
123
+ }, [
124
+ hostId,
125
+ serviceId
126
+ ]);
127
+ // Slots grouped by local day for a compact two-step pick.
128
+ const slotsByDay = useMemo(()=>{
129
+ const groups = {};
130
+ for (const slot of slots != null ? slots : []){
131
+ var _groups, _day, _;
132
+ const day = new Date(slot.startsAtMs).toLocaleDateString();
133
+ ((_ = (_groups = groups)[_day = day]) != null ? _ : _groups[_day] = []).push(slot);
134
+ }
135
+ return groups;
136
+ }, [
137
+ slots
138
+ ]);
139
+ const [day, setDay] = useState('');
140
+ useEffect(()=>{
141
+ setDay('');
142
+ }, [
143
+ serviceId
144
+ ]);
145
+ const handleBook = useCallback(async ()=>{
146
+ if (!hostId || !serviceId || !slotMs || status === 'booking') return;
147
+ setStatus('booking');
148
+ setErrorMessage(null);
149
+ // The CRM record a booking link was dropped from (AGL-2660), carried
150
+ // onto the request so the booking lands on that record even when the
151
+ // visitor books with a different address. Parsed here so a value that
152
+ // is not a reference is never sent.
153
+ const crmRef = parseCrmBookingRef(readLinkParam(CRM_BOOKING_REF_PARAM));
154
+ try {
155
+ const response = await siteFetch('/api/bookings/book', {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json'
159
+ },
160
+ body: JSON.stringify(_extends({
161
+ hostId,
162
+ serviceId,
163
+ startsAtMs: slotMs,
164
+ name,
165
+ email
166
+ }, crmRef ? {
167
+ crmRef: formatCrmBookingRef(crmRef)
168
+ } : {}, marketingConsent ? {
169
+ marketingConsent: true
170
+ } : {}, campaignTouchField()))
171
+ });
172
+ const payload = await response.json().catch(()=>({}));
173
+ if (!response.ok) {
174
+ var _ref;
175
+ setErrorMessage((_ref = payload == null ? void 0 : payload.error) != null ? _ref : 'Booking failed — try again');
176
+ setStatus('error');
177
+ return;
178
+ }
179
+ if (payload == null ? void 0 : payload.checkoutUrl) {
180
+ var _ref1;
181
+ // Paid service (AGL-170): finish payment on Stripe; the webhook
182
+ // confirms the held slot.
183
+ //
184
+ // The `begin_checkout` that `purchase` has never had a counterpart to
185
+ // (AGL-2481 wired the completion and left the intent). Without it a
186
+ // merchant selling appointments sees a purchase with no checkout step
187
+ // in front of it, so GA4's shopping funnel reports every paid booking
188
+ // as an abandoned one — the same shape the bookings plugin was
189
+ // reporting before it sent anything at all.
190
+ //
191
+ // After the server accepted the hold, so a double-booked slot or a
192
+ // closed calendar — both of which answer !ok above — never counts.
193
+ //
194
+ // `item_id` is the SERVICE id, matching the id
195
+ // `buildBookingPurchaseParams` puts on the completed hit, so the two
196
+ // steps join into one per-service funnel instead of naming the same
197
+ // service twice.
198
+ const service = (services != null ? services : []).find((one)=>one.$id === serviceId);
199
+ await trackEventBeforeNavigation('begin_checkout', buildBeginCheckoutParams({
200
+ items: [
201
+ {
202
+ item_id: serviceId,
203
+ item_name: (_ref1 = service == null ? void 0 : service.name) != null ? _ref1 : 'Service',
204
+ price: service == null ? void 0 : service.priceUsd,
205
+ quantity: 1
206
+ }
207
+ ]
208
+ }));
209
+ window.location.assign(payload.checkoutUrl);
210
+ return;
211
+ }
212
+ if (Array.isArray(payload == null ? void 0 : payload.alerts)) setAlerts(payload.alerts);
213
+ // A FREE booking is confirmed right here — there is no Stripe leg, so
214
+ // nothing downstream will ever report it, and a merchant whose services
215
+ // are free saw an appointment book with no event of any kind.
216
+ //
217
+ // `generate_lead` rather than a zero-value `purchase`: GA4 has no
218
+ // recommended booking event, `purchase` with `value: 0` would put a
219
+ // free appointment into the merchant's revenue reports as a sale worth
220
+ // nothing, and a confirmed request for someone's time IS the lead this
221
+ // event names. The form's own generic submissions report the same event
222
+ // from `form.tsx`, so the merchant reads one conversion count.
223
+ //
224
+ // `form_name` is the block, not the service and never the guest: the
225
+ // name and email typed above are the reason this is the only param
226
+ // shape that may leave here.
227
+ trackEvent('generate_lead', {
228
+ form_name: 'Booking',
229
+ form_location: window.location.pathname
230
+ });
231
+ setStatus('booked');
232
+ } catch (unused) {
233
+ setErrorMessage('Booking failed — try again');
234
+ setStatus('error');
235
+ }
236
+ }, [
237
+ hostId,
238
+ serviceId,
239
+ slotMs,
240
+ name,
241
+ email,
242
+ marketingConsent,
243
+ status,
244
+ siteFetch,
245
+ // Read for the checkout event's item name and price. Without it the
246
+ // handler closes over the services list as it was when the handler was
247
+ // created, which on the first render is `null` — and every paid booking
248
+ // would report an unnamed, unpriced service.
249
+ services
250
+ ]);
251
+ if (!hostId) {
252
+ return /*#__PURE__*/ _jsx(Box, _extends({
253
+ ref: ref
254
+ }, rest, {
255
+ sx: [
256
+ {
257
+ p: 3,
258
+ border: '1px dashed',
259
+ borderColor: 'divider',
260
+ borderRadius: 1,
261
+ color: 'text.secondary',
262
+ fontSize: 13,
263
+ fontFamily: 'system-ui, sans-serif'
264
+ },
265
+ ...nodeSx
266
+ ],
267
+ children: 'Booking widget — visitors pick a service and time here'
268
+ }));
269
+ }
270
+ if (status === 'booked') {
271
+ return /*#__PURE__*/ _jsxs(Stack, _extends({
272
+ ref: ref,
273
+ spacing: 1.5
274
+ }, rest, {
275
+ children: [
276
+ /*#__PURE__*/ _jsx(Alert, {
277
+ severity: "success",
278
+ children: successMessage || 'Booking confirmed — check your email for the details.'
279
+ }),
280
+ alerts.map((alert, index)=>/*#__PURE__*/ _jsx(Alert, {
281
+ severity: alert.severity || 'info',
282
+ children: alert.message
283
+ }, index))
284
+ ]
285
+ }));
286
+ }
287
+ const selected = (services != null ? services : []).find((service)=>service.$id === serviceId);
288
+ return /*#__PURE__*/ _jsxs(Stack, _extends({
289
+ ref: ref,
290
+ spacing: 2
291
+ }, rest, {
292
+ children: [
293
+ heading ? /*#__PURE__*/ _jsx(Typography, {
294
+ variant: "h5",
295
+ children: heading
296
+ }) : null,
297
+ services === null ? /*#__PURE__*/ _jsx(CircularProgress, {
298
+ size: 24
299
+ }) : services.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
300
+ variant: "body2",
301
+ color: "text.secondary",
302
+ children: 'No bookable services yet.'
303
+ }) : /*#__PURE__*/ _jsx(TextField, {
304
+ select: true,
305
+ label: "Service",
306
+ value: serviceId,
307
+ onChange: (event)=>setServiceId(event.target.value),
308
+ size: "small",
309
+ fullWidth: true,
310
+ children: services.map((service)=>/*#__PURE__*/ _jsx(MenuItem, {
311
+ value: service.$id,
312
+ children: `${service.name} · ${service.durationMinutes} min` + (service.priceUsd > 0 ? ` · $${service.priceUsd}` : '')
313
+ }, service.$id))
314
+ }),
315
+ (selected == null ? void 0 : selected.description) ? /*#__PURE__*/ _jsx(Typography, {
316
+ variant: "body2",
317
+ color: "text.secondary",
318
+ children: selected.description
319
+ }) : null,
320
+ serviceId ? slots === null ? /*#__PURE__*/ _jsx(CircularProgress, {
321
+ size: 24
322
+ }) : slots.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
323
+ variant: "body2",
324
+ color: "text.secondary",
325
+ children: 'No open times in the next 60 days.'
326
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
327
+ children: [
328
+ /*#__PURE__*/ _jsx(Stack, {
329
+ direction: "row",
330
+ spacing: 1,
331
+ sx: {
332
+ flexWrap: 'wrap',
333
+ rowGap: 1
334
+ },
335
+ children: Object.keys(slotsByDay).slice(0, 14).map((value)=>/*#__PURE__*/ _jsx(Chip, {
336
+ label: value,
337
+ color: day === value ? 'primary' : 'default',
338
+ variant: day === value ? 'filled' : 'outlined',
339
+ onClick: ()=>{
340
+ setDay(value);
341
+ setSlotMs(null);
342
+ }
343
+ }, value))
344
+ }),
345
+ day ? /*#__PURE__*/ _jsx(Stack, {
346
+ direction: "row",
347
+ spacing: 1,
348
+ sx: {
349
+ flexWrap: 'wrap',
350
+ rowGap: 1
351
+ },
352
+ children: ((_slotsByDay_day = slotsByDay[day]) != null ? _slotsByDay_day : []).slice(0, 24).map((slot)=>/*#__PURE__*/ _jsx(Chip, {
353
+ label: new Date(slot.startsAtMs).toLocaleTimeString([], {
354
+ hour: 'numeric',
355
+ minute: '2-digit'
356
+ }),
357
+ color: slotMs === slot.startsAtMs ? 'primary' : 'default',
358
+ variant: slotMs === slot.startsAtMs ? 'filled' : 'outlined',
359
+ onClick: ()=>setSlotMs(slot.startsAtMs)
360
+ }, slot.startsAtMs))
361
+ }) : null
362
+ ]
363
+ }) : null,
364
+ slotMs ? /*#__PURE__*/ _jsxs(Stack, {
365
+ spacing: 1.5,
366
+ children: [
367
+ /*#__PURE__*/ _jsx(TextField, {
368
+ label: "Your name",
369
+ value: name,
370
+ onChange: (event)=>setName(event.target.value),
371
+ size: "small",
372
+ fullWidth: true
373
+ }),
374
+ /*#__PURE__*/ _jsx(TextField, {
375
+ label: "Email",
376
+ type: "email",
377
+ value: email,
378
+ onChange: (event)=>setEmail(event.target.value),
379
+ size: "small",
380
+ fullWidth: true
381
+ }),
382
+ /*#__PURE__*/ _jsx(FormControlLabel, {
383
+ control: /*#__PURE__*/ _jsx(Checkbox, {
384
+ size: "small",
385
+ checked: marketingConsent,
386
+ onChange: (event)=>setMarketingConsent(event.target.checked)
387
+ }),
388
+ label: "Email me about offers and updates"
389
+ }),
390
+ errorMessage ? /*#__PURE__*/ _jsx(Alert, {
391
+ severity: "error",
392
+ children: errorMessage
393
+ }) : null,
394
+ /*#__PURE__*/ _jsx(Button, {
395
+ variant: "contained",
396
+ disabled: !name.trim() || !email.trim() || status === 'booking',
397
+ onClick: handleBook,
398
+ sx: {
399
+ alignSelf: 'flex-start'
400
+ },
401
+ children: status === 'booking' ? 'Booking…' : 'Confirm booking'
402
+ })
403
+ ]
404
+ }) : null
405
+ ]
406
+ }));
407
+ });
408
+ Booking.displayName = 'Booking';
409
+ export const schema = {
410
+ $id: ID,
411
+ pluginId: BUNDLE_ID,
412
+ displayName: 'Booking',
413
+ description: 'Service and time picker that books appointments. It is double-booking safe.',
414
+ category: Aglyn.ComponentCategory.INPUT,
415
+ icon: {
416
+ path: mdiCalendarClock.path,
417
+ sx: {
418
+ color: '#0288d1'
419
+ }
420
+ },
421
+ flags: {
422
+ selfClosing: Aglyn.FEATURE_FLAG.ENABLED
423
+ },
424
+ attributes: [
425
+ {
426
+ name: 'heading',
427
+ description: 'Heading above the widget; empty hides it.',
428
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
429
+ label: 'Heading'
430
+ },
431
+ {
432
+ name: 'successMessage',
433
+ description: 'Shown after a confirmed booking.',
434
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
435
+ label: 'Success message'
436
+ }
437
+ ]
438
+ };
439
+ export const presets = [
440
+ {
441
+ $id: generatePresetId(ID),
442
+ type: Aglyn.NodeType.PRESET,
443
+ displayName: 'Booking',
444
+ pluginId: BUNDLE_ID,
445
+ description: 'Service + time picker that books appointments',
446
+ category: Aglyn.ComponentCategory.INPUT,
447
+ icon: {
448
+ path: mdiCalendarClock.path,
449
+ sx: {
450
+ color: '#0288d1'
451
+ }
452
+ },
453
+ data: {
454
+ $id: null,
455
+ componentId: ID,
456
+ pluginId: BUNDLE_ID,
457
+ props: {}
458
+ }
459
+ }
460
+ ];
461
+ export default Booking;
462
+
463
+ //# sourceMappingURL=booking.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/components/booking.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport {\n buildBeginCheckoutParams,\n trackEvent,\n trackEventBeforeNavigation,\n} from '@aglyn/aglyn/app-utils/analytics-events'\nimport { campaignTouchField } from '@aglyn/aglyn/app-utils/campaign-touch'\nimport {\n BOOKING_SERVICE_PARAM,\n CRM_BOOKING_REF_PARAM,\n formatCrmBookingRef,\n parseCrmBookingRef,\n} from '@aglyn/aglyn/app-utils/crm-booking'\nimport { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport Alert from '@mui/material/Alert'\nimport Box from '@mui/material/Box'\nimport Button from '@mui/material/Button'\nimport Checkbox from '@mui/material/Checkbox'\nimport Chip from '@mui/material/Chip'\nimport CircularProgress from '@mui/material/CircularProgress'\nimport FormControlLabel from '@mui/material/FormControlLabel'\nimport MenuItem from '@mui/material/MenuItem'\nimport Stack from '@mui/material/Stack'\nimport TextField from '@mui/material/TextField'\nimport Typography from '@mui/material/Typography'\nimport { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { generatePresetId } from '../utils/generate-preset-id'\nimport { useBookingPurchaseEvent } from '../utils/use-booking-purchase-event'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'booking'\n\nexport interface BookingProps {\n /** Heading above the widget; empty hides it. */\n heading?: string\n successMessage?: string\n}\n\ninterface ServiceOption {\n $id: string\n name: string\n durationMinutes: number\n priceUsd: number\n description?: string\n}\n\n/**\n * One query value off the page the widget is on, or `null` — on the server\n * and in the besigner there is no page to read.\n *\n * A booking link built from a CRM record carries two (AGL-2660): the\n * service it is about, and the record it was dropped from. Read at the\n * moment each is needed rather than held in state, so a widget mounted\n * before the URL settles still sees the values.\n */\nfunction readLinkParam(key: string): string | null {\n if (typeof window === 'undefined') return null\n return new URLSearchParams(window.location.search).get(key)\n}\n\ninterface SlotOption {\n startsAtMs: number\n endsAtMs: number\n}\n\n/**\n * Booking widget (AGL-160): service picker → open-slot picker → contact\n * form, driven by the tenant /api/bookings endpoints (server-validated,\n * double-booking safe). Inert placeholder in the besigner/preview (no\n * SiteContext host id) so editing never creates bookings.\n */\nconst Booking = forwardRef<HTMLDivElement, BookingProps>((props, ref) => {\n const { heading, successMessage, ...rest } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const siteFetch = Aglyn.useSiteFetch()\n\n // The merchant's own `purchase` for a paid booking (AGL-2481). This widget\n // is where Stripe returns the guest, because a tenant site has no booking\n // confirmation route. No-ops on every ordinary render: the hook returns\n // immediately unless the URL carries `?booking=paid&session_id=…`.\n useBookingPurchaseEvent(hostId, siteFetch)\n\n const [services, setServices] = useState<ServiceOption[] | null>(null)\n const [serviceId, setServiceId] = useState('')\n const [slots, setSlots] = useState<SlotOption[] | null>(null)\n const [slotMs, setSlotMs] = useState<number | null>(null)\n const [name, setName] = useState('')\n const [email, setEmail] = useState('')\n const [marketingConsent, setMarketingConsent] = useState(false)\n const [status, setStatus] = useState<\n 'idle' | 'loading' | 'booking' | 'booked' | 'error'\n >('idle')\n const [errorMessage, setErrorMessage] = useState<string | null>(null)\n const [alerts, setAlerts] = useState<\n Array<{ message: string; severity?: string }>\n >([])\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void fetch(`/api/bookings/slots?hostId=${encodeURIComponent(hostId)}`)\n .then((response) => response.json())\n .then((payload) => {\n if (!active) return\n const loaded: ServiceOption[] = payload?.services ?? []\n setServices(loaded)\n // A booking link opens the widget on its service (AGL-2660). Only\n // a service the site still offers: a link to one since deleted\n // leaves the picker for the visitor rather than failing on it.\n const wanted = readLinkParam(BOOKING_SERVICE_PARAM)\n if (wanted && loaded.some((one) => one.$id === wanted)) {\n setServiceId(wanted)\n }\n })\n .catch(() => {\n if (active) setServices([])\n })\n return () => {\n active = false\n }\n }, [hostId])\n\n useEffect(() => {\n if (!hostId || !serviceId) return setSlots(null)\n let active = true\n setSlots(null)\n setSlotMs(null)\n void fetch(\n `/api/bookings/slots?hostId=${encodeURIComponent(hostId)}` +\n `&serviceId=${encodeURIComponent(serviceId)}`,\n )\n .then((response) => response.json())\n .then((payload) => {\n if (active) setSlots(payload?.slots ?? [])\n })\n .catch(() => {\n if (active) setSlots([])\n })\n return () => {\n active = false\n }\n }, [hostId, serviceId])\n\n // Slots grouped by local day for a compact two-step pick.\n const slotsByDay = useMemo(() => {\n const groups: Record<string, SlotOption[]> = {}\n for (const slot of slots ?? []) {\n const day = new Date(slot.startsAtMs).toLocaleDateString()\n ;(groups[day] ??= []).push(slot)\n }\n return groups\n }, [slots])\n const [day, setDay] = useState('')\n useEffect(() => {\n setDay('')\n }, [serviceId])\n\n const handleBook = useCallback(async () => {\n if (!hostId || !serviceId || !slotMs || status === 'booking') return\n setStatus('booking')\n setErrorMessage(null)\n // The CRM record a booking link was dropped from (AGL-2660), carried\n // onto the request so the booking lands on that record even when the\n // visitor books with a different address. Parsed here so a value that\n // is not a reference is never sent.\n const crmRef = parseCrmBookingRef(readLinkParam(CRM_BOOKING_REF_PARAM))\n try {\n const response = await siteFetch('/api/bookings/book', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n serviceId,\n startsAtMs: slotMs,\n name,\n email,\n ...(crmRef ? { crmRef: formatCrmBookingRef(crmRef) } : {}),\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // The campaign this visitor came from, when they came from one.\n // A booking is an identify moment like a form submission: the\n // visitor was anonymous until this request named them.\n ...campaignTouchField(),\n }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n setErrorMessage(payload?.error ?? 'Booking failed — try again')\n setStatus('error')\n return\n }\n if (payload?.checkoutUrl) {\n // Paid service (AGL-170): finish payment on Stripe; the webhook\n // confirms the held slot.\n //\n // The `begin_checkout` that `purchase` has never had a counterpart to\n // (AGL-2481 wired the completion and left the intent). Without it a\n // merchant selling appointments sees a purchase with no checkout step\n // in front of it, so GA4's shopping funnel reports every paid booking\n // as an abandoned one — the same shape the bookings plugin was\n // reporting before it sent anything at all.\n //\n // After the server accepted the hold, so a double-booked slot or a\n // closed calendar — both of which answer !ok above — never counts.\n //\n // `item_id` is the SERVICE id, matching the id\n // `buildBookingPurchaseParams` puts on the completed hit, so the two\n // steps join into one per-service funnel instead of naming the same\n // service twice.\n const service = (services ?? []).find((one) => one.$id === serviceId)\n await trackEventBeforeNavigation(\n 'begin_checkout',\n buildBeginCheckoutParams({\n items: [\n {\n item_id: serviceId,\n item_name: service?.name ?? 'Service',\n price: service?.priceUsd,\n quantity: 1,\n },\n ],\n }),\n )\n window.location.assign(payload.checkoutUrl)\n return\n }\n if (Array.isArray(payload?.alerts)) setAlerts(payload.alerts)\n // A FREE booking is confirmed right here — there is no Stripe leg, so\n // nothing downstream will ever report it, and a merchant whose services\n // are free saw an appointment book with no event of any kind.\n //\n // `generate_lead` rather than a zero-value `purchase`: GA4 has no\n // recommended booking event, `purchase` with `value: 0` would put a\n // free appointment into the merchant's revenue reports as a sale worth\n // nothing, and a confirmed request for someone's time IS the lead this\n // event names. The form's own generic submissions report the same event\n // from `form.tsx`, so the merchant reads one conversion count.\n //\n // `form_name` is the block, not the service and never the guest: the\n // name and email typed above are the reason this is the only param\n // shape that may leave here.\n trackEvent('generate_lead', {\n form_name: 'Booking',\n form_location: window.location.pathname,\n })\n setStatus('booked')\n } catch {\n setErrorMessage('Booking failed — try again')\n setStatus('error')\n }\n }, [\n hostId,\n serviceId,\n slotMs,\n name,\n email,\n marketingConsent,\n status,\n siteFetch,\n // Read for the checkout event's item name and price. Without it the\n // handler closes over the services list as it was when the handler was\n // created, which on the first render is `null` — and every paid booking\n // would report an unnamed, unpriced service.\n services,\n ])\n\n if (!hostId) {\n return (\n <Box\n ref={ref}\n {...rest}\n sx={[\n {\n p: 3,\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: 1,\n color: 'text.secondary',\n fontSize: 13,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'Booking widget — visitors pick a service and time here'}\n </Box>\n )\n }\n\n if (status === 'booked') {\n return (\n <Stack ref={ref} spacing={1.5} {...rest}>\n <Alert severity=\"success\">\n {successMessage ||\n 'Booking confirmed — check your email for the details.'}\n </Alert>\n {alerts.map((alert, index) => (\n <Alert key={index} severity={(alert.severity as any) || 'info'}>\n {alert.message}\n </Alert>\n ))}\n </Stack>\n )\n }\n\n const selected = (services ?? []).find(\n (service) => service.$id === serviceId,\n )\n\n return (\n <Stack ref={ref} spacing={2} {...rest}>\n {heading ? <Typography variant=\"h5\">{heading}</Typography> : null}\n {services === null ? (\n <CircularProgress size={24} />\n ) : services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No bookable services yet.'}\n </Typography>\n ) : (\n <TextField\n select\n label=\"Service\"\n value={serviceId}\n onChange={(event) => setServiceId(event.target.value)}\n size=\"small\"\n fullWidth\n >\n {services.map((service) => (\n <MenuItem key={service.$id} value={service.$id}>\n {`${service.name} · ${service.durationMinutes} min` +\n (service.priceUsd > 0 ? ` · $${service.priceUsd}` : '')}\n </MenuItem>\n ))}\n </TextField>\n )}\n {selected?.description ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {selected.description}\n </Typography>\n ) : null}\n {serviceId ? (\n slots === null ? (\n <CircularProgress size={24} />\n ) : slots.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open times in the next 60 days.'}\n </Typography>\n ) : (\n <>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n {Object.keys(slotsByDay)\n .slice(0, 14)\n .map((value) => (\n <Chip\n key={value}\n label={value}\n color={day === value ? 'primary' : 'default'}\n variant={day === value ? 'filled' : 'outlined'}\n onClick={() => {\n setDay(value)\n setSlotMs(null)\n }}\n />\n ))}\n </Stack>\n {day ? (\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n {(slotsByDay[day] ?? []).slice(0, 24).map((slot) => (\n <Chip\n key={slot.startsAtMs}\n label={new Date(slot.startsAtMs).toLocaleTimeString([], {\n hour: 'numeric',\n minute: '2-digit',\n })}\n color={slotMs === slot.startsAtMs ? 'primary' : 'default'}\n variant={\n slotMs === slot.startsAtMs ? 'filled' : 'outlined'\n }\n onClick={() => setSlotMs(slot.startsAtMs)}\n />\n ))}\n </Stack>\n ) : null}\n </>\n )\n ) : null}\n {slotMs ? (\n <Stack spacing={1.5}>\n <TextField\n label=\"Your name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n size=\"small\"\n fullWidth\n />\n <TextField\n label=\"Email\"\n type=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n size=\"small\"\n fullWidth\n />\n <FormControlLabel\n control={\n <Checkbox\n size=\"small\"\n checked={marketingConsent}\n onChange={(event) =>\n setMarketingConsent(event.target.checked)\n }\n />\n }\n label=\"Email me about offers and updates\"\n />\n {errorMessage ? <Alert severity=\"error\">{errorMessage}</Alert> : null}\n <Button\n variant=\"contained\"\n disabled={\n !name.trim() || !email.trim() || status === 'booking'\n }\n onClick={handleBook}\n sx={{ alignSelf: 'flex-start' }}\n >\n {status === 'booking' ? 'Booking…' : 'Confirm booking'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n )\n})\nBooking.displayName = 'Booking'\n\nexport const schema: Aglyn.ComponentSchema<BookingProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Booking',\n description:\n 'Service and time picker that books appointments. It is double-booking safe.',\n category: Aglyn.ComponentCategory.INPUT,\n icon: {\n path: mdiCalendarClock.path,\n sx: { color: '#0288d1' },\n },\n flags: {\n selfClosing: Aglyn.FEATURE_FLAG.ENABLED,\n },\n attributes: [\n {\n name: 'heading',\n description: 'Heading above the widget; empty hides it.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Heading',\n },\n {\n name: 'successMessage',\n description: 'Shown after a confirmed booking.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Success message',\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Booking',\n pluginId: BUNDLE_ID,\n description: 'Service + time picker that books appointments',\n category: Aglyn.ComponentCategory.INPUT,\n icon: {\n path: mdiCalendarClock.path,\n sx: { color: '#0288d1' },\n },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default Booking\n"],"names":["Aglyn","buildBeginCheckoutParams","trackEvent","trackEventBeforeNavigation","campaignTouchField","BOOKING_SERVICE_PARAM","CRM_BOOKING_REF_PARAM","formatCrmBookingRef","parseCrmBookingRef","mdiCalendarClock","Alert","Box","Button","Checkbox","Chip","CircularProgress","FormControlLabel","MenuItem","Stack","TextField","Typography","forwardRef","useCallback","useEffect","useMemo","useState","BUNDLE_ID","generatePresetId","useBookingPurchaseEvent","ID","readLinkParam","key","window","URLSearchParams","location","search","get","Booking","props","ref","slotsByDay","heading","successMessage","rest","nodeSx","Array","isArray","hostId","useSite","siteFetch","useSiteFetch","services","setServices","serviceId","setServiceId","slots","setSlots","slotMs","setSlotMs","name","setName","email","setEmail","marketingConsent","setMarketingConsent","status","setStatus","errorMessage","setErrorMessage","alerts","setAlerts","active","fetch","encodeURIComponent","then","response","json","payload","loaded","wanted","some","one","$id","catch","groups","slot","day","Date","startsAtMs","toLocaleDateString","push","setDay","handleBook","crmRef","method","headers","body","JSON","stringify","ok","error","checkoutUrl","service","find","items","item_id","item_name","price","priceUsd","quantity","assign","form_name","form_location","pathname","sx","p","border","borderColor","borderRadius","color","fontSize","fontFamily","spacing","severity","map","alert","index","message","selected","variant","size","length","select","label","value","onChange","event","target","fullWidth","durationMinutes","description","direction","flexWrap","rowGap","Object","keys","slice","onClick","toLocaleTimeString","hour","minute","type","control","checked","disabled","trim","alignSelf","displayName","schema","pluginId","category","ComponentCategory","INPUT","icon","path","flags","selfClosing","FEATURE_FLAG","ENABLED","attributes","component","FieldComponentType","TEXT_FIELD","presets","NodeType","PRESET","data","componentId"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import { type ConsolePluginPageProps } from '@aglyn/aglyn';
2
+ /**
3
+ * Bookings manager (AGL-159): bookable services with weekly availability
4
+ * windows, and the upcoming-bookings list with cancel. Visitors book via
5
+ * the org /api/bookings endpoints; confirmations email through the
6
+ * env-gated Resend path. Plan-gated (`bookings` flag + `servicesPerHost`).
7
+ */
8
+ export declare function BookingsConsolePage(props: ConsolePluginPageProps): import("react").JSX.Element;
9
+ export declare namespace BookingsConsolePage {
10
+ var displayName: string;
11
+ }
12
+ export default BookingsConsolePage;