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

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 (35) hide show
  1. package/package.json +12 -12
  2. package/src/lib/components/book-meeting-action.d.ts +7 -4
  3. package/src/lib/components/book-meeting-action.js +2 -2
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.js +10 -10
  6. package/src/lib/components/booking.js.map +1 -1
  7. package/src/lib/model/booking-record.d.ts +74 -0
  8. package/src/lib/model/booking-record.js +131 -0
  9. package/src/lib/model/booking-record.js.map +1 -0
  10. package/src/lib/model/booking-time.d.ts +60 -0
  11. package/src/lib/model/booking-time.js +65 -0
  12. package/src/lib/model/booking-time.js.map +1 -0
  13. package/src/lib/model/bookings-record-routes.d.ts +15 -0
  14. package/src/lib/model/bookings-record-routes.js +1 -1
  15. package/src/lib/model/bookings-record-routes.js.map +1 -1
  16. package/src/lib/model/bookings-search-sources.js +4 -2
  17. package/src/lib/model/bookings-search-sources.js.map +1 -1
  18. package/src/lib/model/bookings.d.ts +4 -4
  19. package/src/lib/model/bookings.js +3 -3
  20. package/src/lib/model/bookings.js.map +1 -1
  21. package/src/lib/model/index.d.ts +2 -0
  22. package/src/lib/model/index.js +2 -0
  23. package/src/lib/model/index.js.map +1 -1
  24. package/src/lib/server/billing-webhook.js +31 -17
  25. package/src/lib/server/billing-webhook.js.map +1 -1
  26. package/src/lib/server/booking-crm.d.ts +40 -24
  27. package/src/lib/server/booking-crm.js +84 -26
  28. package/src/lib/server/booking-crm.js.map +1 -1
  29. package/src/lib/server/booking-time-zone.d.ts +32 -0
  30. package/src/lib/server/booking-time-zone.js +59 -0
  31. package/src/lib/server/booking-time-zone.js.map +1 -0
  32. package/src/lib/server.js +46 -35
  33. package/src/lib/server.js.map +1 -1
  34. package/src/lib/tenant-emails.js +19 -4
  35. package/src/lib/tenant-emails.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-bookings",
3
- "version": "1.0.0-beta.219",
3
+ "version": "1.0.0-beta.221",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,23 +25,23 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.219",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.219",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.219",
31
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.219",
32
- "@aglyn/shared-util-email": "1.0.0-beta.219",
33
- "@aglyn/shared-util-http": "1.0.0-beta.219",
34
- "@aglyn/shared-util-timestamp": "1.0.0-beta.219",
35
- "@aglyn/tenant-data-admin": "1.0.0-beta.219",
36
- "@aglyn/tenant-feature-instance": "1.0.0-beta.219",
37
- "@aglyn/tenant-runtime": "1.0.0-beta.219",
28
+ "@aglyn/aglyn": "1.0.0-beta.221",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.221",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.221",
31
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.221",
32
+ "@aglyn/shared-util-email": "1.0.0-beta.221",
33
+ "@aglyn/shared-util-http": "1.0.0-beta.221",
34
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.221",
35
+ "@aglyn/tenant-data-admin": "1.0.0-beta.221",
36
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.221",
37
+ "@aglyn/tenant-runtime": "1.0.0-beta.221",
38
38
  "@swc/helpers": "0.5.23"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@mui/material": "^9.3.1",
42
42
  "firebase": "^12.18.0",
43
43
  "firebase-admin": "^14.3.0",
44
- "next": "16.3.3",
44
+ "next": "^16.3.3",
45
45
  "react": "^19.2.8"
46
46
  },
47
47
  "sideEffects": [
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type AglynOrgBilling, type CrmBookingRefKind } from '@aglyn/aglyn';
17
+ import { type AglynOrgBilling } from '@aglyn/aglyn';
18
18
  /**
19
19
  * WHETHER THIS SITE HAS A BOOKING DOOR (AGL-2660).
20
20
  *
@@ -42,8 +42,11 @@ export interface BookMeetingButtonProps {
42
42
  */
43
43
  hostId: string | null;
44
44
  org?: Partial<AglynOrgBilling> | null;
45
- /** The record the link is dropped from, carried as `?crm=kind:id`. */
46
- kind: CrmBookingRefKind;
45
+ /**
46
+ * The record the link is dropped from, by its kind in the record system's
47
+ * own words (`contact`), carried as `?crm=kind:id`.
48
+ */
49
+ kind: string;
47
50
  recordId: string;
