@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.
- package/package.json +12 -12
- package/src/lib/components/book-meeting-action.d.ts +7 -4
- package/src/lib/components/book-meeting-action.js +2 -2
- package/src/lib/components/book-meeting-action.js.map +1 -1
- package/src/lib/components/booking.js +10 -10
- package/src/lib/components/booking.js.map +1 -1
- package/src/lib/model/booking-record.d.ts +74 -0
- package/src/lib/model/booking-record.js +131 -0
- package/src/lib/model/booking-record.js.map +1 -0
- package/src/lib/model/booking-time.d.ts +60 -0
- package/src/lib/model/booking-time.js +65 -0
- package/src/lib/model/booking-time.js.map +1 -0
- package/src/lib/model/bookings-record-routes.d.ts +15 -0
- package/src/lib/model/bookings-record-routes.js +1 -1
- package/src/lib/model/bookings-record-routes.js.map +1 -1
- package/src/lib/model/bookings-search-sources.js +4 -2
- package/src/lib/model/bookings-search-sources.js.map +1 -1
- package/src/lib/model/bookings.d.ts +4 -4
- package/src/lib/model/bookings.js +3 -3
- package/src/lib/model/bookings.js.map +1 -1
- package/src/lib/model/index.d.ts +2 -0
- package/src/lib/model/index.js +2 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/server/billing-webhook.js +31 -17
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/booking-crm.d.ts +40 -24
- package/src/lib/server/booking-crm.js +84 -26
- package/src/lib/server/booking-crm.js.map +1 -1
- package/src/lib/server/booking-time-zone.d.ts +32 -0
- package/src/lib/server/booking-time-zone.js +59 -0
- package/src/lib/server/booking-time-zone.js.map +1 -0
- package/src/lib/server.js +46 -35
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.js +19 -4
- 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.
|
|
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.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
35
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
36
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
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
|
|
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
|
-
/**
|
|
46
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const
|
|
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
|
-
},
|
|
167
|
-
crmRef:
|
|
166
|
+
}, recordRef ? {
|
|
167
|
+
crmRef: formatBookingRecordRef(recordRef)
|
|
168
168
|
} : {}, marketingConsent ? {
|
|
169
169
|
marketingConsent: true
|
|
170
|
-
} : {},
|
|
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
|