@aglyn/plugins-bookings 1.0.0-beta.221 → 1.0.0-beta.223

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 (52) hide show
  1. package/README.md +3 -2
  2. package/package.json +11 -11
  3. package/src/lib/components/book-meeting-action.js +5 -3
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.d.ts +33 -0
  6. package/src/lib/components/booking.js +260 -54
  7. package/src/lib/components/booking.js.map +1 -1
  8. package/src/lib/components/bookings-console-page.js +139 -15
  9. package/src/lib/components/bookings-console-page.js.map +1 -1
  10. package/src/lib/declarations.console-server.d.ts +34 -0
  11. package/src/lib/declarations.console-server.js +84 -0
  12. package/src/lib/declarations.console-server.js.map +1 -0
  13. package/src/lib/model/booking-contact-fields.d.ts +88 -0
  14. package/src/lib/model/booking-contact-fields.js +130 -0
  15. package/src/lib/model/booking-contact-fields.js.map +1 -0
  16. package/src/lib/model/booking-price.d.ts +51 -0
  17. package/src/lib/model/booking-price.js +61 -0
  18. package/src/lib/model/booking-price.js.map +1 -0
  19. package/src/lib/model/booking-record.d.ts +7 -0
  20. package/src/lib/model/booking-record.js +11 -2
  21. package/src/lib/model/booking-record.js.map +1 -1
  22. package/src/lib/model/bookings-record-counts.d.ts +25 -0
  23. package/src/lib/model/bookings-record-counts.js +34 -0
  24. package/src/lib/model/bookings-record-counts.js.map +1 -0
  25. package/src/lib/model/bookings.d.ts +61 -4
  26. package/src/lib/model/bookings.js +81 -15
  27. package/src/lib/model/bookings.js.map +1 -1
  28. package/src/lib/model/index.d.ts +2 -0
  29. package/src/lib/model/index.js +2 -0
  30. package/src/lib/model/index.js.map +1 -1
  31. package/src/lib/plugin.js +14 -0
  32. package/src/lib/plugin.js.map +1 -1
  33. package/src/lib/server/billing-webhook.js +33 -15
  34. package/src/lib/server/billing-webhook.js.map +1 -1
  35. package/src/lib/server/booking-crm.js +4 -1
  36. package/src/lib/server/booking-crm.js.map +1 -1
  37. package/src/lib/server/person-eraser.d.ts +36 -0
  38. package/src/lib/server/person-eraser.js +48 -0
  39. package/src/lib/server/person-eraser.js.map +1 -0
  40. package/src/lib/server/refund.js +2 -2
  41. package/src/lib/server/refund.js.map +1 -1
  42. package/src/lib/server.js +97 -36
  43. package/src/lib/server.js.map +1 -1
  44. package/src/lib/transfer/bookings-transfer-common.d.ts +68 -0
  45. package/src/lib/transfer/bookings-transfer-common.js +130 -0
  46. package/src/lib/transfer/bookings-transfer-common.js.map +1 -0
  47. package/src/lib/transfer/bookings-transfer.d.ts +86 -0
  48. package/src/lib/transfer/bookings-transfer.js +613 -0
  49. package/src/lib/transfer/bookings-transfer.js.map +1 -0
  50. package/src/lib/transfer/bookings-transfer.server.d.ts +26 -0
  51. package/src/lib/transfer/bookings-transfer.server.js +31 -0
  52. package/src/lib/transfer/bookings-transfer.server.js.map +1 -0
@@ -31,9 +31,12 @@ import MenuItem from "@mui/material/MenuItem";
31
31
  import Stack from "@mui/material/Stack";
32
32
  import TextField from "@mui/material/TextField";
33
33
  import Typography from "@mui/material/Typography";
34
- import { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
34
+ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
35
35
  import { BUNDLE_ID } from "../constants/bundle-common.js";
36
36
  import { BOOKING_RECORD_PARAM, BOOKING_SERVICE_PARAM, formatBookingRecordRef, parseBookingRecordRef } from "../model/booking-record.js";
37
+ import { bookingContactAsks, readBookingContactFields, readBookingPhone } from "../model/booking-contact-fields.js";
38
+ import { bookingPriceDisplay, bookingPriceText } from "../model/booking-price.js";
39
+ import { BOOKING_MAX_DAYS_AHEAD, BOOKING_SLOT_PAGE_DAYS } from "../model/bookings.js";
37
40
  import { generatePresetId } from "../utils/generate-preset-id.js";
38
41
  import { useBookingPurchaseEvent } from "../utils/use-booking-purchase-event.js";
39
42
  // Component ids are persisted in screen documents; never rename.
@@ -50,13 +53,81 @@ export const ID = 'booking';
50
53
  if (typeof window === 'undefined') return null;
51
54
  return new URLSearchParams(window.location.search).get(key);
52
55
  }