48
51
  /**
49
52
  * When given, every service also offers **Insert into email** and the
@@ -71,7 +74,7 @@ interface BookMeetingDialogProps {
71
74
  /** The site document the door already read — its public naming. */
72
75
  host: Record<string, unknown> | null;
73
76
  org?: Partial<AglynOrgBilling> | null;
74
- kind: CrmBookingRefKind;
77
+ kind: string;
75
78
  recordId: string;
76
79
  onInsert?: (link: string) => void;
77
80
  }
@@ -141,7 +141,7 @@ BookMeetingButton.displayName = 'BookMeetingButton';
141
141
  subdomain: host == null ? void 0 : host['subdomain']
142
142
  };
143
143
  const path = configReady ? String((_config_bookingPath = config['bookingPath']) != null ? _config_bookingPath : BOOKING_PATH_DEFAULT) : null;
144
- const crmRef = {
144
+ const recordRef = {
145
145
  kind,
146
146
  id: recordId
147
147
  };
@@ -209,7 +209,7 @@ BookMeetingButton.displayName = 'BookMeetingButton';
209
209
  id
210
210
  },
211
211
  path,
212
- crmRef
212
+ recordRef
213
213
  });
214
214
  const price = Number((_service_priceUsd = service['priceUsd']) != null ? _service_priceUsd : 0);
215
215
  return /*#__PURE__*/ _jsxs(Stack, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/components/book-meeting-action.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\n'use client'\n\nimport {\n type AglynOrgBilling,\n checkEntitlement,\n type CrmBookingRefKind,\n isHostPluginEnabled,\n} from '@aglyn/aglyn'\nimport { bookingsPageHref } from '../model/bookings-record-routes'\nimport { BOOKING_PATH_DEFAULT, bookingLinkFor } from '../model/bookings'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { mdiCalendarClock, mdiContentCopy } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useOrgDataScope,\n useSitePluginConfig,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n IconButton,\n Stack,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { collection, doc, limit, query } from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useCallback, useState } from 'react'\n\n\n/**\n * WHETHER THIS SITE HAS A BOOKING DOOR (AGL-2660).\n *\n * The action is offered only where a booking can actually be taken: the\n * Bookings plugin runs on the site — the org's set less the site's own\n * deny-list, which is the one resolver every other per-site gate reads —\n * and the org holds the `bookings` entitlement, without which the booking\n * API refuses every request. One listen on the site document answers the\n * deny-list and, for the link, the site's public naming.\n *\n * At the organization level the record's own capturing site is the site\n * asked about, which is how a contact captured by one brand of an agency\n * offers that brand's services and not another's. A record no site has\n * captured has no door.\n */\nexport function useBookingDoor(\n hostId: string | null | undefined,\n org?: Partial<AglynOrgBilling> | null,\n): { open: boolean; host: Record<string, unknown> | null } {\n const firestore = useFirestore()\n const { data: host, status } = useFirestoreDoc<Record<string, unknown>>(\n () => (hostId ? doc(firestore, 'hosts', hostId) : null),\n [firestore, hostId],\n )\n const open =\n Boolean(hostId) &&\n status === 'success' &&\n Boolean(host) &&\n isHostPluginEnabled(org ?? null, host as never, BUNDLE_ID) &&\n checkEntitlement(org ?? null, 'bookings')\n return { open, host: host ?? null }\n}\n\nexport interface BookMeetingButtonProps {\n /**\n * The site whose services are offered — the mounted site, or at the\n * organization level the record's own capturing site; `null` for a\n * record no site has captured, which offers nothing.\n */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n /** The record the link is dropped from, carried as `?crm=kind:id`. */\n kind: CrmBookingRefKind\n recordId: string\n /**\n * When given, every service also offers **Insert into email** and the\n * chosen link is handed here — the email dialog's use.\n */\n onInsert?: (link: string) => void\n /** `chip` is the compact form the email dialog carries beside its fields. */\n variant?: 'button' | 'chip'\n}\n\n/**\n * THE \"BOOK A MEETING\" ACTION A RECORD CARRIES (AGL-2660): one control, and\n * the dialog it opens, mounted only while open. Renders NOTHING where the\n * site has no booking door, rather than a disabled control with a reason —\n * a site that has never enabled Bookings should not read every record page\n * as missing something.\n */\nexport function BookMeetingButton(props: BookMeetingButtonProps) {\n const { hostId, org, kind, recordId, onInsert, variant = 'button' } = props\n const door = useBookingDoor(hostId, org)\n const [open, setOpen] = useState(false)\n if (!door.open || !hostId || !recordId) return null\n const icon = <MdiIcon path={mdiCalendarClock.path} size={0.8} />\n return (\n <>\n {variant === 'chip' ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n icon={icon}\n label=\"Insert booking link\"\n onClick={() => setOpen(true)}\n // A flex item in the dialog's column, kept to its own width.\n sx={{ alignSelf: 'flex-start' }}\n />\n ) : (\n <Button size=\"small\" variant=\"outlined\" startIcon={icon} onClick={() => setOpen(true)}>\n {'Book a meeting'}\n </Button>\n )}\n {open ? (\n <BookMeetingDialog\n open\n onClose={() => setOpen(false)}\n hostId={hostId}\n host={door.host}\n org={org}\n kind={kind}\n recordId={recordId}\n onInsert={onInsert}\n />\n ) : null}\n </>\n )\n}\nBookMeetingButton.displayName = 'BookMeetingButton'\n\ninterface BookMeetingDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n /** The site document the door already read — its public naming. */\n host: Record<string, unknown> | null\n org?: Partial<AglynOrgBilling> | null\n kind: CrmBookingRefKind\n recordId: string\n onInsert?: (link: string) => void\n}\n\n/**\n * The site's bookable services, each with the link a visitor books it at.\n *\n * The link is the Bookings plugin's own builder over three facts read here:\n * the site's public naming (off the site document), the page the Booking\n * block lives on (the plugin's per-site `bookingPath` setting), and the\n * record this dialog was opened from. A site with no public address yet\n * lists its services and says why there is no link.\n */\nexport function BookMeetingDialog(props: BookMeetingDialogProps) {\n const { open, onClose, hostId, host, org, kind, recordId, onInsert } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId } = useOrgDataScope({ hostId, orgId: org?.$id })\n const { config, ready: configReady } = useSitePluginConfig(orgId, hostId, BUNDLE_ID)\n const { data: serviceDocs, status } = useFirestoreCollection<Record<string, unknown>>(\n () => query(collection(firestore, 'hosts', hostId, 'services'), limit(100)),\n [firestore, hostId],\n { idField: '$id' },\n )\n const services = (serviceDocs ?? [])\n .filter((service) => !service['deletedAt'])\n .sort((a, b) => String(a['name'] ?? '').localeCompare(String(b['name'] ?? '')))\n\n // Where services are set up, for the empty state — addressable only\n // under a site route, where the URL names the org and the site.\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const bookingsHref =\n params?.orgSlug && params?.host\n ? bookingsPageHref({ orgSlug: String(params.orgSlug), host: String(params.host) })\n : null\n\n const site = {\n cname: host?.['cname'] as string | null | undefined,\n subdomain: host?.['subdomain'] as string | null | undefined,\n }\n const path = configReady ? String(config['bookingPath'] ?? BOOKING_PATH_DEFAULT) : null\n const crmRef = { kind, id: recordId }\n\n const copy = useCallback(\n async (link: string) => {\n try {\n await navigator.clipboard.writeText(link)\n enqueueSnackbar('Booking link copied', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The link could not be copied', { variant: 'error', allowDuplicate: true })\n }\n },\n [enqueueSnackbar],\n )\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{'Book a meeting'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Each link opens your site’s booking page on one service, with this ' +\n 'record attached so the booking lands on its timeline — whatever ' +\n 'address the person books with.'}\n </Typography>\n {status === 'loading' || path === null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No bookable services yet. '}\n {bookingsHref ? <AppLink href={bookingsHref}>{'Set up services'}</AppLink> : null}\n </Typography>\n ) : (\n <Stack spacing={1.5}>\n {services.map((service) => {\n const id = String(service['$id'])\n const name = String(service['name'] ?? '')\n const link = bookingLinkFor({ site, service: { id }, path, crmRef })\n const price = Number(service['priceUsd'] ?? 0)\n return (\n <Stack key={id} direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {name}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {`${Number(service['durationMinutes'] ?? 0)} min` +\n (price > 0 ? ` · $${price}` : ' · free')}\n </Typography>\n {link ? (\n <Typography variant=\"caption\" sx={{ wordBreak: 'break-all' }}>\n {link}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This site has no public address yet, so there is no link.'}\n </Typography>\n )}\n </Stack>\n <Tooltip title=\"Copy link\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Copy link for ${name}`}\n disabled={!link}\n onClick={() => link && void copy(link)}\n >\n <MdiIcon path={mdiContentCopy.path} size={0.8} />\n </IconButton>\n </span>\n </Tooltip>\n {onInsert ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={!link}\n onClick={() => {\n if (!link) return\n onInsert(link)\n onClose()\n }}\n >\n {'Insert into email'}\n </Button>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n )}\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Close'}</Button>\n </DialogActions>\n </Dialog>\n )\n}\nBookMeetingDialog.displayName = 'BookMeetingDialog'\n\nexport default BookMeetingButton\n"],"names":["checkEntitlement","isHostPluginEnabled","bookingsPageHref","BOOKING_PATH_DEFAULT","bookingLinkFor","BUNDLE_ID","mdiCalendarClock","mdiContentCopy","AppLink","MdiIcon","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","useSitePluginConfig","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","IconButton","Stack","Tooltip","Typography","collection","doc","limit","query","useParams","useCallback","useState","useBookingDoor","hostId","org","firestore","data","host","status","open","Boolean","BookMeetingButton","props","kind","recordId","onInsert","variant","door","setOpen","icon","path","size","label","onClick","sx","alignSelf","startIcon","BookMeetingDialog","onClose","displayName","config","enqueueSnackbar","orgId","$id","ready","configReady","serviceDocs","idField","services","filter","service","sort","a","b","String","localeCompare","params","bookingsHref","orgSlug","site","cname","subdomain","crmRef","id","copy","link","navigator","clipboard","writeText","persist","error","console","allowDuplicate","maxWidth","fullWidth","display","flexDirection","gap","color","length","href","spacing","map","name","price","Number","direction","alignItems","flex","minWidth","noWrap","wordBreak","title","span","aria-label","disabled"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/components/book-meeting-action.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\n'use client'\n\nimport {\n type AglynOrgBilling,\n checkEntitlement,\n isHostPluginEnabled,\n} from '@aglyn/aglyn'\nimport { bookingsPageHref } from '../model/bookings-record-routes'\nimport { BOOKING_PATH_DEFAULT, bookingLinkFor } from '../model/bookings'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { mdiCalendarClock, mdiContentCopy } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useOrgDataScope,\n useSitePluginConfig,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n IconButton,\n Stack,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { collection, doc, limit, query } from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useCallback, useState } from 'react'\n\n\n/**\n * WHETHER THIS SITE HAS A BOOKING DOOR (AGL-2660).\n *\n * The action is offered only where a booking can actually be taken: the\n * Bookings plugin runs on the site — the org's set less the site's own\n * deny-list, which is the one resolver every other per-site gate reads —\n * and the org holds the `bookings` entitlement, without which the booking\n * API refuses every request. One listen on the site document answers the\n * deny-list and, for the link, the site's public naming.\n *\n * At the organization level the record's own capturing site is the site\n * asked about, which is how a contact captured by one brand of an agency\n * offers that brand's services and not another's. A record no site has\n * captured has no door.\n */\nexport function useBookingDoor(\n hostId: string | null | undefined,\n org?: Partial<AglynOrgBilling> | null,\n): { open: boolean; host: Record<string, unknown> | null } {\n const firestore = useFirestore()\n const { data: host, status } = useFirestoreDoc<Record<string, unknown>>(\n () => (hostId ? doc(firestore, 'hosts', hostId) : null),\n [firestore, hostId],\n )\n const open =\n Boolean(hostId) &&\n status === 'success' &&\n Boolean(host) &&\n isHostPluginEnabled(org ?? null, host as never, BUNDLE_ID) &&\n checkEntitlement(org ?? null, 'bookings')\n return { open, host: host ?? null }\n}\n\nexport interface BookMeetingButtonProps {\n /**\n * The site whose services are offered — the mounted site, or at the\n * organization level the record's own capturing site; `null` for a\n * record no site has captured, which offers nothing.\n */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n /**\n * The record the link is dropped from, by its kind in the record system's\n * own words (`contact`), carried as `?crm=kind:id`.\n */\n kind: string\n recordId: string\n /**\n * When given, every service also offers **Insert into email** and the\n * chosen link is handed here — the email dialog's use.\n */\n onInsert?: (link: string) => void\n /** `chip` is the compact form the email dialog carries beside its fields. */\n variant?: 'button' | 'chip'\n}\n\n/**\n * THE \"BOOK A MEETING\" ACTION A RECORD CARRIES (AGL-2660): one control, and\n * the dialog it opens, mounted only while open. Renders NOTHING where the\n * site has no booking door, rather than a disabled control with a reason —\n * a site that has never enabled Bookings should not read every record page\n * as missing something.\n */\nexport function BookMeetingButton(props: BookMeetingButtonProps) {\n const { hostId, org, kind, recordId, onInsert, variant = 'button' } = props\n const door = useBookingDoor(hostId, org)\n const [open, setOpen] = useState(false)\n if (!door.open || !hostId || !recordId) return null\n const icon = <MdiIcon path={mdiCalendarClock.path} size={0.8} />\n return (\n <>\n {variant === 'chip' ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n icon={icon}\n label=\"Insert booking link\"\n onClick={() => setOpen(true)}\n // A flex item in the dialog's column, kept to its own width.\n sx={{ alignSelf: 'flex-start' }}\n />\n ) : (\n <Button size=\"small\" variant=\"outlined\" startIcon={icon} onClick={() => setOpen(true)}>\n {'Book a meeting'}\n </Button>\n )}\n {open ? (\n <BookMeetingDialog\n open\n onClose={() => setOpen(false)}\n hostId={hostId}\n host={door.host}\n org={org}\n kind={kind}\n recordId={recordId}\n onInsert={onInsert}\n />\n ) : null}\n </>\n )\n}\nBookMeetingButton.displayName = 'BookMeetingButton'\n\ninterface BookMeetingDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n /** The site document the door already read — its public naming. */\n host: Record<string, unknown> | null\n org?: Partial<AglynOrgBilling> | null\n kind: string\n recordId: string\n onInsert?: (link: string) => void\n}\n\n/**\n * The site's bookable services, each with the link a visitor books it at.\n *\n * The link is the Bookings plugin's own builder over three facts read here:\n * the site's public naming (off the site document), the page the Booking\n * block lives on (the plugin's per-site `bookingPath` setting), and the\n * record this dialog was opened from. A site with no public address yet\n * lists its services and says why there is no link.\n */\nexport function BookMeetingDialog(props: BookMeetingDialogProps) {\n const { open, onClose, hostId, host, org, kind, recordId, onInsert } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId } = useOrgDataScope({ hostId, orgId: org?.$id })\n const { config, ready: configReady } = useSitePluginConfig(orgId, hostId, BUNDLE_ID)\n const { data: serviceDocs, status } = useFirestoreCollection<Record<string, unknown>>(\n () => query(collection(firestore, 'hosts', hostId, 'services'), limit(100)),\n [firestore, hostId],\n { idField: '$id' },\n )\n const services = (serviceDocs ?? [])\n .filter((service) => !service['deletedAt'])\n .sort((a, b) => String(a['name'] ?? '').localeCompare(String(b['name'] ?? '')))\n\n // Where services are set up, for the empty state — addressable only\n // under a site route, where the URL names the org and the site.\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const bookingsHref =\n params?.orgSlug && params?.host\n ? bookingsPageHref({ orgSlug: String(params.orgSlug), host: String(params.host) })\n : null\n\n const site = {\n cname: host?.['cname'] as string | null | undefined,\n subdomain: host?.['subdomain'] as string | null | undefined,\n }\n const path = configReady ? String(config['bookingPath'] ?? BOOKING_PATH_DEFAULT) : null\n const recordRef = { kind, id: recordId }\n\n const copy = useCallback(\n async (link: string) => {\n try {\n await navigator.clipboard.writeText(link)\n enqueueSnackbar('Booking link copied', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The link could not be copied', { variant: 'error', allowDuplicate: true })\n }\n },\n [enqueueSnackbar],\n )\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{'Book a meeting'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Each link opens your site’s booking page on one service, with this ' +\n 'record attached so the booking lands on its timeline — whatever ' +\n 'address the person books with.'}\n </Typography>\n {status === 'loading' || path === null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No bookable services yet. '}\n {bookingsHref ? <AppLink href={bookingsHref}>{'Set up services'}</AppLink> : null}\n </Typography>\n ) : (\n <Stack spacing={1.5}>\n {services.map((service) => {\n const id = String(service['$id'])\n const name = String(service['name'] ?? '')\n const link = bookingLinkFor({ site, service: { id }, path, recordRef })\n const price = Number(service['priceUsd'] ?? 0)\n return (\n <Stack key={id} direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {name}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {`${Number(service['durationMinutes'] ?? 0)} min` +\n (price > 0 ? ` · $${price}` : ' · free')}\n </Typography>\n {link ? (\n <Typography variant=\"caption\" sx={{ wordBreak: 'break-all' }}>\n {link}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This site has no public address yet, so there is no link.'}\n </Typography>\n )}\n </Stack>\n <Tooltip title=\"Copy link\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Copy link for ${name}`}\n disabled={!link}\n onClick={() => link && void copy(link)}\n >\n <MdiIcon path={mdiContentCopy.path} size={0.8} />\n </IconButton>\n </span>\n </Tooltip>\n {onInsert ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={!link}\n onClick={() => {\n if (!link) return\n onInsert(link)\n onClose()\n }}\n >\n {'Insert into email'}\n </Button>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n )}\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Close'}</Button>\n </DialogActions>\n </Dialog>\n )\n}\nBookMeetingDialog.displayName = 'BookMeetingDialog'\n\nexport default BookMeetingButton\n"],"names":["checkEntitlement","isHostPluginEnabled","bookingsPageHref","BOOKING_PATH_DEFAULT","bookingLinkFor","BUNDLE_ID","mdiCalendarClock","mdiContentCopy","AppLink","MdiIcon","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","useSitePluginConfig","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","IconButton","Stack","Tooltip","Typography","collection","doc","limit","query","useParams","useCallback","useState","useBookingDoor","hostId","org","firestore","data","host","status","open","Boolean","BookMeetingButton","props","kind","recordId","onInsert","variant","door","setOpen","icon","path","size","label","onClick","sx","alignSelf","startIcon","BookMeetingDialog","onClose","displayName","config","enqueueSnackbar","orgId","$id","ready","configReady","serviceDocs","idField","services","filter","service","sort","a","b","String","localeCompare","params","bookingsHref","orgSlug","site","cname","subdomain","recordRef","id","copy","link","navigator","clipboard","writeText","persist","error","console","allowDuplicate","maxWidth","fullWidth","display","flexDirection","gap","color","length","href","spacing","map","name","price","Number","direction","alignItems","flex","minWidth","noWrap","wordBreak","title","span","aria-label","disabled"],"mappings":"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"}
@@ -18,8 +18,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
18
18
  * limitations under the License.
19
19
  */ import * as Aglyn from "@aglyn/aglyn";
20
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";
21
+ import { utmTouchField } from "@aglyn/aglyn/app-utils/utm-touch";
23
22
  import { mdiCalendarClock } from "@aglyn/shared-data-mdi";
24
23
  import Alert from "@mui/material/Alert";
25
24
  import Box from "@mui/material/Box";
@@ -34,6 +33,7 @@ import TextField from "@mui/material/TextField";
34
33
  import Typography from "@mui/material/Typography";
35
34
  import { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
36
35
  import { BUNDLE_ID } from "../constants/bundle-common.js";
36
+ import { BOOKING_RECORD_PARAM, BOOKING_SERVICE_PARAM, formatBookingRecordRef, parseBookingRecordRef } from "../model/booking-record.js";
37
37
  import { generatePresetId } from "../utils/generate-preset-id.js";
38
38
  import { useBookingPurchaseEvent } from "../utils/use-booking-purchase-event.js";
39
39
  // Component ids are persisted in screen documents; never rename.
@@ -146,11 +146,11 @@ export const ID = 'booking';
146
146
  if (!hostId || !serviceId || !slotMs || status === 'booking') return;
147
147
  setStatus('booking');
148
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));
149
+ // The record a booking link was dropped from (AGL-2660), carried onto
150
+ // the request so the booking lands on that record even when the visitor
151
+ // books with a different address. Parsed here so a value that is not a
152
+ // reference is never sent.
153
+ const recordRef = parseBookingRecordRef(readLinkParam(BOOKING_RECORD_PARAM));
154
154
  try {
155
155
  const response = await siteFetch('/api/bookings/book', {
156
156
  method: 'POST',
@@ -163,11 +163,11 @@ export const ID = 'booking';
163
163
  startsAtMs: slotMs,
164
164
  name,
165
165
  email
166
- }, crmRef ? {
167
- crmRef: formatCrmBookingRef(crmRef)
166
+ }, recordRef ? {
167
+ crmRef: formatBookingRecordRef(recordRef)
168
168
  } : {}, marketingConsent ? {
169
169
  marketingConsent: true
170
- } : {}, campaignTouchField()))
170
+ } : {}, utmTouchField()))
171
171
  });