56
+ /**
57
+ * Slots grouped by the VISITOR's local day, in order (AGL-3492).
58
+ *
59
+ * A page of the listing is whole days in the SERVICE's zone, so for a
60
+ * visitor in another zone the last day of a page can be one the page ended
61
+ * part way through. `nextFromMs` is where the page stopped — every open time
62
+ * before it is loaded — so a day that runs past it is held back until the
63
+ * next page completes it, rather than shown with its later times missing.
64
+ */ export function groupSlotsByLocalDay(slots, nextFromMs) {
65
+ const byDay = new Map();
66
+ for (const slot of slots){
67
+ const at = new Date(slot.startsAtMs);
68
+ const startMs = new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime();
69
+ const day = byDay.get(startMs);
70
+ if (day) day.push(slot);
71
+ else byDay.set(startMs, [
72
+ slot
73
+ ]);
74
+ }
75
+ return [
76
+ ...byDay.entries()
77
+ ].map(([startMs, daySlots])=>({
78
+ startMs,
79
+ slots: daySlots.sort((a, b)=>a.startsAtMs - b.startsAtMs)
80
+ })).filter((day)=>{
81
+ if (nextFromMs === null) return true;
82
+ const start = new Date(day.startMs);
83
+ const nextDayMs = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1).getTime();
84
+ return nextDayMs <= nextFromMs;
85
+ }).sort((a, b)=>a.startMs - b.startMs);
86
+ }
87
+ /** A day chip's label: `Mon, Oct 5` in the visitor's locale. */ export function bookingDayLabel(startMs) {
88
+ return new Date(startMs).toLocaleDateString(undefined, {
89
+ weekday: 'short',
90
+ month: 'short',
91
+ day: 'numeric'
92
+ });
93
+ }
94
+ /** A time chip's label: `2:30 PM` in the visitor's locale. */ export function bookingTimeLabel(startsAtMs) {
95
+ return new Date(startsAtMs).toLocaleTimeString([], {
96
+ hour: 'numeric',
97
+ minute: '2-digit'
98
+ });
99
+ }
100
+ /**
101
+ * A day's times in the parts of the day a visitor scans for (AGL-3492): a
102
+ * day open 8 to 5 at 15-minute steps holds 33 starts, and one unbroken run
103
+ * of chips that long reads as noise. Empty parts are left out.
104
+ */ export function partsOfDay(slots) {
105
+ const parts = [
106
+ {
107
+ label: 'Morning',
108
+ slots: []
109
+ },
110
+ {
111
+ label: 'Afternoon',
112
+ slots: []
113
+ },
114
+ {
115
+ label: 'Evening',
116
+ slots: []
117
+ }
118
+ ];
119
+ for (const slot of slots){
120
+ const hour = new Date(slot.startsAtMs).getHours();
121
+ parts[hour < 12 ? 0 : hour < 17 ? 1 : 2].slots.push(slot);
122
+ }
123
+ return parts.filter((part)=>part.slots.length > 0);
124
+ }
53
125
  /**
54
126
  * Booking widget (AGL-160): service picker → open-slot picker → contact
55
127
  * form, driven by the tenant /api/bookings endpoints (server-validated,
56
128
  * double-booking safe). Inert placeholder in the besigner/preview (no
57
129
  * SiteContext host id) so editing never creates bookings.
58
130
  */ const Booking = /*#__PURE__*/ forwardRef((props, ref)=>{
59
- var _slotsByDay_day;
60
131
  const { heading, successMessage } = props, rest = _object_without_properties_loose(props, [
61
132
  "heading",
62
133
  "successMessage"
@@ -74,10 +145,22 @@ export const ID = 'booking';
74
145
  useBookingPurchaseEvent(hostId, siteFetch);
75
146
  const [services, setServices] = useState(null);
76
147
  const [serviceId, setServiceId] = useState('');
148
+ // Every page of the listing loaded so far, in order (AGL-3492).
77
149
  const [slots, setSlots] = useState(null);
150
+ // Where the next page starts; `null` once the horizon is loaded.
151
+ const [nextFromMs, setNextFromMs] = useState(null);
152
+ const [horizonDays, setHorizonDays] = useState(BOOKING_MAX_DAYS_AHEAD);
153
+ const [loadingMore, setLoadingMore] = useState(false);
154
+ // The first day of the strip on screen, as an index into the loaded days.
155
+ const [stripStart, setStripStart] = useState(0);
156
+ // The service the loaded pages belong to, so a page that lands after the
157
+ // visitor switched service is dropped rather than mixed into the new one.
158
+ const slotsServiceRef = useRef('');
78
159
  const [slotMs, setSlotMs] = useState(null);
79
160
  const [name, setName] = useState('');
80
161
  const [email, setEmail] = useState('');
162
+ const [phone, setPhone] = useState('');
163
+ const [address, setAddress] = useState('');
81
164
  const [marketingConsent, setMarketingConsent] = useState(false);
82
165
  const [status, setStatus] = useState('idle');
83
166
  const [errorMessage, setErrorMessage] = useState(null);
@@ -106,42 +189,102 @@ export const ID = 'booking';
106
189
  }, [
107
190
  hostId
108
191
  ]);
192
+ /**
193
+ * One page of the listing (AGL-3492): the next strip's worth of days from
194
+ * `fromMs`, or from now. Appended, never replacing, so a day split across
195
+ * two pages for a visitor in another zone is whole once both are in.
196
+ */ const fetchSlotPage = useCallback(async (forService, fromMs)=>{
197
+ const response = await fetch(`/api/bookings/slots?hostId=${encodeURIComponent(hostId != null ? hostId : '')}` + `&serviceId=${encodeURIComponent(forService)}` + (fromMs !== null ? `&from=${encodeURIComponent(String(fromMs))}` : ''));
198
+ const payload = await response.json();
199
+ if (slotsServiceRef.current !== forService) return;
200
+ const page = Array.isArray(payload == null ? void 0 : payload.slots) ? payload.slots : [];
201
+ setSlots((loaded)=>{
202
+ const seen = new Set((loaded != null ? loaded : []).map((slot)=>slot.startsAtMs));
203
+ return [
204
+ ...loaded != null ? loaded : [],
205
+ ...page.filter((slot)=>!seen.has(slot.startsAtMs))
206
+ ];
207
+ });
208
+ setNextFromMs(typeof (payload == null ? void 0 : payload.nextFromMs) === 'number' ? payload.nextFromMs : null);
209
+ if (Number(payload == null ? void 0 : payload.horizonDays) > 0) {
210
+ setHorizonDays(Number(payload.horizonDays));
211
+ }
212
+ }, [
213
+ hostId
214
+ ]);
109
215
  useEffect(()=>{
110
- if (!hostId || !serviceId) return setSlots(null);
111
- let active = true;
216
+ slotsServiceRef.current = serviceId;
112
217
  setSlots(null);
218
+ setNextFromMs(null);
219
+ setStripStart(0);
113
220
  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([]);
221
+ if (!hostId || !serviceId) return;
222
+ fetchSlotPage(serviceId, null).catch(()=>{
223
+ if (slotsServiceRef.current === serviceId) setSlots([]);
119
224
  });
120
- return ()=>{
121
- active = false;
122
- };
123
225
  }, [
124
226
  hostId,
125
- serviceId
227
+ serviceId,
228
+ fetchSlotPage
126
229
  ]);
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;
230
+ const days = useMemo(()=>groupSlotsByLocalDay(slots != null ? slots : [], nextFromMs), [
231
+ slots,
232
+ nextFromMs
233
+ ]);
234
+ const visibleDays = days.slice(stripStart, stripStart + BOOKING_SLOT_PAGE_DAYS);
235
+ // The strip asks for the next page itself when the days it is showing run
236
+ // out before the horizon does — after "Later dates", or when a page held
237
+ // no open day this visitor can see whole.
238
+ useEffect(()=>{
239
+ if (slots === null || nextFromMs === null || loadingMore) return;
240
+ if (days.length >= stripStart + BOOKING_SLOT_PAGE_DAYS) return;
241
+ if (stripStart === 0 && days.length > 0) return;
242
+ const forService = serviceId;
243
+ setLoadingMore(true);
244
+ fetchSlotPage(forService, nextFromMs).catch(()=>{
245
+ // Stop asking: a page that failed would otherwise be asked for
246
+ // again on every render.
247
+ if (slotsServiceRef.current === forService) setNextFromMs(null);
248
+ }).finally(()=>setLoadingMore(false));
136
249
  }, [
137
- slots
250
+ slots,
251
+ nextFromMs,
252
+ loadingMore,
253
+ days.length,
254
+ stripStart,
255
+ serviceId,
256
+ fetchSlotPage
138
257
  ]);
139
- const [day, setDay] = useState('');
258
+ const [day, setDay] = useState(null);
140
259
  useEffect(()=>{
141
- setDay('');
260
+ setDay(null);
142
261
  }, [
143
262
  serviceId
144
263
  ]);
264
+ const dayTimes = useMemo(()=>{
265
+ var _ref;
266
+ var _days_find;
267
+ return partsOfDay((_ref = (_days_find = days.find((one)=>one.startMs === day)) == null ? void 0 : _days_find.slots) != null ? _ref : []);
268
+ }, [
269
+ days,
270
+ day
271
+ ]);
272
+ const hasEarlierDates = stripStart > 0;
273
+ const hasLaterDates = days.length > stripStart + BOOKING_SLOT_PAGE_DAYS || nextFromMs !== null;
274
+ const moveStrip = (toStart)=>{
275
+ setStripStart(Math.max(0, toStart));
276
+ setDay(null);
277
+ setSlotMs(null);
278
+ };
279
+ // What the picked service asks for beyond a name and an email, and
280
+ // whether what was typed answers it (AGL-3493) — the route's own reading,
281
+ // so the button and the server cannot disagree about a required field.
282
+ const asks = bookingContactAsks((services != null ? services : []).find((one)=>one.$id === serviceId));
283
+ const contactCheck = readBookingContactFields(asks, {
284
+ phone,
285
+ address
286
+ });
287
+ const phoneUnreadable = asks.phone !== 'off' && Boolean(phone.trim()) && !readBookingPhone(phone);
145
288
  const handleBook = useCallback(async ()=>{
146
289
  if (!hostId || !serviceId || !slotMs || status === 'booking') return;
147
290
  setStatus('booking');
@@ -163,7 +306,7 @@ export const ID = 'booking';
163
306
  startsAtMs: slotMs,
164
307
  name,
165
308
  email
166
- }, recordRef ? {
309
+ }, contactCheck.ok ? contactCheck.fields : {}, recordRef ? {
167
310
  crmRef: formatBookingRecordRef(recordRef)
168
311
  } : {}, marketingConsent ? {
169
312
  marketingConsent: true
@@ -239,6 +382,7 @@ export const ID = 'booking';
239
382
  slotMs,
240
383
  name,
241
384
  email,
385
+ contactCheck,
242
386
  marketingConsent,
243
387
  status,
244
388
  siteFetch,
@@ -309,7 +453,7 @@ export const ID = 'booking';
309
453
  fullWidth: true,
310
454
  children: services.map((service)=>/*#__PURE__*/ _jsx(MenuItem, {
311
455
  value: service.$id,
312
- children: `${service.name} · ${service.durationMinutes} min` + (service.priceUsd > 0 ? ` · $${service.priceUsd}` : '')
456
+ children: `${service.name} · ${service.durationMinutes} min` + (bookingPriceDisplay(service.priceDisplay) !== 'fixed' || service.priceUsd > 0 ? ` · ${bookingPriceText(service)}` : '')
313
457
  }, service.$id))
314
458
  }),
315
459
  (selected == null ? void 0 : selected.description) ? /*#__PURE__*/ _jsx(Typography, {
@@ -317,47 +461,86 @@ export const ID = 'booking';
317
461
  color: "text.secondary",
318
462
  children: selected.description
319
463
  }) : null,
320
- serviceId ? slots === null ? /*#__PURE__*/ _jsx(CircularProgress, {
464
+ serviceId ? slots === null || days.length === 0 && nextFromMs !== null ? /*#__PURE__*/ _jsx(CircularProgress, {
321
465
  size: 24
322
- }) : slots.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
466
+ }) : days.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
323
467
  variant: "body2",
324
468
  color: "text.secondary",
325
- children: 'No open times in the next 60 days.'
469
+ children: `No open times in the next ${horizonDays} days.`
326
470
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
327
471
  children: [
328
- /*#__PURE__*/ _jsx(Stack, {
472
+ /*#__PURE__*/ _jsxs(Stack, {
329
473
  direction: "row",
330
474
  spacing: 1,
331
475
  sx: {
332
476
  flexWrap: 'wrap',
333
477
  rowGap: 1
334
478
  },
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);
479
+ children: [
480
+ visibleDays.map((one)=>/*#__PURE__*/ _jsx(Chip, {
481
+ label: bookingDayLabel(one.startMs),
482
+ color: day === one.startMs ? 'primary' : 'default',
483
+ variant: day === one.startMs ? 'filled' : 'outlined',
484
+ onClick: ()=>{
485
+ setDay(one.startMs);
486
+ setSlotMs(null);
487
+ }
488
+ }, one.startMs)),
489
+ loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS ? /*#__PURE__*/ _jsx(CircularProgress, {
490
+ size: 20,
491
+ sx: {
492
+ alignSelf: 'center'
342
493
  }
343
- }, value))
494
+ }) : null
495
+ ]
344
496
  }),
345
- day ? /*#__PURE__*/ _jsx(Stack, {
497
+ hasEarlierDates || hasLaterDates ? /*#__PURE__*/ _jsxs(Stack, {
346
498
  direction: "row",
347
499
  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))
500
+ children: [
501
+ hasEarlierDates ? /*#__PURE__*/ _jsx(Button, {
502
+ size: "small",
503
+ onClick: ()=>moveStrip(stripStart - BOOKING_SLOT_PAGE_DAYS),
504
+ children: 'Earlier dates'
505
+ }) : null,
506
+ hasLaterDates ? /*#__PURE__*/ _jsx(Button, {
507
+ size: "small",
508
+ disabled: loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS,
509
+ // Never past the last day loaded: a strip that was short
510
+ // moves on from where it ended, so no day is stepped over
511
+ // while the next page loads.
512
+ onClick: ()=>moveStrip(Math.min(stripStart + BOOKING_SLOT_PAGE_DAYS, days.length)),
513
+ children: 'Later dates'
514
+ }) : null
515
+ ]
516
+ }) : null,
517
+ day !== null ? /*#__PURE__*/ _jsx(Stack, {
518
+ spacing: 1.5,
519
+ children: dayTimes.map((part)=>/*#__PURE__*/ _jsxs(Stack, {
520
+ spacing: 0.75,
521
+ children: [
522
+ /*#__PURE__*/ _jsx(Typography, {
523
+ variant: "caption",
524
+ color: "text.secondary",
525
+ children: part.label
526
+ }),
527
+ /*#__PURE__*/ _jsx(Box, {
528
+ role: "group",
529
+ "aria-label": `${part.label} times`,
530
+ sx: {
531
+ display: 'grid',
532
+ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))',
533
+ gap: 1
534
+ },
535
+ children: part.slots.map((slot)=>/*#__PURE__*/ _jsx(Chip, {
536
+ label: bookingTimeLabel(slot.startsAtMs),
537
+ color: slotMs === slot.startsAtMs ? 'primary' : 'default',
538
+ variant: slotMs === slot.startsAtMs ? 'filled' : 'outlined',
539
+ onClick: ()=>setSlotMs(slot.startsAtMs)
540
+ }, slot.startsAtMs))
541
+ })
542
+ ]
543
+ }, part.label))
361
544
  }) : null