172
172
  const payload = await response.json().catch(()=>({}));
173
173
  if (!response.ok) {
@@ -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 { 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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"}
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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"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * The query key a booking link carries the record on: `?crm=contact:abc`.
19
+ *
20
+ * The reference rides the LINK rather than relying on the booker's address
21
+ * because the two routinely differ — a rep emails somebody's work address
22
+ * and they book with a personal one — and a booking that could only be
23
+ * matched by address would then land on nobody. The widget copies the value
24
+ * onto the booking request, the booking row keeps it as `crmRef`, and the
25
+ * server hands it to the record system to file the meeting under. The key
26
+ * is the one links already sent carry, so it is never renamed.
27
+ */
28
+ export declare const BOOKING_RECORD_PARAM = "crm";
29
+ /**
30
+ * The query key that preselects a service in the booking widget:
31
+ * `?service={serviceId}`. The widget lists every active service and this
32
+ * opens it on the one the link was about.
33
+ */
34
+ export declare const BOOKING_SERVICE_PARAM = "service";
35
+ /** A record of the record system's, by its kind and id in its own words. */
36
+ export interface BookingRecordRef {
37
+ kind: string;
38
+ id: string;
39
+ }
40
+ /** `contact:abc` — the wire form of a reference. */
41
+ export declare function formatBookingRecordRef(ref: BookingRecordRef): string;
42
+ /**
43
+ * The reference a query value names, or `null` for anything that is not one.
44
+ *
45
+ * Strict on purpose: the value arrives on a public, unauthenticated booking
46
+ * request, so a malformed one is dropped rather than stored — a booking
47
+ * still lands, and is matched by the booker's address instead.
48
+ */
49
+ export declare function parseBookingRecordRef(raw: unknown): BookingRecordRef | null;
50
+ /**
51
+ * What the meeting entry on the timeline says — the service and the slot,
52
+ * in the service's own timezone, with the zone named so a rep in another
53
+ * one is not misled. The body, not the subject: `subject` is the field a
54
+ * sent email carries, and the timeline draws it as one.
55
+ */
56
+ export declare function bookingMeetingBody(input: {
57
+ serviceName: string;
58
+ startsAtMs: number;
59
+ timezone?: string | null;
60
+ }): string;
61
+ /** The title of the task a service files when it asks for a follow-up. */
62
+ export declare function bookingFollowUpTitle(serviceName: string): string;
63
+ /**
64
+ * When the follow-up falls due: ONE BUSINESS DAY after the slot, at the
65
+ * slot's own clock time.
66
+ *
67
+ * A meeting on a Friday is followed up on Monday, one on a Saturday or a
68
+ * Sunday on Monday too; any other day is followed up the next. Whole days
69
+ * are added to the instant rather than a calendar walked, so the arithmetic
70
+ * cannot land on a clock time that does not exist across a DST change —
71
+ * the task is due "about this time tomorrow", and an hour either way is not
72
+ * a fact the task keeps.
73
+ */
74
+ export declare function bookingFollowUpDueMs(endsAtMs: number, timezone?: string | null): number;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHAT A BOOKING CARRIES BACK TO THE RECORD IT CAME FROM (AGL-2660).
18
+ *
19
+ * A rep drops a booking link into an email from a record; the visitor books
20
+ * through it; the booking comes back to the record as a meeting on its
21
+ * timeline and, when the service asks for one, a follow-up task. The record
22
+ * system draws the "Book a meeting" zone and hands it the record by its kind
23
+ * and id; this plugin builds the link, carries the record on it, and files
24
+ * the meeting back through the core's record-timeline seam, where the owner
25
+ * finds the record. Neither plugin imports the other: what this module holds
26
+ * — the link's query keys, the wire form of the record it carries, and the
27
+ * words and the day of what is filed — is this plugin's own.
28
+ */ import { isValidTimeZone, zonedDateTime } from "@aglyn/shared-util-timestamp/zoned-time";
29
+ /**
30
+ * The query key a booking link carries the record on: `?crm=contact:abc`.
31
+ *
32
+ * The reference rides the LINK rather than relying on the booker's address
33
+ * because the two routinely differ — a rep emails somebody's work address
34
+ * and they book with a personal one — and a booking that could only be
35
+ * matched by address would then land on nobody. The widget copies the value
36
+ * onto the booking request, the booking row keeps it as `crmRef`, and the
37
+ * server hands it to the record system to file the meeting under. The key
38
+ * is the one links already sent carry, so it is never renamed.
39
+ */ export const BOOKING_RECORD_PARAM = 'crm';
40
+ /**
41
+ * The query key that preselects a service in the booking widget:
42
+ * `?service={serviceId}`. The widget lists every active service and this
43
+ * opens it on the one the link was about.
44
+ */ export const BOOKING_SERVICE_PARAM = 'service';
45
+ /**
46
+ * What a kind and an id may look like inside the reference: a lowercase
47
+ * word, and an id with nothing a path or a query could be built from. The
48
+ * value arrives on a public, unauthenticated request; which kinds a record
49
+ * system keeps is its own to say when the booking is filed.
50
+ */ const REF_KIND_PATTERN = /^[a-z][a-z-]{0,31}$/;
51
+ const REF_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
52
+ /** `contact:abc` — the wire form of a reference. */ export function formatBookingRecordRef(ref) {
53
+ return `${ref.kind}:${ref.id}`;
54
+ }
55
+ /**
56
+ * The reference a query value names, or `null` for anything that is not one.
57
+ *
58
+ * Strict on purpose: the value arrives on a public, unauthenticated booking
59
+ * request, so a malformed one is dropped rather than stored — a booking
60
+ * still lands, and is matched by the booker's address instead.
61
+ */ export function parseBookingRecordRef(raw) {
62
+ if (typeof raw !== 'string') return null;
63
+ const separator = raw.indexOf(':');
64
+ if (separator <= 0) return null;
65
+ const kind = raw.slice(0, separator);
66
+ const id = raw.slice(separator + 1);
67
+ if (!REF_KIND_PATTERN.test(kind) || !REF_ID_PATTERN.test(id)) return null;
68
+ return {
69
+ kind,
70
+ id
71
+ };
72
+ }
73
+ /** The slot, as the meeting entry and the task print it. */ function formatSlot(startsAtMs, timezone) {
74
+ try {
75
+ return new Intl.DateTimeFormat('en-US', {
76
+ timeZone: timezone,
77
+ dateStyle: 'full',
78
+ timeStyle: 'short'
79
+ }).format(new Date(startsAtMs));
80
+ } catch (unused) {
81
+ // An unknown zone name on the service falls back to UTC rather than
82
+ // failing the write: a meeting filed with a slightly wrong clock is
83
+ // better than no meeting at all.
84
+ return new Intl.DateTimeFormat('en-US', {
85
+ timeZone: 'UTC',
86
+ dateStyle: 'full',
87
+ timeStyle: 'short'
88
+ }).format(new Date(startsAtMs));
89
+ }
90
+ }
91
+ /**
92
+ * What the meeting entry on the timeline says — the service and the slot,
93
+ * in the service's own timezone, with the zone named so a rep in another
94
+ * one is not misled. The body, not the subject: `subject` is the field a
95
+ * sent email carries, and the timeline draws it as one.
96
+ */ export function bookingMeetingBody(input) {
97
+ var _input_serviceName;
98
+ const timezone = input.timezone || 'UTC';
99
+ const service = String((_input_serviceName = input.serviceName) != null ? _input_serviceName : '').trim() || 'Booking';
100
+ return `${service} — ${formatSlot(input.startsAtMs, timezone)} (${timezone})`;
101
+ }
102
+ /** The title of the task a service files when it asks for a follow-up. */ export function bookingFollowUpTitle(serviceName) {
103
+ const service = String(serviceName != null ? serviceName : '').trim() || 'the booking';
104
+ return `Follow up after ${service}`;
105
+ }
106
+ const DAY_MS = 24 * 60 * 60 * 1000;
107
+ /**
108
+ * Weekday (0 = Sunday … 6 = Saturday) of an instant in a timezone, read in
109
+ * UTC when the zone name is one `Intl` does not know — for the reason
110
+ * `formatSlot` gives.
111
+ */ function weekdayIn(atMs, timezone) {
112
+ return zonedDateTime(atMs, isValidTimeZone(timezone) ? timezone : 'UTC').weekday;
113
+ }
114
+ /**
115
+ * When the follow-up falls due: ONE BUSINESS DAY after the slot, at the
116
+ * slot's own clock time.
117
+ *
118
+ * A meeting on a Friday is followed up on Monday, one on a Saturday or a
119
+ * Sunday on Monday too; any other day is followed up the next. Whole days
120
+ * are added to the instant rather than a calendar walked, so the arithmetic
121
+ * cannot land on a clock time that does not exist across a DST change —
122
+ * the task is due "about this time tomorrow", and an hour either way is not
123
+ * a fact the task keeps.
124
+ */ export function bookingFollowUpDueMs(endsAtMs, timezone) {
125
+ const weekday = weekdayIn(endsAtMs, timezone || 'UTC');
126
+ // Friday (5) skips the weekend; Saturday (6) reaches Monday in two days.
127
+ const daysAhead = weekday === 5 ? 3 : weekday === 6 ? 2 : 1;
128
+ return endsAtMs + daysAhead * DAY_MS;
129
+ }
130
+
131
+ //# sourceMappingURL=booking-record.js.map