362
545
  ]
363
546
  }) : null,
@@ -379,6 +562,29 @@ export const ID = 'booking';
379
562
  size: "small",
380
563
  fullWidth: true
381
564
  }),
565
+ asks.phone !== 'off' ? /*#__PURE__*/ _jsx(TextField, {
566
+ label: "Phone",
567
+ type: "tel",
568
+ autoComplete: "tel",
569
+ required: asks.phone === 'required',
570
+ value: phone,
571
+ onChange: (event)=>setPhone(event.target.value),
572
+ error: phoneUnreadable,
573
+ helperText: phoneUnreadable ? 'Enter a valid phone number' : undefined,
574
+ size: "small",
575
+ fullWidth: true
576
+ }) : null,
577
+ asks.address !== 'off' ? /*#__PURE__*/ _jsx(TextField, {
578
+ label: "Address",
579
+ autoComplete: "street-address",
580
+ required: asks.address === 'required',
581
+ value: address,
582
+ onChange: (event)=>setAddress(event.target.value),
583
+ size: "small",
584
+ fullWidth: true,
585
+ multiline: true,
586
+ minRows: 2
587
+ }) : null,
382
588
  /*#__PURE__*/ _jsx(FormControlLabel, {
383
589
  control: /*#__PURE__*/ _jsx(Checkbox, {
384
590
  size: "small",
@@ -393,7 +599,7 @@ export const ID = 'booking';
393
599
  }) : null,
394
600
  /*#__PURE__*/ _jsx(Button, {
395
601
  variant: "contained",
396
- disabled: !name.trim() || !email.trim() || status === 'booking',
602
+ disabled: !name.trim() || !email.trim() || !contactCheck.ok || status === 'booking',
397
603
  onClick: handleBook,
398
604
  sx: {
399
605
  alignSelf: 'flex-start'
@@ -1 +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 { utmTouchField } from '@aglyn/aglyn/app-utils/utm-touch'\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 {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n formatBookingRecordRef,\n parseBookingRecordRef,\n} from '../model/booking-record'\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 record a booking link was dropped from (AGL-2660), carried onto\n // the request so the booking lands on that record even when the visitor\n // books with a different address. Parsed here so a value that is not a\n // reference is never sent.\n const recordRef = parseBookingRecordRef(readLinkParam(BOOKING_RECORD_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 ...(recordRef ? { crmRef: formatBookingRecordRef(recordRef) } : {}),\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 ...utmTouchField(),\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","utmTouchField","mdiCalendarClock","Alert","Box","Button","Checkbox","Chip","CircularProgress","FormControlLabel","MenuItem","Stack","TextField","Typography","forwardRef","useCallback","useEffect","useMemo","useState","BUNDLE_ID","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","parseBookingRecordRef","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","recordRef","method","headers","body","JSON","stringify","crmRef","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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"}
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 { utmTouchField } from '@aglyn/aglyn/app-utils/utm-touch'\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 {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n formatBookingRecordRef,\n parseBookingRecordRef,\n} from '../model/booking-record'\nimport {\n bookingContactAsks,\n readBookingContactFields,\n readBookingPhone,\n} from '../model/booking-contact-fields'\nimport { bookingPriceDisplay, bookingPriceText } from '../model/booking-price'\nimport { BOOKING_MAX_DAYS_AHEAD, BOOKING_SLOT_PAGE_DAYS } from '../model/bookings'\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 /** How the service states its price (AGL-3475); a label books with no charge. */\n priceDisplay?: string\n description?: string\n /** What the service asks for beyond a name and an email (AGL-3493). */\n askPhone?: string\n askAddress?: 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/** One day of the strip: the visitor's local calendar day and its times. */\nexport interface BookingSlotDay {\n /** Local midnight that starts the day, in epoch ms — key and sort order. */\n startMs: number\n slots: SlotOption[]\n}\n\n/**\n * Slots grouped by the VISITOR's local day, in order (AGL-3492).\n *\n * A page of the listing is whole days in the SERVICE's zone, so for a\n * visitor in another zone the last day of a page can be one the page ended\n * part way through. `nextFromMs` is where the page stopped — every open time\n * before it is loaded — so a day that runs past it is held back until the\n * next page completes it, rather than shown with its later times missing.\n */\nexport function groupSlotsByLocalDay(\n slots: SlotOption[],\n nextFromMs: number | null,\n): BookingSlotDay[] {\n const byDay = new Map<number, SlotOption[]>()\n for (const slot of slots) {\n const at = new Date(slot.startsAtMs)\n const startMs = new Date(\n at.getFullYear(),\n at.getMonth(),\n at.getDate(),\n ).getTime()\n const day = byDay.get(startMs)\n if (day) day.push(slot)\n else byDay.set(startMs, [slot])\n }\n return [...byDay.entries()]\n .map(([startMs, daySlots]) => ({\n startMs,\n slots: daySlots.sort((a, b) => a.startsAtMs - b.startsAtMs),\n }))\n .filter((day) => {\n if (nextFromMs === null) return true\n const start = new Date(day.startMs)\n const nextDayMs = new Date(\n start.getFullYear(),\n start.getMonth(),\n start.getDate() + 1,\n ).getTime()\n return nextDayMs <= nextFromMs\n })\n .sort((a, b) => a.startMs - b.startMs)\n}\n\n/** A day chip's label: `Mon, Oct 5` in the visitor's locale. */\nexport function bookingDayLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })\n}\n\n/** A time chip's label: `2:30 PM` in the visitor's locale. */\nexport function bookingTimeLabel(startsAtMs: number): string {\n return new Date(startsAtMs).toLocaleTimeString([], {\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\n/**\n * A day's times in the parts of the day a visitor scans for (AGL-3492): a\n * day open 8 to 5 at 15-minute steps holds 33 starts, and one unbroken run\n * of chips that long reads as noise. Empty parts are left out.\n */\nexport function partsOfDay(\n slots: SlotOption[],\n): Array<{ label: string; slots: SlotOption[] }> {\n const parts = [\n { label: 'Morning', slots: [] as SlotOption[] },\n { label: 'Afternoon', slots: [] as SlotOption[] },\n { label: 'Evening', slots: [] as SlotOption[] },\n ]\n for (const slot of slots) {\n const hour = new Date(slot.startsAtMs).getHours()\n parts[hour < 12 ? 0 : hour < 17 ? 1 : 2].slots.push(slot)\n }\n return parts.filter((part) => part.slots.length > 0)\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 // Every page of the listing loaded so far, in order (AGL-3492).\n const [slots, setSlots] = useState<SlotOption[] | null>(null)\n // Where the next page starts; `null` once the horizon is loaded.\n const [nextFromMs, setNextFromMs] = useState<number | null>(null)\n const [horizonDays, setHorizonDays] = useState(BOOKING_MAX_DAYS_AHEAD)\n const [loadingMore, setLoadingMore] = useState(false)\n // The first day of the strip on screen, as an index into the loaded days.\n const [stripStart, setStripStart] = useState(0)\n // The service the loaded pages belong to, so a page that lands after the\n // visitor switched service is dropped rather than mixed into the new one.\n const slotsServiceRef = useRef('')\n const [slotMs, setSlotMs] = useState<number | null>(null)\n const [name, setName] = useState('')\n const [email, setEmail] = useState('')\n const [phone, setPhone] = useState('')\n const [address, setAddress] = 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 /**\n * One page of the listing (AGL-3492): the next strip's worth of days from\n * `fromMs`, or from now. Appended, never replacing, so a day split across\n * two pages for a visitor in another zone is whole once both are in.\n */\n const fetchSlotPage = useCallback(\n async (forService: string, fromMs: number | null) => {\n const response = await fetch(\n `/api/bookings/slots?hostId=${encodeURIComponent(hostId ?? '')}` +\n `&serviceId=${encodeURIComponent(forService)}` +\n (fromMs !== null ? `&from=${encodeURIComponent(String(fromMs))}` : ''),\n )\n const payload = await response.json()\n if (slotsServiceRef.current !== forService) return\n const page: SlotOption[] = Array.isArray(payload?.slots)\n ? payload.slots\n : []\n setSlots((loaded) => {\n const seen = new Set((loaded ?? []).map((slot) => slot.startsAtMs))\n return [\n ...(loaded ?? []),\n ...page.filter((slot) => !seen.has(slot.startsAtMs)),\n ]\n })\n setNextFromMs(\n typeof payload?.nextFromMs === 'number' ? payload.nextFromMs : null,\n )\n if (Number(payload?.horizonDays) > 0) {\n setHorizonDays(Number(payload.horizonDays))\n }\n },\n [hostId],\n )\n\n useEffect(() => {\n slotsServiceRef.current = serviceId\n setSlots(null)\n setNextFromMs(null)\n setStripStart(0)\n setSlotMs(null)\n if (!hostId || !serviceId) return\n fetchSlotPage(serviceId, null).catch(() => {\n if (slotsServiceRef.current === serviceId) setSlots([])\n })\n }, [hostId, serviceId, fetchSlotPage])\n\n const days = useMemo(\n () => groupSlotsByLocalDay(slots ?? [], nextFromMs),\n [slots, nextFromMs],\n )\n const visibleDays = days.slice(stripStart, stripStart + BOOKING_SLOT_PAGE_DAYS)\n\n // The strip asks for the next page itself when the days it is showing run\n // out before the horizon does — after \"Later dates\", or when a page held\n // no open day this visitor can see whole.\n useEffect(() => {\n if (slots === null || nextFromMs === null || loadingMore) return\n if (days.length >= stripStart + BOOKING_SLOT_PAGE_DAYS) return\n if (stripStart === 0 && days.length > 0) return\n const forService = serviceId\n setLoadingMore(true)\n fetchSlotPage(forService, nextFromMs)\n .catch(() => {\n // Stop asking: a page that failed would otherwise be asked for\n // again on every render.\n if (slotsServiceRef.current === forService) setNextFromMs(null)\n })\n .finally(() => setLoadingMore(false))\n }, [\n slots,\n nextFromMs,\n loadingMore,\n days.length,\n stripStart,\n serviceId,\n fetchSlotPage,\n ])\n\n const [day, setDay] = useState<number | null>(null)\n useEffect(() => {\n setDay(null)\n }, [serviceId])\n const dayTimes = useMemo(\n () => partsOfDay(days.find((one) => one.startMs === day)?.slots ?? []),\n [days, day],\n )\n const hasEarlierDates = stripStart > 0\n const hasLaterDates =\n days.length > stripStart + BOOKING_SLOT_PAGE_DAYS || nextFromMs !== null\n const moveStrip = (toStart: number) => {\n setStripStart(Math.max(0, toStart))\n setDay(null)\n setSlotMs(null)\n }\n\n // What the picked service asks for beyond a name and an email, and\n // whether what was typed answers it (AGL-3493) — the route's own reading,\n // so the button and the server cannot disagree about a required field.\n const asks = bookingContactAsks(\n (services ?? []).find((one) => one.$id === serviceId),\n )\n const contactCheck = readBookingContactFields(asks, { phone, address })\n const phoneUnreadable =\n asks.phone !== 'off' && Boolean(phone.trim()) && !readBookingPhone(phone)\n\n const handleBook = useCallback(async () => {\n if (!hostId || !serviceId || !slotMs || status === 'booking') return\n setStatus('booking')\n setErrorMessage(null)\n // The record a booking link was dropped from (AGL-2660), carried onto\n // the request so the booking lands on that record even when the visitor\n // books with a different address. Parsed here so a value that is not a\n // reference is never sent.\n const recordRef = parseBookingRecordRef(readLinkParam(BOOKING_RECORD_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 // Only what the service asks for: the route drops anything else.\n ...(contactCheck.ok ? contactCheck.fields : {}),\n ...(recordRef ? { crmRef: formatBookingRecordRef(recordRef) } : {}),\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 ...utmTouchField(),\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 contactCheck,\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 (bookingPriceDisplay(service.priceDisplay) !== 'fixed' || service.priceUsd > 0\n ? ` · ${bookingPriceText(service)}`\n : '')}\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 || (days.length === 0 && nextFromMs !== null) ? (\n <CircularProgress size={24} />\n ) : days.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No open times in the next ${horizonDays} days.`}\n </Typography>\n ) : (\n <>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n {visibleDays.map((one) => (\n <Chip\n key={one.startMs}\n label={bookingDayLabel(one.startMs)}\n color={day === one.startMs ? 'primary' : 'default'}\n variant={day === one.startMs ? 'filled' : 'outlined'}\n onClick={() => {\n setDay(one.startMs)\n setSlotMs(null)\n }}\n />\n ))}\n {loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS ? (\n <CircularProgress size={20} sx={{ alignSelf: 'center' }} />\n ) : null}\n </Stack>\n {hasEarlierDates || hasLaterDates ? (\n <Stack direction=\"row\" spacing={1}>\n {hasEarlierDates ? (\n <Button\n size=\"small\"\n onClick={() =>\n moveStrip(stripStart - BOOKING_SLOT_PAGE_DAYS)\n }\n >\n {'Earlier dates'}\n </Button>\n ) : null}\n {hasLaterDates ? (\n <Button\n size=\"small\"\n disabled={\n loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS\n }\n // Never past the last day loaded: a strip that was short\n // moves on from where it ended, so no day is stepped over\n // while the next page loads.\n onClick={() =>\n moveStrip(\n Math.min(\n stripStart + BOOKING_SLOT_PAGE_DAYS,\n days.length,\n ),\n )\n }\n >\n {'Later dates'}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n {day !== null ? (\n <Stack spacing={1.5}>\n {dayTimes.map((part) => (\n <Stack key={part.label} spacing={0.75}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {part.label}\n </Typography>\n <Box\n role=\"group\"\n aria-label={`${part.label} times`}\n sx={{\n display: 'grid',\n gridTemplateColumns:\n 'repeat(auto-fill, minmax(5.5rem, 1fr))',\n gap: 1,\n }}\n >\n {part.slots.map((slot) => (\n <Chip\n key={slot.startsAtMs}\n label={bookingTimeLabel(slot.startsAtMs)}\n color={\n slotMs === slot.startsAtMs ? 'primary' : 'default'\n }\n variant={\n slotMs === slot.startsAtMs ? 'filled' : 'outlined'\n }\n onClick={() => setSlotMs(slot.startsAtMs)}\n />\n ))}\n </Box>\n </Stack>\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 {asks.phone !== 'off' ? (\n <TextField\n label=\"Phone\"\n type=\"tel\"\n autoComplete=\"tel\"\n required={asks.phone === 'required'}\n value={phone}\n onChange={(event) => setPhone(event.target.value)}\n error={phoneUnreadable}\n helperText={phoneUnreadable ? 'Enter a valid phone number' : undefined}\n size=\"small\"\n fullWidth\n />\n ) : null}\n {asks.address !== 'off' ? (\n <TextField\n label=\"Address\"\n autoComplete=\"street-address\"\n required={asks.address === 'required'}\n value={address}\n onChange={(event) => setAddress(event.target.value)}\n size=\"small\"\n fullWidth\n multiline\n minRows={2}\n />\n ) : null}\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() ||\n !email.trim() ||\n !contactCheck.ok ||\n 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","utmTouchField","mdiCalendarClock","Alert","Box","Button","Checkbox","Chip","CircularProgress","FormControlLabel","MenuItem","Stack","TextField","Typography","forwardRef","useCallback","useEffect","useMemo","useRef","useState","BUNDLE_ID","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","parseBookingRecordRef","bookingContactAsks","readBookingContactFields","readBookingPhone","bookingPriceDisplay","bookingPriceText","BOOKING_MAX_DAYS_AHEAD","BOOKING_SLOT_PAGE_DAYS","generatePresetId","useBookingPurchaseEvent","ID","readLinkParam","key","window","URLSearchParams","location","search","get","groupSlotsByLocalDay","slots","nextFromMs","byDay","Map","slot","at","Date","startsAtMs","startMs","getFullYear","getMonth","getDate","getTime","day","push","set","entries","map","daySlots","sort","a","b","filter","start","nextDayMs","bookingDayLabel","toLocaleDateString","undefined","weekday","month","bookingTimeLabel","toLocaleTimeString","hour","minute","partsOfDay","parts","label","getHours","part","length","Booking","props","ref","heading","successMessage","rest","nodeSx","Array","isArray","hostId","useSite","siteFetch","useSiteFetch","services","setServices","serviceId","setServiceId","setSlots","setNextFromMs","horizonDays","setHorizonDays","loadingMore","setLoadingMore","stripStart","setStripStart","slotsServiceRef","slotMs","setSlotMs","name","setName","email","setEmail","phone","setPhone","address","setAddress","marketingConsent","setMarketingConsent","status","setStatus","errorMessage","setErrorMessage","alerts","setAlerts","active","fetch","encodeURIComponent","then","response","json","payload","loaded","wanted","some","one","$id","catch","fetchSlotPage","forService","fromMs","String","current","page","seen","Set","has","Number","days","visibleDays","slice","finally","setDay","dayTimes","find","hasEarlierDates","hasLaterDates","moveStrip","toStart","Math","max","asks","contactCheck","phoneUnreadable","Boolean","trim","handleBook","recordRef","method","headers","body","JSON","stringify","ok","fields","crmRef","error","checkoutUrl","service","items","item_id","item_name","price","priceUsd","quantity","assign","form_name","form_location","pathname","sx","p","border","borderColor","borderRadius","color","fontSize","fontFamily","spacing","severity","alert","index","message","selected","variant","size","select","value","onChange","event","target","fullWidth","durationMinutes","priceDisplay","description","direction","flexWrap","rowGap","onClick","alignSelf","disabled","min","role","aria-label","display","gridTemplateColumns","gap","type","autoComplete","required","helperText","multiline","minRows","control","checked","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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"}