@aglyn/plugins-bookings 1.0.0-beta.229 → 1.0.0-beta.230
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 +11 -11
- package/src/lib/components/book-meeting-action.js +3 -2
- package/src/lib/components/book-meeting-action.js.map +1 -1
- package/src/lib/components/booking.js +4 -0
- package/src/lib/components/booking.js.map +1 -1
- package/src/lib/components/bookings-console-page.js +62 -4
- package/src/lib/components/bookings-console-page.js.map +1 -1
- package/src/lib/model/bookings.d.ts +22 -0
- package/src/lib/model/bookings.js +11 -0
- package/src/lib/model/bookings.js.map +1 -1
- package/src/lib/server/booking-service-ai-capability.d.ts +50 -0
- package/src/lib/server/booking-service-ai-capability.js +203 -0
- package/src/lib/server/booking-service-ai-capability.js.map +1 -0
- package/src/lib/server/booking-service-drafts.d.ts +107 -0
- package/src/lib/server/booking-service-drafts.js +320 -0
- package/src/lib/server/booking-service-drafts.js.map +1 -0
- package/src/lib/server.js +15 -4
- package/src/lib/server.js.map +1 -1
- package/src/lib/utils/use-booking-purchase-event.js +5 -0
- package/src/lib/utils/use-booking-purchase-event.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.230",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,16 +25,16 @@
|
|
|
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.230",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.230",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.230",
|
|
31
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.230",
|
|
32
|
+
"@aglyn/shared-util-email": "1.0.0-beta.230",
|
|
33
|
+
"@aglyn/shared-util-http": "1.0.0-beta.230",
|
|
34
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.230",
|
|
35
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.230",
|
|
36
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.230",
|
|
37
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.230",
|
|
38
38
|
"@swc/helpers": "0.5.23"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
18
18
|
import { checkEntitlement, isHostPluginEnabled } from "@aglyn/aglyn";
|
|
19
19
|
import { bookingsPageHref } from "../model/bookings-record-routes.js";
|
|
20
|
-
import { BOOKING_PATH_DEFAULT, bookingLinkFor } from "../model/bookings.js";
|
|
20
|
+
import { BOOKING_PATH_DEFAULT, bookingLinkFor, bookingServiceIsOffered } from "../model/bookings.js";
|
|
21
21
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
22
22
|
import { mdiCalendarClock, mdiContentCopy } from "@aglyn/shared-data-mdi";
|
|
23
23
|
import { AppLink, MdiIcon } from "@aglyn/shared-ui-jsx";
|
|
@@ -126,7 +126,8 @@ BookMeetingButton.displayName = 'BookMeetingButton';
|
|
|
126
126
|
], {
|
|
127
127
|
idField: '$id'
|
|
128
128
|
});
|
|
129
|
-
const services = (serviceDocs != null ? serviceDocs : [])
|
|
129
|
+
const services = (serviceDocs != null ? serviceDocs : [])// A draft takes no bookings (AGL-3616), so it has no link to hand out.
|
|
130
|
+
.filter((service)=>bookingServiceIsOffered(service)).sort((a, b)=>{
|
|
130
131
|
var _a_name, _b_name;
|
|
131
132
|
return String((_a_name = a['name']) != null ? _a_name : '').localeCompare(String((_b_name = b['name']) != null ? _b_name : ''));
|
|
132
133
|
});
|
|
@@ -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 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'\nimport { bookingPriceText } from '../model/booking-price'\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 // `$150`, `free`, or the label the service states its price as (AGL-3475).\n const price = bookingPriceText(service)\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 · ${price === 'Free' ? 'free' : price}`}\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","bookingPriceText","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","direction","alignItems","flex","minWidth","noWrap","Number","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, bookingServiceIsOffered } 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'\nimport { bookingPriceText } from '../model/booking-price'\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 // A draft takes no bookings (AGL-3616), so it has no link to hand out.\n .filter((service) => bookingServiceIsOffered(service))\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 // `$150`, `free`, or the label the service states its price as (AGL-3475).\n const price = bookingPriceText(service)\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 · ${price === 'Free' ? 'free' : price}`}\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","bookingServiceIsOffered","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","bookingPriceText","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","direction","alignItems","flex","minWidth","noWrap","Number","wordBreak","title","span","aria-label","disabled"],"mappings":"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"}
|
|
@@ -18,6 +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 { recordSiteJourneyStep } from "@aglyn/aglyn/app-utils/site-journey";
|
|
21
22
|
import { utmTouchField } from "@aglyn/aglyn/app-utils/utm-touch";
|
|
22
23
|
import { mdiCalendarClock } from "@aglyn/shared-data-mdi";
|
|
23
24
|
import Alert from "@mui/material/Alert";
|
|
@@ -367,6 +368,9 @@ export const ID = 'booking';
|
|
|
367
368
|
// `form_name` is the block, not the service and never the guest: the
|
|
368
369
|
// name and email typed above are the reason this is the only param
|
|
369
370
|
// shape that may leave here.
|
|
371
|
+
// A funnel step (AGL-3605): a free booking is made here; a paid one
|
|
372
|
+
// when its payment settles (`use-booking-purchase-event.ts`).
|
|
373
|
+
recordSiteJourneyStep('booking', serviceId);
|
|
370
374
|
trackEvent('generate_lead', {
|
|
371
375
|
form_name: 'Booking',
|
|
372
376
|
form_location: window.location.pathname
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/components/booking.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport {\n buildBeginCheckoutParams,\n trackEvent,\n trackEventBeforeNavigation,\n} from '@aglyn/aglyn/app-utils/analytics-events'\nimport { utmTouchField } from '@aglyn/aglyn/app-utils/utm-touch'\nimport { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport Alert from '@mui/material/Alert'\nimport Box from '@mui/material/Box'\nimport Button from '@mui/material/Button'\nimport Checkbox from '@mui/material/Checkbox'\nimport Chip from '@mui/material/Chip'\nimport CircularProgress from '@mui/material/CircularProgress'\nimport FormControlLabel from '@mui/material/FormControlLabel'\nimport MenuItem from '@mui/material/MenuItem'\nimport Stack from '@mui/material/Stack'\nimport TextField from '@mui/material/TextField'\nimport Typography from '@mui/material/Typography'\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n formatBookingRecordRef,\n parseBookingRecordRef,\n} from '../model/booking-record'\nimport {\n bookingContactAsks,\n readBookingContactFields,\n readBookingPhone,\n} from '../model/booking-contact-fields'\nimport { bookingPriceDisplay, bookingPriceText } from '../model/booking-price'\nimport { BOOKING_MAX_DAYS_AHEAD, BOOKING_SLOT_PAGE_DAYS } from '../model/bookings'\nimport { generatePresetId } from '../utils/generate-preset-id'\nimport { useBookingPurchaseEvent } from '../utils/use-booking-purchase-event'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'booking'\n\nexport interface BookingProps {\n /** Heading above the widget; empty hides it. */\n heading?: string\n successMessage?: string\n}\n\ninterface ServiceOption {\n $id: string\n name: string\n durationMinutes: number\n priceUsd: number\n /** How the service states its price (AGL-3475); a label books with no charge. */\n priceDisplay?: string\n description?: string\n /** What the service asks for beyond a name and an email (AGL-3493). */\n askPhone?: string\n askAddress?: string\n}\n\n/**\n * One query value off the page the widget is on, or `null` — on the server\n * and in the besigner there is no page to read.\n *\n * A booking link built from a CRM record carries two (AGL-2660): the\n * service it is about, and the record it was dropped from. Read at the\n * moment each is needed rather than held in state, so a widget mounted\n * before the URL settles still sees the values.\n */\nfunction readLinkParam(key: string): string | null {\n if (typeof window === 'undefined') return null\n return new URLSearchParams(window.location.search).get(key)\n}\n\ninterface SlotOption {\n startsAtMs: number\n endsAtMs: number\n}\n\n/** One day of the strip: the visitor's local calendar day and its times. */\nexport interface BookingSlotDay {\n /** Local midnight that starts the day, in epoch ms — key and sort order. */\n startMs: number\n slots: SlotOption[]\n}\n\n/**\n * Slots grouped by the VISITOR's local day, in order (AGL-3492).\n *\n * A page of the listing is whole days in the SERVICE's zone, so for a\n * visitor in another zone the last day of a page can be one the page ended\n * part way through. `nextFromMs` is where the page stopped — every open time\n * before it is loaded — so a day that runs past it is held back until the\n * next page completes it, rather than shown with its later times missing.\n */\nexport function groupSlotsByLocalDay(\n slots: SlotOption[],\n nextFromMs: number | null,\n): BookingSlotDay[] {\n const byDay = new Map<number, SlotOption[]>()\n for (const slot of slots) {\n const at = new Date(slot.startsAtMs)\n const startMs = new Date(\n at.getFullYear(),\n at.getMonth(),\n at.getDate(),\n ).getTime()\n const day = byDay.get(startMs)\n if (day) day.push(slot)\n else byDay.set(startMs, [slot])\n }\n return [...byDay.entries()]\n .map(([startMs, daySlots]) => ({\n startMs,\n slots: daySlots.sort((a, b) => a.startsAtMs - b.startsAtMs),\n }))\n .filter((day) => {\n if (nextFromMs === null) return true\n const start = new Date(day.startMs)\n const nextDayMs = new Date(\n start.getFullYear(),\n start.getMonth(),\n start.getDate() + 1,\n ).getTime()\n return nextDayMs <= nextFromMs\n })\n .sort((a, b) => a.startMs - b.startMs)\n}\n\n/** A day chip's label: `Mon, Oct 5` in the visitor's locale. */\nexport function bookingDayLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })\n}\n\n/** A time chip's label: `2:30 PM` in the visitor's locale. */\nexport function bookingTimeLabel(startsAtMs: number): string {\n return new Date(startsAtMs).toLocaleTimeString([], {\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\n/**\n * A day's times in the parts of the day a visitor scans for (AGL-3492): a\n * day open 8 to 5 at 15-minute steps holds 33 starts, and one unbroken run\n * of chips that long reads as noise. Empty parts are left out.\n */\nexport function partsOfDay(\n slots: SlotOption[],\n): Array<{ label: string; slots: SlotOption[] }> {\n const parts = [\n { label: 'Morning', slots: [] as SlotOption[] },\n { label: 'Afternoon', slots: [] as SlotOption[] },\n { label: 'Evening', slots: [] as SlotOption[] },\n ]\n for (const slot of slots) {\n const hour = new Date(slot.startsAtMs).getHours()\n parts[hour < 12 ? 0 : hour < 17 ? 1 : 2].slots.push(slot)\n }\n return parts.filter((part) => part.slots.length > 0)\n}\n\n/**\n * Booking widget (AGL-160): service picker → open-slot picker → contact\n * form, driven by the tenant /api/bookings endpoints (server-validated,\n * double-booking safe). Inert placeholder in the besigner/preview (no\n * SiteContext host id) so editing never creates bookings.\n */\nconst Booking = forwardRef<HTMLDivElement, BookingProps>((props, ref) => {\n const { heading, successMessage, ...rest } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const siteFetch = Aglyn.useSiteFetch()\n\n // The merchant's own `purchase` for a paid booking (AGL-2481). This widget\n // is where Stripe returns the guest, because a tenant site has no booking\n // confirmation route. No-ops on every ordinary render: the hook returns\n // immediately unless the URL carries `?booking=paid&session_id=…`.\n useBookingPurchaseEvent(hostId, siteFetch)\n\n const [services, setServices] = useState<ServiceOption[] | null>(null)\n const [serviceId, setServiceId] = useState('')\n // Every page of the listing loaded so far, in order (AGL-3492).\n const [slots, setSlots] = useState<SlotOption[] | null>(null)\n // Where the next page starts; `null` once the horizon is loaded.\n const [nextFromMs, setNextFromMs] = useState<number | null>(null)\n const [horizonDays, setHorizonDays] = useState(BOOKING_MAX_DAYS_AHEAD)\n const [loadingMore, setLoadingMore] = useState(false)\n // The first day of the strip on screen, as an index into the loaded days.\n const [stripStart, setStripStart] = useState(0)\n // The service the loaded pages belong to, so a page that lands after the\n // visitor switched service is dropped rather than mixed into the new one.\n const slotsServiceRef = useRef('')\n const [slotMs, setSlotMs] = useState<number | null>(null)\n const [name, setName] = useState('')\n const [email, setEmail] = useState('')\n const [phone, setPhone] = useState('')\n const [address, setAddress] = useState('')\n const [marketingConsent, setMarketingConsent] = useState(false)\n const [status, setStatus] = useState<\n 'idle' | 'loading' | 'booking' | 'booked' | 'error'\n >('idle')\n const [errorMessage, setErrorMessage] = useState<string | null>(null)\n const [alerts, setAlerts] = useState<\n Array<{ message: string; severity?: string }>\n >([])\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void fetch(`/api/bookings/slots?hostId=${encodeURIComponent(hostId)}`)\n .then((response) => response.json())\n .then((payload) => {\n if (!active) return\n const loaded: ServiceOption[] = payload?.services ?? []\n setServices(loaded)\n // A booking link opens the widget on its service (AGL-2660). Only\n // a service the site still offers: a link to one since deleted\n // leaves the picker for the visitor rather than failing on it.\n const wanted = readLinkParam(BOOKING_SERVICE_PARAM)\n if (wanted && loaded.some((one) => one.$id === wanted)) {\n setServiceId(wanted)\n }\n })\n .catch(() => {\n if (active) setServices([])\n })\n return () => {\n active = false\n }\n }, [hostId])\n\n /**\n * One page of the listing (AGL-3492): the next strip's worth of days from\n * `fromMs`, or from now. Appended, never replacing, so a day split across\n * two pages for a visitor in another zone is whole once both are in.\n */\n const fetchSlotPage = useCallback(\n async (forService: string, fromMs: number | null) => {\n const response = await fetch(\n `/api/bookings/slots?hostId=${encodeURIComponent(hostId ?? '')}` +\n `&serviceId=${encodeURIComponent(forService)}` +\n (fromMs !== null ? `&from=${encodeURIComponent(String(fromMs))}` : ''),\n )\n const payload = await response.json()\n if (slotsServiceRef.current !== forService) return\n const page: SlotOption[] = Array.isArray(payload?.slots)\n ? payload.slots\n : []\n setSlots((loaded) => {\n const seen = new Set((loaded ?? []).map((slot) => slot.startsAtMs))\n return [\n ...(loaded ?? []),\n ...page.filter((slot) => !seen.has(slot.startsAtMs)),\n ]\n })\n setNextFromMs(\n typeof payload?.nextFromMs === 'number' ? payload.nextFromMs : null,\n )\n if (Number(payload?.horizonDays) > 0) {\n setHorizonDays(Number(payload.horizonDays))\n }\n },\n [hostId],\n )\n\n useEffect(() => {\n slotsServiceRef.current = serviceId\n setSlots(null)\n setNextFromMs(null)\n setStripStart(0)\n setSlotMs(null)\n if (!hostId || !serviceId) return\n fetchSlotPage(serviceId, null).catch(() => {\n if (slotsServiceRef.current === serviceId) setSlots([])\n })\n }, [hostId, serviceId, fetchSlotPage])\n\n const days = useMemo(\n () => groupSlotsByLocalDay(slots ?? [], nextFromMs),\n [slots, nextFromMs],\n )\n const visibleDays = days.slice(stripStart, stripStart + BOOKING_SLOT_PAGE_DAYS)\n\n // The strip asks for the next page itself when the days it is showing run\n // out before the horizon does — after \"Later dates\", or when a page held\n // no open day this visitor can see whole.\n useEffect(() => {\n if (slots === null || nextFromMs === null || loadingMore) return\n if (days.length >= stripStart + BOOKING_SLOT_PAGE_DAYS) return\n if (stripStart === 0 && days.length > 0) return\n const forService = serviceId\n setLoadingMore(true)\n fetchSlotPage(forService, nextFromMs)\n .catch(() => {\n // Stop asking: a page that failed would otherwise be asked for\n // again on every render.\n if (slotsServiceRef.current === forService) setNextFromMs(null)\n })\n .finally(() => setLoadingMore(false))\n }, [\n slots,\n nextFromMs,\n loadingMore,\n days.length,\n stripStart,\n serviceId,\n fetchSlotPage,\n ])\n\n const [day, setDay] = useState<number | null>(null)\n useEffect(() => {\n setDay(null)\n }, [serviceId])\n const dayTimes = useMemo(\n () => partsOfDay(days.find((one) => one.startMs === day)?.slots ?? []),\n [days, day],\n )\n const hasEarlierDates = stripStart > 0\n const hasLaterDates =\n days.length > stripStart + BOOKING_SLOT_PAGE_DAYS || nextFromMs !== null\n const moveStrip = (toStart: number) => {\n setStripStart(Math.max(0, toStart))\n setDay(null)\n setSlotMs(null)\n }\n\n // What the picked service asks for beyond a name and an email, and\n // whether what was typed answers it (AGL-3493) — the route's own reading,\n // so the button and the server cannot disagree about a required field.\n const asks = bookingContactAsks(\n (services ?? []).find((one) => one.$id === serviceId),\n )\n const contactCheck = readBookingContactFields(asks, { phone, address })\n const phoneUnreadable =\n asks.phone !== 'off' && Boolean(phone.trim()) && !readBookingPhone(phone)\n\n const handleBook = useCallback(async () => {\n if (!hostId || !serviceId || !slotMs || status === 'booking') return\n setStatus('booking')\n setErrorMessage(null)\n // The record a booking link was dropped from (AGL-2660), carried onto\n // the request so the booking lands on that record even when the visitor\n // books with a different address. Parsed here so a value that is not a\n // reference is never sent.\n const recordRef = parseBookingRecordRef(readLinkParam(BOOKING_RECORD_PARAM))\n try {\n const response = await siteFetch('/api/bookings/book', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n serviceId,\n startsAtMs: slotMs,\n name,\n email,\n // Only what the service asks for: the route drops anything else.\n ...(contactCheck.ok ? contactCheck.fields : {}),\n ...(recordRef ? { crmRef: formatBookingRecordRef(recordRef) } : {}),\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // The campaign this visitor came from, when they came from one.\n // A booking is an identify moment like a form submission: the\n // visitor was anonymous until this request named them.\n ...utmTouchField(),\n }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n setErrorMessage(payload?.error ?? 'Booking failed — try again')\n setStatus('error')\n return\n }\n if (payload?.checkoutUrl) {\n // Paid service (AGL-170): finish payment on Stripe; the webhook\n // confirms the held slot.\n //\n // The `begin_checkout` that `purchase` has never had a counterpart to\n // (AGL-2481 wired the completion and left the intent). Without it a\n // merchant selling appointments sees a purchase with no checkout step\n // in front of it, so GA4's shopping funnel reports every paid booking\n // as an abandoned one — the same shape the bookings plugin was\n // reporting before it sent anything at all.\n //\n // After the server accepted the hold, so a double-booked slot or a\n // closed calendar — both of which answer !ok above — never counts.\n //\n // `item_id` is the SERVICE id, matching the id\n // `buildBookingPurchaseParams` puts on the completed hit, so the two\n // steps join into one per-service funnel instead of naming the same\n // service twice.\n const service = (services ?? []).find((one) => one.$id === serviceId)\n await trackEventBeforeNavigation(\n 'begin_checkout',\n buildBeginCheckoutParams({\n items: [\n {\n item_id: serviceId,\n item_name: service?.name ?? 'Service',\n price: service?.priceUsd,\n quantity: 1,\n },\n ],\n }),\n )\n window.location.assign(payload.checkoutUrl)\n return\n }\n if (Array.isArray(payload?.alerts)) setAlerts(payload.alerts)\n // A FREE booking is confirmed right here — there is no Stripe leg, so\n // nothing downstream will ever report it, and a merchant whose services\n // are free saw an appointment book with no event of any kind.\n //\n // `generate_lead` rather than a zero-value `purchase`: GA4 has no\n // recommended booking event, `purchase` with `value: 0` would put a\n // free appointment into the merchant's revenue reports as a sale worth\n // nothing, and a confirmed request for someone's time IS the lead this\n // event names. The form's own generic submissions report the same event\n // from `form.tsx`, so the merchant reads one conversion count.\n //\n // `form_name` is the block, not the service and never the guest: the\n // name and email typed above are the reason this is the only param\n // shape that may leave here.\n trackEvent('generate_lead', {\n form_name: 'Booking',\n form_location: window.location.pathname,\n })\n setStatus('booked')\n } catch {\n setErrorMessage('Booking failed — try again')\n setStatus('error')\n }\n }, [\n hostId,\n serviceId,\n slotMs,\n name,\n email,\n contactCheck,\n marketingConsent,\n status,\n siteFetch,\n // Read for the checkout event's item name and price. Without it the\n // handler closes over the services list as it was when the handler was\n // created, which on the first render is `null` — and every paid booking\n // would report an unnamed, unpriced service.\n services,\n ])\n\n if (!hostId) {\n return (\n <Box\n ref={ref}\n {...rest}\n sx={[\n {\n p: 3,\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: 1,\n color: 'text.secondary',\n fontSize: 13,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'Booking widget — visitors pick a service and time here'}\n </Box>\n )\n }\n\n if (status === 'booked') {\n return (\n <Stack ref={ref} spacing={1.5} {...rest}>\n <Alert severity=\"success\">\n {successMessage ||\n 'Booking confirmed — check your email for the details.'}\n </Alert>\n {alerts.map((alert, index) => (\n <Alert key={index} severity={(alert.severity as any) || 'info'}>\n {alert.message}\n </Alert>\n ))}\n </Stack>\n )\n }\n\n const selected = (services ?? []).find(\n (service) => service.$id === serviceId,\n )\n\n return (\n <Stack ref={ref} spacing={2} {...rest}>\n {heading ? <Typography variant=\"h5\">{heading}</Typography> : null}\n {services === null ? (\n <CircularProgress size={24} />\n ) : services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No bookable services yet.'}\n </Typography>\n ) : (\n <TextField\n select\n label=\"Service\"\n value={serviceId}\n onChange={(event) => setServiceId(event.target.value)}\n size=\"small\"\n fullWidth\n >\n {services.map((service) => (\n <MenuItem key={service.$id} value={service.$id}>\n {`${service.name} · ${service.durationMinutes} min` +\n (bookingPriceDisplay(service.priceDisplay) !== 'fixed' || service.priceUsd > 0\n ? ` · ${bookingPriceText(service)}`\n : '')}\n </MenuItem>\n ))}\n </TextField>\n )}\n {selected?.description ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {selected.description}\n </Typography>\n ) : null}\n {serviceId ? (\n slots === null || (days.length === 0 && nextFromMs !== null) ? (\n <CircularProgress size={24} />\n ) : days.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No open times in the next ${horizonDays} days.`}\n </Typography>\n ) : (\n <>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n {visibleDays.map((one) => (\n <Chip\n key={one.startMs}\n label={bookingDayLabel(one.startMs)}\n color={day === one.startMs ? 'primary' : 'default'}\n variant={day === one.startMs ? 'filled' : 'outlined'}\n onClick={() => {\n setDay(one.startMs)\n setSlotMs(null)\n }}\n />\n ))}\n {loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS ? (\n <CircularProgress size={20} sx={{ alignSelf: 'center' }} />\n ) : null}\n </Stack>\n {hasEarlierDates || hasLaterDates ? (\n <Stack direction=\"row\" spacing={1}>\n {hasEarlierDates ? (\n <Button\n size=\"small\"\n onClick={() =>\n moveStrip(stripStart - BOOKING_SLOT_PAGE_DAYS)\n }\n >\n {'Earlier dates'}\n </Button>\n ) : null}\n {hasLaterDates ? (\n <Button\n size=\"small\"\n disabled={\n loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS\n }\n // Never past the last day loaded: a strip that was short\n // moves on from where it ended, so no day is stepped over\n // while the next page loads.\n onClick={() =>\n moveStrip(\n Math.min(\n stripStart + BOOKING_SLOT_PAGE_DAYS,\n days.length,\n ),\n )\n }\n >\n {'Later dates'}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n {day !== null ? (\n <Stack spacing={1.5}>\n {dayTimes.map((part) => (\n <Stack key={part.label} spacing={0.75}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {part.label}\n </Typography>\n <Box\n role=\"group\"\n aria-label={`${part.label} times`}\n sx={{\n display: 'grid',\n gridTemplateColumns:\n 'repeat(auto-fill, minmax(5.5rem, 1fr))',\n gap: 1,\n }}\n >\n {part.slots.map((slot) => (\n <Chip\n key={slot.startsAtMs}\n label={bookingTimeLabel(slot.startsAtMs)}\n color={\n slotMs === slot.startsAtMs ? 'primary' : 'default'\n }\n variant={\n slotMs === slot.startsAtMs ? 'filled' : 'outlined'\n }\n onClick={() => setSlotMs(slot.startsAtMs)}\n />\n ))}\n </Box>\n </Stack>\n ))}\n </Stack>\n ) : null}\n </>\n )\n ) : null}\n {slotMs ? (\n <Stack spacing={1.5}>\n <TextField\n label=\"Your name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n size=\"small\"\n fullWidth\n />\n <TextField\n label=\"Email\"\n type=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n size=\"small\"\n fullWidth\n />\n {asks.phone !== 'off' ? (\n <TextField\n label=\"Phone\"\n type=\"tel\"\n autoComplete=\"tel\"\n required={asks.phone === 'required'}\n value={phone}\n onChange={(event) => setPhone(event.target.value)}\n error={phoneUnreadable}\n helperText={phoneUnreadable ? 'Enter a valid phone number' : undefined}\n size=\"small\"\n fullWidth\n />\n ) : null}\n {asks.address !== 'off' ? (\n <TextField\n label=\"Address\"\n autoComplete=\"street-address\"\n required={asks.address === 'required'}\n value={address}\n onChange={(event) => setAddress(event.target.value)}\n size=\"small\"\n fullWidth\n multiline\n minRows={2}\n />\n ) : null}\n <FormControlLabel\n control={\n <Checkbox\n size=\"small\"\n checked={marketingConsent}\n onChange={(event) =>\n setMarketingConsent(event.target.checked)\n }\n />\n }\n label=\"Email me about offers and updates\"\n />\n {errorMessage ? <Alert severity=\"error\">{errorMessage}</Alert> : null}\n <Button\n variant=\"contained\"\n disabled={\n !name.trim() ||\n !email.trim() ||\n !contactCheck.ok ||\n status === 'booking'\n }\n onClick={handleBook}\n sx={{ alignSelf: 'flex-start' }}\n >\n {status === 'booking' ? 'Booking…' : 'Confirm booking'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n )\n})\nBooking.displayName = 'Booking'\n\nexport const schema: Aglyn.ComponentSchema<BookingProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Booking',\n description:\n 'Service and time picker that books appointments. It is double-booking safe.',\n category: Aglyn.ComponentCategory.INPUT,\n icon: {\n path: mdiCalendarClock.path,\n sx: { color: '#0288d1' },\n },\n flags: {\n selfClosing: Aglyn.FEATURE_FLAG.ENABLED,\n },\n attributes: [\n {\n name: 'heading',\n description: 'Heading above the widget; empty hides it.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Heading',\n },\n {\n name: 'successMessage',\n description: 'Shown after a confirmed booking.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Success message',\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Booking',\n pluginId: BUNDLE_ID,\n description: 'Service + time picker that books appointments',\n category: Aglyn.ComponentCategory.INPUT,\n icon: {\n path: mdiCalendarClock.path,\n sx: { color: '#0288d1' },\n },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default Booking\n"],"names":["Aglyn","buildBeginCheckoutParams","trackEvent","trackEventBeforeNavigation","utmTouchField","mdiCalendarClock","Alert","Box","Button","Checkbox","Chip","CircularProgress","FormControlLabel","MenuItem","Stack","TextField","Typography","forwardRef","useCallback","useEffect","useMemo","useRef","useState","BUNDLE_ID","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","parseBookingRecordRef","bookingContactAsks","readBookingContactFields","readBookingPhone","bookingPriceDisplay","bookingPriceText","BOOKING_MAX_DAYS_AHEAD","BOOKING_SLOT_PAGE_DAYS","generatePresetId","useBookingPurchaseEvent","ID","readLinkParam","key","window","URLSearchParams","location","search","get","groupSlotsByLocalDay","slots","nextFromMs","byDay","Map","slot","at","Date","startsAtMs","startMs","getFullYear","getMonth","getDate","getTime","day","push","set","entries","map","daySlots","sort","a","b","filter","start","nextDayMs","bookingDayLabel","toLocaleDateString","undefined","weekday","month","bookingTimeLabel","toLocaleTimeString","hour","minute","partsOfDay","parts","label","getHours","part","length","Booking","props","ref","heading","successMessage","rest","nodeSx","Array","isArray","hostId","useSite","siteFetch","useSiteFetch","services","setServices","serviceId","setServiceId","setSlots","setNextFromMs","horizonDays","setHorizonDays","loadingMore","setLoadingMore","stripStart","setStripStart","slotsServiceRef","slotMs","setSlotMs","name","setName","email","setEmail","phone","setPhone","address","setAddress","marketingConsent","setMarketingConsent","status","setStatus","errorMessage","setErrorMessage","alerts","setAlerts","active","fetch","encodeURIComponent","then","response","json","payload","loaded","wanted","some","one","$id","catch","fetchSlotPage","forService","fromMs","String","current","page","seen","Set","has","Number","days","visibleDays","slice","finally","setDay","dayTimes","find","hasEarlierDates","hasLaterDates","moveStrip","toStart","Math","max","asks","contactCheck","phoneUnreadable","Boolean","trim","handleBook","recordRef","method","headers","body","JSON","stringify","ok","fields","crmRef","error","checkoutUrl","service","items","item_id","item_name","price","priceUsd","quantity","assign","form_name","form_location","pathname","sx","p","border","borderColor","borderRadius","color","fontSize","fontFamily","spacing","severity","alert","index","message","selected","variant","size","select","value","onChange","event","target","fullWidth","durationMinutes","priceDisplay","description","direction","flexWrap","rowGap","onClick","alignSelf","disabled","min","role","aria-label","display","gridTemplateColumns","gap","type","autoComplete","required","helperText","multiline","minRows","control","checked","displayName","schema","pluginId","category","ComponentCategory","INPUT","icon","path","flags","selfClosing","FEATURE_FLAG","ENABLED","attributes","component","FieldComponentType","TEXT_FIELD","presets","NodeType","PRESET","data","componentId"],"mappings":"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"}
|
|
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 { recordSiteJourneyStep } from '@aglyn/aglyn/app-utils/site-journey'\nimport { utmTouchField } from '@aglyn/aglyn/app-utils/utm-touch'\nimport { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport Alert from '@mui/material/Alert'\nimport Box from '@mui/material/Box'\nimport Button from '@mui/material/Button'\nimport Checkbox from '@mui/material/Checkbox'\nimport Chip from '@mui/material/Chip'\nimport CircularProgress from '@mui/material/CircularProgress'\nimport FormControlLabel from '@mui/material/FormControlLabel'\nimport MenuItem from '@mui/material/MenuItem'\nimport Stack from '@mui/material/Stack'\nimport TextField from '@mui/material/TextField'\nimport Typography from '@mui/material/Typography'\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n formatBookingRecordRef,\n parseBookingRecordRef,\n} from '../model/booking-record'\nimport {\n bookingContactAsks,\n readBookingContactFields,\n readBookingPhone,\n} from '../model/booking-contact-fields'\nimport { bookingPriceDisplay, bookingPriceText } from '../model/booking-price'\nimport { BOOKING_MAX_DAYS_AHEAD, BOOKING_SLOT_PAGE_DAYS } from '../model/bookings'\nimport { generatePresetId } from '../utils/generate-preset-id'\nimport { useBookingPurchaseEvent } from '../utils/use-booking-purchase-event'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'booking'\n\nexport interface BookingProps {\n /** Heading above the widget; empty hides it. */\n heading?: string\n successMessage?: string\n}\n\ninterface ServiceOption {\n $id: string\n name: string\n durationMinutes: number\n priceUsd: number\n /** How the service states its price (AGL-3475); a label books with no charge. */\n priceDisplay?: string\n description?: string\n /** What the service asks for beyond a name and an email (AGL-3493). */\n askPhone?: string\n askAddress?: string\n}\n\n/**\n * One query value off the page the widget is on, or `null` — on the server\n * and in the besigner there is no page to read.\n *\n * A booking link built from a CRM record carries two (AGL-2660): the\n * service it is about, and the record it was dropped from. Read at the\n * moment each is needed rather than held in state, so a widget mounted\n * before the URL settles still sees the values.\n */\nfunction readLinkParam(key: string): string | null {\n if (typeof window === 'undefined') return null\n return new URLSearchParams(window.location.search).get(key)\n}\n\ninterface SlotOption {\n startsAtMs: number\n endsAtMs: number\n}\n\n/** One day of the strip: the visitor's local calendar day and its times. */\nexport interface BookingSlotDay {\n /** Local midnight that starts the day, in epoch ms — key and sort order. */\n startMs: number\n slots: SlotOption[]\n}\n\n/**\n * Slots grouped by the VISITOR's local day, in order (AGL-3492).\n *\n * A page of the listing is whole days in the SERVICE's zone, so for a\n * visitor in another zone the last day of a page can be one the page ended\n * part way through. `nextFromMs` is where the page stopped — every open time\n * before it is loaded — so a day that runs past it is held back until the\n * next page completes it, rather than shown with its later times missing.\n */\nexport function groupSlotsByLocalDay(\n slots: SlotOption[],\n nextFromMs: number | null,\n): BookingSlotDay[] {\n const byDay = new Map<number, SlotOption[]>()\n for (const slot of slots) {\n const at = new Date(slot.startsAtMs)\n const startMs = new Date(\n at.getFullYear(),\n at.getMonth(),\n at.getDate(),\n ).getTime()\n const day = byDay.get(startMs)\n if (day) day.push(slot)\n else byDay.set(startMs, [slot])\n }\n return [...byDay.entries()]\n .map(([startMs, daySlots]) => ({\n startMs,\n slots: daySlots.sort((a, b) => a.startsAtMs - b.startsAtMs),\n }))\n .filter((day) => {\n if (nextFromMs === null) return true\n const start = new Date(day.startMs)\n const nextDayMs = new Date(\n start.getFullYear(),\n start.getMonth(),\n start.getDate() + 1,\n ).getTime()\n return nextDayMs <= nextFromMs\n })\n .sort((a, b) => a.startMs - b.startMs)\n}\n\n/** A day chip's label: `Mon, Oct 5` in the visitor's locale. */\nexport function bookingDayLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })\n}\n\n/** A time chip's label: `2:30 PM` in the visitor's locale. */\nexport function bookingTimeLabel(startsAtMs: number): string {\n return new Date(startsAtMs).toLocaleTimeString([], {\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\n/**\n * A day's times in the parts of the day a visitor scans for (AGL-3492): a\n * day open 8 to 5 at 15-minute steps holds 33 starts, and one unbroken run\n * of chips that long reads as noise. Empty parts are left out.\n */\nexport function partsOfDay(\n slots: SlotOption[],\n): Array<{ label: string; slots: SlotOption[] }> {\n const parts = [\n { label: 'Morning', slots: [] as SlotOption[] },\n { label: 'Afternoon', slots: [] as SlotOption[] },\n { label: 'Evening', slots: [] as SlotOption[] },\n ]\n for (const slot of slots) {\n const hour = new Date(slot.startsAtMs).getHours()\n parts[hour < 12 ? 0 : hour < 17 ? 1 : 2].slots.push(slot)\n }\n return parts.filter((part) => part.slots.length > 0)\n}\n\n/**\n * Booking widget (AGL-160): service picker → open-slot picker → contact\n * form, driven by the tenant /api/bookings endpoints (server-validated,\n * double-booking safe). Inert placeholder in the besigner/preview (no\n * SiteContext host id) so editing never creates bookings.\n */\nconst Booking = forwardRef<HTMLDivElement, BookingProps>((props, ref) => {\n const { heading, successMessage, ...rest } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const siteFetch = Aglyn.useSiteFetch()\n\n // The merchant's own `purchase` for a paid booking (AGL-2481). This widget\n // is where Stripe returns the guest, because a tenant site has no booking\n // confirmation route. No-ops on every ordinary render: the hook returns\n // immediately unless the URL carries `?booking=paid&session_id=…`.\n useBookingPurchaseEvent(hostId, siteFetch)\n\n const [services, setServices] = useState<ServiceOption[] | null>(null)\n const [serviceId, setServiceId] = useState('')\n // Every page of the listing loaded so far, in order (AGL-3492).\n const [slots, setSlots] = useState<SlotOption[] | null>(null)\n // Where the next page starts; `null` once the horizon is loaded.\n const [nextFromMs, setNextFromMs] = useState<number | null>(null)\n const [horizonDays, setHorizonDays] = useState(BOOKING_MAX_DAYS_AHEAD)\n const [loadingMore, setLoadingMore] = useState(false)\n // The first day of the strip on screen, as an index into the loaded days.\n const [stripStart, setStripStart] = useState(0)\n // The service the loaded pages belong to, so a page that lands after the\n // visitor switched service is dropped rather than mixed into the new one.\n const slotsServiceRef = useRef('')\n const [slotMs, setSlotMs] = useState<number | null>(null)\n const [name, setName] = useState('')\n const [email, setEmail] = useState('')\n const [phone, setPhone] = useState('')\n const [address, setAddress] = useState('')\n const [marketingConsent, setMarketingConsent] = useState(false)\n const [status, setStatus] = useState<\n 'idle' | 'loading' | 'booking' | 'booked' | 'error'\n >('idle')\n const [errorMessage, setErrorMessage] = useState<string | null>(null)\n const [alerts, setAlerts] = useState<\n Array<{ message: string; severity?: string }>\n >([])\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void fetch(`/api/bookings/slots?hostId=${encodeURIComponent(hostId)}`)\n .then((response) => response.json())\n .then((payload) => {\n if (!active) return\n const loaded: ServiceOption[] = payload?.services ?? []\n setServices(loaded)\n // A booking link opens the widget on its service (AGL-2660). Only\n // a service the site still offers: a link to one since deleted\n // leaves the picker for the visitor rather than failing on it.\n const wanted = readLinkParam(BOOKING_SERVICE_PARAM)\n if (wanted && loaded.some((one) => one.$id === wanted)) {\n setServiceId(wanted)\n }\n })\n .catch(() => {\n if (active) setServices([])\n })\n return () => {\n active = false\n }\n }, [hostId])\n\n /**\n * One page of the listing (AGL-3492): the next strip's worth of days from\n * `fromMs`, or from now. Appended, never replacing, so a day split across\n * two pages for a visitor in another zone is whole once both are in.\n */\n const fetchSlotPage = useCallback(\n async (forService: string, fromMs: number | null) => {\n const response = await fetch(\n `/api/bookings/slots?hostId=${encodeURIComponent(hostId ?? '')}` +\n `&serviceId=${encodeURIComponent(forService)}` +\n (fromMs !== null ? `&from=${encodeURIComponent(String(fromMs))}` : ''),\n )\n const payload = await response.json()\n if (slotsServiceRef.current !== forService) return\n const page: SlotOption[] = Array.isArray(payload?.slots)\n ? payload.slots\n : []\n setSlots((loaded) => {\n const seen = new Set((loaded ?? []).map((slot) => slot.startsAtMs))\n return [\n ...(loaded ?? []),\n ...page.filter((slot) => !seen.has(slot.startsAtMs)),\n ]\n })\n setNextFromMs(\n typeof payload?.nextFromMs === 'number' ? payload.nextFromMs : null,\n )\n if (Number(payload?.horizonDays) > 0) {\n setHorizonDays(Number(payload.horizonDays))\n }\n },\n [hostId],\n )\n\n useEffect(() => {\n slotsServiceRef.current = serviceId\n setSlots(null)\n setNextFromMs(null)\n setStripStart(0)\n setSlotMs(null)\n if (!hostId || !serviceId) return\n fetchSlotPage(serviceId, null).catch(() => {\n if (slotsServiceRef.current === serviceId) setSlots([])\n })\n }, [hostId, serviceId, fetchSlotPage])\n\n const days = useMemo(\n () => groupSlotsByLocalDay(slots ?? [], nextFromMs),\n [slots, nextFromMs],\n )\n const visibleDays = days.slice(stripStart, stripStart + BOOKING_SLOT_PAGE_DAYS)\n\n // The strip asks for the next page itself when the days it is showing run\n // out before the horizon does — after \"Later dates\", or when a page held\n // no open day this visitor can see whole.\n useEffect(() => {\n if (slots === null || nextFromMs === null || loadingMore) return\n if (days.length >= stripStart + BOOKING_SLOT_PAGE_DAYS) return\n if (stripStart === 0 && days.length > 0) return\n const forService = serviceId\n setLoadingMore(true)\n fetchSlotPage(forService, nextFromMs)\n .catch(() => {\n // Stop asking: a page that failed would otherwise be asked for\n // again on every render.\n if (slotsServiceRef.current === forService) setNextFromMs(null)\n })\n .finally(() => setLoadingMore(false))\n }, [\n slots,\n nextFromMs,\n loadingMore,\n days.length,\n stripStart,\n serviceId,\n fetchSlotPage,\n ])\n\n const [day, setDay] = useState<number | null>(null)\n useEffect(() => {\n setDay(null)\n }, [serviceId])\n const dayTimes = useMemo(\n () => partsOfDay(days.find((one) => one.startMs === day)?.slots ?? []),\n [days, day],\n )\n const hasEarlierDates = stripStart > 0\n const hasLaterDates =\n days.length > stripStart + BOOKING_SLOT_PAGE_DAYS || nextFromMs !== null\n const moveStrip = (toStart: number) => {\n setStripStart(Math.max(0, toStart))\n setDay(null)\n setSlotMs(null)\n }\n\n // What the picked service asks for beyond a name and an email, and\n // whether what was typed answers it (AGL-3493) — the route's own reading,\n // so the button and the server cannot disagree about a required field.\n const asks = bookingContactAsks(\n (services ?? []).find((one) => one.$id === serviceId),\n )\n const contactCheck = readBookingContactFields(asks, { phone, address })\n const phoneUnreadable =\n asks.phone !== 'off' && Boolean(phone.trim()) && !readBookingPhone(phone)\n\n const handleBook = useCallback(async () => {\n if (!hostId || !serviceId || !slotMs || status === 'booking') return\n setStatus('booking')\n setErrorMessage(null)\n // The record a booking link was dropped from (AGL-2660), carried onto\n // the request so the booking lands on that record even when the visitor\n // books with a different address. Parsed here so a value that is not a\n // reference is never sent.\n const recordRef = parseBookingRecordRef(readLinkParam(BOOKING_RECORD_PARAM))\n try {\n const response = await siteFetch('/api/bookings/book', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n serviceId,\n startsAtMs: slotMs,\n name,\n email,\n // Only what the service asks for: the route drops anything else.\n ...(contactCheck.ok ? contactCheck.fields : {}),\n ...(recordRef ? { crmRef: formatBookingRecordRef(recordRef) } : {}),\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // The campaign this visitor came from, when they came from one.\n // A booking is an identify moment like a form submission: the\n // visitor was anonymous until this request named them.\n ...utmTouchField(),\n }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n setErrorMessage(payload?.error ?? 'Booking failed — try again')\n setStatus('error')\n return\n }\n if (payload?.checkoutUrl) {\n // Paid service (AGL-170): finish payment on Stripe; the webhook\n // confirms the held slot.\n //\n // The `begin_checkout` that `purchase` has never had a counterpart to\n // (AGL-2481 wired the completion and left the intent). Without it a\n // merchant selling appointments sees a purchase with no checkout step\n // in front of it, so GA4's shopping funnel reports every paid booking\n // as an abandoned one — the same shape the bookings plugin was\n // reporting before it sent anything at all.\n //\n // After the server accepted the hold, so a double-booked slot or a\n // closed calendar — both of which answer !ok above — never counts.\n //\n // `item_id` is the SERVICE id, matching the id\n // `buildBookingPurchaseParams` puts on the completed hit, so the two\n // steps join into one per-service funnel instead of naming the same\n // service twice.\n const service = (services ?? []).find((one) => one.$id === serviceId)\n await trackEventBeforeNavigation(\n 'begin_checkout',\n buildBeginCheckoutParams({\n items: [\n {\n item_id: serviceId,\n item_name: service?.name ?? 'Service',\n price: service?.priceUsd,\n quantity: 1,\n },\n ],\n }),\n )\n window.location.assign(payload.checkoutUrl)\n return\n }\n if (Array.isArray(payload?.alerts)) setAlerts(payload.alerts)\n // A FREE booking is confirmed right here — there is no Stripe leg, so\n // nothing downstream will ever report it, and a merchant whose services\n // are free saw an appointment book with no event of any kind.\n //\n // `generate_lead` rather than a zero-value `purchase`: GA4 has no\n // recommended booking event, `purchase` with `value: 0` would put a\n // free appointment into the merchant's revenue reports as a sale worth\n // nothing, and a confirmed request for someone's time IS the lead this\n // event names. The form's own generic submissions report the same event\n // from `form.tsx`, so the merchant reads one conversion count.\n //\n // `form_name` is the block, not the service and never the guest: the\n // name and email typed above are the reason this is the only param\n // shape that may leave here.\n // A funnel step (AGL-3605): a free booking is made here; a paid one\n // when its payment settles (`use-booking-purchase-event.ts`).\n recordSiteJourneyStep('booking', serviceId)\n trackEvent('generate_lead', {\n form_name: 'Booking',\n form_location: window.location.pathname,\n })\n setStatus('booked')\n } catch {\n setErrorMessage('Booking failed — try again')\n setStatus('error')\n }\n }, [\n hostId,\n serviceId,\n slotMs,\n name,\n email,\n contactCheck,\n marketingConsent,\n status,\n siteFetch,\n // Read for the checkout event's item name and price. Without it the\n // handler closes over the services list as it was when the handler was\n // created, which on the first render is `null` — and every paid booking\n // would report an unnamed, unpriced service.\n services,\n ])\n\n if (!hostId) {\n return (\n <Box\n ref={ref}\n {...rest}\n sx={[\n {\n p: 3,\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: 1,\n color: 'text.secondary',\n fontSize: 13,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'Booking widget — visitors pick a service and time here'}\n </Box>\n )\n }\n\n if (status === 'booked') {\n return (\n <Stack ref={ref} spacing={1.5} {...rest}>\n <Alert severity=\"success\">\n {successMessage ||\n 'Booking confirmed — check your email for the details.'}\n </Alert>\n {alerts.map((alert, index) => (\n <Alert key={index} severity={(alert.severity as any) || 'info'}>\n {alert.message}\n </Alert>\n ))}\n </Stack>\n )\n }\n\n const selected = (services ?? []).find(\n (service) => service.$id === serviceId,\n )\n\n return (\n <Stack ref={ref} spacing={2} {...rest}>\n {heading ? <Typography variant=\"h5\">{heading}</Typography> : null}\n {services === null ? (\n <CircularProgress size={24} />\n ) : services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No bookable services yet.'}\n </Typography>\n ) : (\n <TextField\n select\n label=\"Service\"\n value={serviceId}\n onChange={(event) => setServiceId(event.target.value)}\n size=\"small\"\n fullWidth\n >\n {services.map((service) => (\n <MenuItem key={service.$id} value={service.$id}>\n {`${service.name} · ${service.durationMinutes} min` +\n (bookingPriceDisplay(service.priceDisplay) !== 'fixed' || service.priceUsd > 0\n ? ` · ${bookingPriceText(service)}`\n : '')}\n </MenuItem>\n ))}\n </TextField>\n )}\n {selected?.description ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {selected.description}\n </Typography>\n ) : null}\n {serviceId ? (\n slots === null || (days.length === 0 && nextFromMs !== null) ? (\n <CircularProgress size={24} />\n ) : days.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No open times in the next ${horizonDays} days.`}\n </Typography>\n ) : (\n <>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', rowGap: 1 }}\n >\n {visibleDays.map((one) => (\n <Chip\n key={one.startMs}\n label={bookingDayLabel(one.startMs)}\n color={day === one.startMs ? 'primary' : 'default'}\n variant={day === one.startMs ? 'filled' : 'outlined'}\n onClick={() => {\n setDay(one.startMs)\n setSlotMs(null)\n }}\n />\n ))}\n {loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS ? (\n <CircularProgress size={20} sx={{ alignSelf: 'center' }} />\n ) : null}\n </Stack>\n {hasEarlierDates || hasLaterDates ? (\n <Stack direction=\"row\" spacing={1}>\n {hasEarlierDates ? (\n <Button\n size=\"small\"\n onClick={() =>\n moveStrip(stripStart - BOOKING_SLOT_PAGE_DAYS)\n }\n >\n {'Earlier dates'}\n </Button>\n ) : null}\n {hasLaterDates ? (\n <Button\n size=\"small\"\n disabled={\n loadingMore && visibleDays.length < BOOKING_SLOT_PAGE_DAYS\n }\n // Never past the last day loaded: a strip that was short\n // moves on from where it ended, so no day is stepped over\n // while the next page loads.\n onClick={() =>\n moveStrip(\n Math.min(\n stripStart + BOOKING_SLOT_PAGE_DAYS,\n days.length,\n ),\n )\n }\n >\n {'Later dates'}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n {day !== null ? (\n <Stack spacing={1.5}>\n {dayTimes.map((part) => (\n <Stack key={part.label} spacing={0.75}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {part.label}\n </Typography>\n <Box\n role=\"group\"\n aria-label={`${part.label} times`}\n sx={{\n display: 'grid',\n gridTemplateColumns:\n 'repeat(auto-fill, minmax(5.5rem, 1fr))',\n gap: 1,\n }}\n >\n {part.slots.map((slot) => (\n <Chip\n key={slot.startsAtMs}\n label={bookingTimeLabel(slot.startsAtMs)}\n color={\n slotMs === slot.startsAtMs ? 'primary' : 'default'\n }\n variant={\n slotMs === slot.startsAtMs ? 'filled' : 'outlined'\n }\n onClick={() => setSlotMs(slot.startsAtMs)}\n />\n ))}\n </Box>\n </Stack>\n ))}\n </Stack>\n ) : null}\n </>\n )\n ) : null}\n {slotMs ? (\n <Stack spacing={1.5}>\n <TextField\n label=\"Your name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n size=\"small\"\n fullWidth\n />\n <TextField\n label=\"Email\"\n type=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n size=\"small\"\n fullWidth\n />\n {asks.phone !== 'off' ? (\n <TextField\n label=\"Phone\"\n type=\"tel\"\n autoComplete=\"tel\"\n required={asks.phone === 'required'}\n value={phone}\n onChange={(event) => setPhone(event.target.value)}\n error={phoneUnreadable}\n helperText={phoneUnreadable ? 'Enter a valid phone number' : undefined}\n size=\"small\"\n fullWidth\n />\n ) : null}\n {asks.address !== 'off' ? (\n <TextField\n label=\"Address\"\n autoComplete=\"street-address\"\n required={asks.address === 'required'}\n value={address}\n onChange={(event) => setAddress(event.target.value)}\n size=\"small\"\n fullWidth\n multiline\n minRows={2}\n />\n ) : null}\n <FormControlLabel\n control={\n <Checkbox\n size=\"small\"\n checked={marketingConsent}\n onChange={(event) =>\n setMarketingConsent(event.target.checked)\n }\n />\n }\n label=\"Email me about offers and updates\"\n />\n {errorMessage ? <Alert severity=\"error\">{errorMessage}</Alert> : null}\n <Button\n variant=\"contained\"\n disabled={\n !name.trim() ||\n !email.trim() ||\n !contactCheck.ok ||\n status === 'booking'\n }\n onClick={handleBook}\n sx={{ alignSelf: 'flex-start' }}\n >\n {status === 'booking' ? 'Booking…' : 'Confirm booking'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n )\n})\nBooking.displayName = 'Booking'\n\nexport const schema: Aglyn.ComponentSchema<BookingProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Booking',\n description:\n 'Service and time picker that books appointments. It is double-booking safe.',\n category: Aglyn.ComponentCategory.INPUT,\n icon: {\n path: mdiCalendarClock.path,\n sx: { color: '#0288d1' },\n },\n flags: {\n selfClosing: Aglyn.FEATURE_FLAG.ENABLED,\n },\n attributes: [\n {\n name: 'heading',\n description: 'Heading above the widget; empty hides it.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Heading',\n },\n {\n name: 'successMessage',\n description: 'Shown after a confirmed booking.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Success message',\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Booking',\n pluginId: BUNDLE_ID,\n description: 'Service + time picker that books appointments',\n category: Aglyn.ComponentCategory.INPUT,\n icon: {\n path: mdiCalendarClock.path,\n sx: { color: '#0288d1' },\n },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default Booking\n"],"names":["Aglyn","buildBeginCheckoutParams","trackEvent","trackEventBeforeNavigation","recordSiteJourneyStep","utmTouchField","mdiCalendarClock","Alert","Box","Button","Checkbox","Chip","CircularProgress","FormControlLabel","MenuItem","Stack","TextField","Typography","forwardRef","useCallback","useEffect","useMemo","useRef","useState","BUNDLE_ID","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","parseBookingRecordRef","bookingContactAsks","readBookingContactFields","readBookingPhone","bookingPriceDisplay","bookingPriceText","BOOKING_MAX_DAYS_AHEAD","BOOKING_SLOT_PAGE_DAYS","generatePresetId","useBookingPurchaseEvent","ID","readLinkParam","key","window","URLSearchParams","location","search","get","groupSlotsByLocalDay","slots","nextFromMs","byDay","Map","slot","at","Date","startsAtMs","startMs","getFullYear","getMonth","getDate","getTime","day","push","set","entries","map","daySlots","sort","a","b","filter","start","nextDayMs","bookingDayLabel","toLocaleDateString","undefined","weekday","month","bookingTimeLabel","toLocaleTimeString","hour","minute","partsOfDay","parts","label","getHours","part","length","Booking","props","ref","heading","successMessage","rest","nodeSx","Array","isArray","hostId","useSite","siteFetch","useSiteFetch","services","setServices","serviceId","setServiceId","setSlots","setNextFromMs","horizonDays","setHorizonDays","loadingMore","setLoadingMore","stripStart","setStripStart","slotsServiceRef","slotMs","setSlotMs","name","setName","email","setEmail","phone","setPhone","address","setAddress","marketingConsent","setMarketingConsent","status","setStatus","errorMessage","setErrorMessage","alerts","setAlerts","active","fetch","encodeURIComponent","then","response","json","payload","loaded","wanted","some","one","$id","catch","fetchSlotPage","forService","fromMs","String","current","page","seen","Set","has","Number","days","visibleDays","slice","finally","setDay","dayTimes","find","hasEarlierDates","hasLaterDates","moveStrip","toStart","Math","max","asks","contactCheck","phoneUnreadable","Boolean","trim","handleBook","recordRef","method","headers","body","JSON","stringify","ok","fields","crmRef","error","checkoutUrl","service","items","item_id","item_name","price","priceUsd","quantity","assign","form_name","form_location","pathname","sx","p","border","borderColor","borderRadius","color","fontSize","fontFamily","spacing","severity","alert","index","message","selected","variant","size","select","value","onChange","event","target","fullWidth","durationMinutes","priceDisplay","description","direction","flexWrap","rowGap","onClick","alignSelf","disabled","min","role","aria-label","display","gridTemplateColumns","gap","type","autoComplete","required","helperText","multiline","minRows","control","checked","displayName","schema","pluginId","category","ComponentCategory","INPUT","icon","path","flags","selfClosing","FEATURE_FLAG","ENABLED","attributes","component","FieldComponentType","TEXT_FIELD","presets","NodeType","PRESET","data","componentId"],"mappings":";;;AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SACEC,wBAAwB,EACxBC,UAAU,EACVC,0BAA0B,QACrB,0CAAyC;AAChD,SAASC,qBAAqB,QAAQ,sCAAqC;AAC3E,SAASC,aAAa,QAAQ,mCAAkC;AAChE,SAASC,gBAAgB,QAAQ,yBAAwB;AACzD,OAAOC,WAAW,sBAAqB;AACvC,OAAOC,SAAS,oBAAmB;AACnC,OAAOC,YAAY,uBAAsB;AACzC,OAAOC,cAAc,yBAAwB;AAC7C,OAAOC,UAAU,qBAAoB;AACrC,OAAOC,sBAAsB,iCAAgC;AAC7D,OAAOC,sBAAsB,iCAAgC;AAC7D,OAAOC,cAAc,yBAAwB;AAC7C,OAAOC,WAAW,sBAAqB;AACvC,OAAOC,eAAe,0BAAyB;AAC/C,OAAOC,gBAAgB,2BAA0B;AACjD,SACEC,UAAU,EACVC,WAAW,EACXC,SAAS,EACTC,OAAO,EACPC,MAAM,EACNC,QAAQ,QACH,QAAO;AACd,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,sBAAsB,EACtBC,qBAAqB,QAChB,6BAAyB;AAChC,SACEC,kBAAkB,EAClBC,wBAAwB,EACxBC,gBAAgB,QACX,qCAAiC;AACxC,SAASC,mBAAmB,EAAEC,gBAAgB,QAAQ,4BAAwB;AAC9E,SAASC,sBAAsB,EAAEC,sBAAsB,QAAQ,uBAAmB;AAClF,SAASC,gBAAgB,QAAQ,iCAA6B;AAC9D,SAASC,uBAAuB,QAAQ,yCAAqC;AAE7E,iEAAiE;AACjE,OAAO,MAAMC,KAAwB,UAAS;AAqB9C;;;;;;;;CAQC,GACD,SAASC,cAAcC,GAAW;IAChC,IAAI,OAAOC,WAAW,aAAa,OAAO;IAC1C,OAAO,IAAIC,gBAAgBD,OAAOE,QAAQ,CAACC,MAAM,EAAEC,GAAG,CAACL;AACzD;AAcA;;;;;;;;CAQC,GACD,OAAO,SAASM,qBACdC,KAAmB,EACnBC,UAAyB;IAEzB,MAAMC,QAAQ,IAAIC;IAClB,KAAK,MAAMC,QAAQJ,MAAO;QACxB,MAAMK,KAAK,IAAIC,KAAKF,KAAKG,UAAU;QACnC,MAAMC,UAAU,IAAIF,KAClBD,GAAGI,WAAW,IACdJ,GAAGK,QAAQ,IACXL,GAAGM,OAAO,IACVC,OAAO;QACT,MAAMC,MAAMX,MAAMJ,GAAG,CAACU;QACtB,IAAIK,KAAKA,IAAIC,IAAI,CAACV;aACbF,MAAMa,GAAG,CAACP,SAAS;YAACJ;SAAK;IAChC;IACA,OAAO;WAAIF,MAAMc,OAAO;KAAG,CACxBC,GAAG,CAAC,CAAC,CAACT,SAASU,SAAS,GAAM,CAAA;YAC7BV;YACAR,OAAOkB,SAASC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEb,UAAU,GAAGc,EAAEd,UAAU;QAC5D,CAAA,GACCe,MAAM,CAAC,CAACT;QACP,IAAIZ,eAAe,MAAM,OAAO;QAChC,MAAMsB,QAAQ,IAAIjB,KAAKO,IAAIL,OAAO;QAClC,MAAMgB,YAAY,IAAIlB,KACpBiB,MAAMd,WAAW,IACjBc,MAAMb,QAAQ,IACda,MAAMZ,OAAO,KAAK,GAClBC,OAAO;QACT,OAAOY,aAAavB;IACtB,GACCkB,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEZ,OAAO,GAAGa,EAAEb,OAAO;AACzC;AAEA,8DAA8D,GAC9D,OAAO,SAASiB,gBAAgBjB,OAAe;IAC7C,OAAO,IAAIF,KAAKE,SAASkB,kBAAkB,CAACC,WAAW;QACrDC,SAAS;QACTC,OAAO;QACPhB,KAAK;IACP;AACF;AAEA,4DAA4D,GAC5D,OAAO,SAASiB,iBAAiBvB,UAAkB;IACjD,OAAO,IAAID,KAAKC,YAAYwB,kBAAkB,CAAC,EAAE,EAAE;QACjDC,MAAM;QACNC,QAAQ;IACV;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASC,WACdlC,KAAmB;IAEnB,MAAMmC,QAAQ;QACZ;YAAEC,OAAO;YAAWpC,OAAO,EAAE;QAAiB;QAC9C;YAAEoC,OAAO;YAAapC,OAAO,EAAE;QAAiB;QAChD;YAAEoC,OAAO;YAAWpC,OAAO,EAAE;QAAiB;KAC/C;IACD,KAAK,MAAMI,QAAQJ,MAAO;QACxB,MAAMgC,OAAO,IAAI1B,KAAKF,KAAKG,UAAU,EAAE8B,QAAQ;QAC/CF,KAAK,CAACH,OAAO,KAAK,IAAIA,OAAO,KAAK,IAAI,EAAE,CAAChC,KAAK,CAACc,IAAI,CAACV;IACtD;IACA,OAAO+B,MAAMb,MAAM,CAAC,CAACgB,OAASA,KAAKtC,KAAK,CAACuC,MAAM,GAAG;AACpD;AAEA;;;;;CAKC,GACD,MAAMC,wBAAUrE,WAAyC,CAACsE,OAAOC;IAC/D,MAAM,EAAEC,OAAO,EAAEC,cAAc,EAAW,GAAGH,OAATI,wCAASJ;;;;IAC7C,yEAAyE;IACzE,MAAMK,SAASC,MAAMC,OAAO,CAACP,KAAK,CAAC,KAAK,IAAIA,KAAK,CAAC,KAAK,GAAG;QAACA,KAAK,CAAC,KAAK;KAAC;IACvE,MAAM,EAAEQ,MAAM,EAAE,GAAGhG,MAAMiG,OAAO;IAChC,MAAMC,YAAYlG,MAAMmG,YAAY;IAEpC,2EAA2E;IAC3E,0EAA0E;IAC1E,wEAAwE;IACxE,mEAAmE;IACnE9D,wBAAwB2D,QAAQE;IAEhC,MAAM,CAACE,UAAUC,YAAY,GAAG9E,SAAiC;IACjE,MAAM,CAAC+E,WAAWC,aAAa,GAAGhF,SAAS;IAC3C,gEAAgE;IAChE,MAAM,CAACwB,OAAOyD,SAAS,GAAGjF,SAA8B;IACxD,iEAAiE;IACjE,MAAM,CAACyB,YAAYyD,cAAc,GAAGlF,SAAwB;IAC5D,MAAM,CAACmF,aAAaC,eAAe,GAAGpF,SAASW;IAC/C,MAAM,CAAC0E,aAAaC,eAAe,GAAGtF,SAAS;IAC/C,0EAA0E;IAC1E,MAAM,CAACuF,YAAYC,cAAc,GAAGxF,SAAS;IAC7C,yEAAyE;IACzE,0EAA0E;IAC1E,MAAMyF,kBAAkB1F,OAAO;IAC/B,MAAM,CAAC2F,QAAQC,UAAU,GAAG3F,SAAwB;IACpD,MAAM,CAAC4F,MAAMC,QAAQ,GAAG7F,SAAS;IACjC,MAAM,CAAC8F,OAAOC,SAAS,GAAG/F,SAAS;IACnC,MAAM,CAACgG,OAAOC,SAAS,GAAGjG,SAAS;IACnC,MAAM,CAACkG,SAASC,WAAW,GAAGnG,SAAS;IACvC,MAAM,CAACoG,kBAAkBC,oBAAoB,GAAGrG,SAAS;IACzD,MAAM,CAACsG,QAAQC,UAAU,GAAGvG,SAE1B;IACF,MAAM,CAACwG,cAAcC,gBAAgB,GAAGzG,SAAwB;IAChE,MAAM,CAAC0G,QAAQC,UAAU,GAAG3G,SAE1B,EAAE;IAEJH,UAAU;QACR,IAAI,CAAC4E,QAAQ;QACb,IAAImC,SAAS;QACb,KAAKC,MAAM,CAAC,2BAA2B,EAAEC,mBAAmBrC,SAAS,EAClEsC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,IAChCF,IAAI,CAAC,CAACG;;YACL,IAAI,CAACN,QAAQ;YACb,MAAMO,iBAA0BD,2BAAAA,QAASrC,QAAQ,mBAAI,EAAE;YACvDC,YAAYqC;YACZ,kEAAkE;YAClE,+DAA+D;YAC/D,+DAA+D;YAC/D,MAAMC,SAASpG,cAAcb;YAC7B,IAAIiH,UAAUD,OAAOE,IAAI,CAAC,CAACC,MAAQA,IAAIC,GAAG,KAAKH,SAAS;gBACtDpC,aAAaoC;YACf;QACF,GACCI,KAAK,CAAC;YACL,IAAIZ,QAAQ9B,YAAY,EAAE;QAC5B;QACF,OAAO;YACL8B,SAAS;QACX;IACF,GAAG;QAACnC;KAAO;IAEX;;;;GAIC,GACD,MAAMgD,gBAAgB7H,YACpB,OAAO8H,YAAoBC;QACzB,MAAMX,WAAW,MAAMH,MACrB,CAAC,2BAA2B,EAAEC,mBAAmBrC,iBAAAA,SAAU,KAAK,GAC9D,CAAC,WAAW,EAAEqC,mBAAmBY,aAAa,GAC7CC,CAAAA,WAAW,OAAO,CAAC,MAAM,EAAEb,mBAAmBc,OAAOD,UAAU,GAAG,EAAC;QAExE,MAAMT,UAAU,MAAMF,SAASC,IAAI;QACnC,IAAIxB,gBAAgBoC,OAAO,KAAKH,YAAY;QAC5C,MAAMI,OAAqBvD,MAAMC,OAAO,CAAC0C,2BAAAA,QAAS1F,KAAK,IACnD0F,QAAQ1F,KAAK,GACb,EAAE;QACNyD,SAAS,CAACkC;YACR,MAAMY,OAAO,IAAIC,IAAI,CAACb,iBAAAA,SAAU,EAAE,EAAE1E,GAAG,CAAC,CAACb,OAASA,KAAKG,UAAU;YACjE,OAAO;mBACDoF,iBAAAA,SAAU,EAAE;mBACbW,KAAKhF,MAAM,CAAC,CAAClB,OAAS,CAACmG,KAAKE,GAAG,CAACrG,KAAKG,UAAU;aACnD;QACH;QACAmD,cACE,QAAOgC,2BAAAA,QAASzF,UAAU,MAAK,WAAWyF,QAAQzF,UAAU,GAAG;QAEjE,IAAIyG,OAAOhB,2BAAAA,QAAS/B,WAAW,IAAI,GAAG;YACpCC,eAAe8C,OAAOhB,QAAQ/B,WAAW;QAC3C;IACF,GACA;QAACV;KAAO;IAGV5E,UAAU;QACR4F,gBAAgBoC,OAAO,GAAG9C;QAC1BE,SAAS;QACTC,cAAc;QACdM,cAAc;QACdG,UAAU;QACV,IAAI,CAAClB,UAAU,CAACM,WAAW;QAC3B0C,cAAc1C,WAAW,MAAMyC,KAAK,CAAC;YACnC,IAAI/B,gBAAgBoC,OAAO,KAAK9C,WAAWE,SAAS,EAAE;QACxD;IACF,GAAG;QAACR;QAAQM;QAAW0C;KAAc;IAErC,MAAMU,OAAOrI,QACX,IAAMyB,qBAAqBC,gBAAAA,QAAS,EAAE,EAAEC,aACxC;QAACD;QAAOC;KAAW;IAErB,MAAM2G,cAAcD,KAAKE,KAAK,CAAC9C,YAAYA,aAAa3E;IAExD,0EAA0E;IAC1E,yEAAyE;IACzE,0CAA0C;IAC1Cf,UAAU;QACR,IAAI2B,UAAU,QAAQC,eAAe,QAAQ4D,aAAa;QAC1D,IAAI8C,KAAKpE,MAAM,IAAIwB,aAAa3E,wBAAwB;QACxD,IAAI2E,eAAe,KAAK4C,KAAKpE,MAAM,GAAG,GAAG;QACzC,MAAM2D,aAAa3C;QACnBO,eAAe;QACfmC,cAAcC,YAAYjG,YACvB+F,KAAK,CAAC;YACL,+DAA+D;YAC/D,yBAAyB;YACzB,IAAI/B,gBAAgBoC,OAAO,KAAKH,YAAYxC,cAAc;QAC5D,GACCoD,OAAO,CAAC,IAAMhD,eAAe;IAClC,GAAG;QACD9D;QACAC;QACA4D;QACA8C,KAAKpE,MAAM;QACXwB;QACAR;QACA0C;KACD;IAED,MAAM,CAACpF,KAAKkG,OAAO,GAAGvI,SAAwB;IAC9CH,UAAU;QACR0I,OAAO;IACT,GAAG;QAACxD;KAAU;IACd,MAAMyD,WAAW1I,QACf;;YAAiBqI;eAAXzE,oBAAWyE,aAAAA,KAAKM,IAAI,CAAC,CAACnB,MAAQA,IAAItF,OAAO,KAAKK,yBAAnC8F,WAAyC3G,KAAK,mBAAI,EAAE;OACrE;QAAC2G;QAAM9F;KAAI;IAEb,MAAMqG,kBAAkBnD,aAAa;IACrC,MAAMoD,gBACJR,KAAKpE,MAAM,GAAGwB,aAAa3E,0BAA0Ba,eAAe;IACtE,MAAMmH,YAAY,CAACC;QACjBrD,cAAcsD,KAAKC,GAAG,CAAC,GAAGF;QAC1BN,OAAO;QACP5C,UAAU;IACZ;IAEA,mEAAmE;IACnE,0EAA0E;IAC1E,uEAAuE;IACvE,MAAMqD,OAAO1I,mBACX,CAACuE,mBAAAA,WAAY,EAAE,EAAE4D,IAAI,CAAC,CAACnB,MAAQA,IAAIC,GAAG,KAAKxC;IAE7C,MAAMkE,eAAe1I,yBAAyByI,MAAM;QAAEhD;QAAOE;IAAQ;IACrE,MAAMgD,kBACJF,KAAKhD,KAAK,KAAK,SAASmD,QAAQnD,MAAMoD,IAAI,OAAO,CAAC5I,iBAAiBwF;IAErE,MAAMqD,aAAazJ,YAAY;QAC7B,IAAI,CAAC6E,UAAU,CAACM,aAAa,CAACW,UAAUY,WAAW,WAAW;QAC9DC,UAAU;QACVE,gBAAgB;QAChB,sEAAsE;QACtE,wEAAwE;QACxE,uEAAuE;QACvE,2BAA2B;QAC3B,MAAM6C,YAAYjJ,sBAAsBW,cAAcd;QACtD,IAAI;YACF,MAAM8G,WAAW,MAAMrC,UAAU,sBAAsB;gBACrD4E,QAAQ;gBACRC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBACnBlF;oBACAM;oBACAhD,YAAY2D;oBACZE;oBACAE;mBAEImD,aAAaW,EAAE,GAAGX,aAAaY,MAAM,GAAG,CAAC,GACzCP,YAAY;oBAAEQ,QAAQ1J,uBAAuBkJ;gBAAW,IAAI,CAAC,GAC7DlD,mBAAmB;oBAAEA,kBAAkB;gBAAK,IAAI,CAAC,GAIlDtH;YAEP;YACA,MAAMoI,UAAU,MAAMF,SAASC,IAAI,GAAGO,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;YACpD,IAAI,CAACR,SAAS4C,EAAE,EAAE;;gBAChBnD,wBAAgBS,2BAAAA,QAAS6C,KAAK,mBAAI;gBAClCxD,UAAU;gBACV;YACF;YACA,IAAIW,2BAAAA,QAAS8C,WAAW,EAAE;;gBACxB,gEAAgE;gBAChE,0BAA0B;gBAC1B,EAAE;gBACF,sEAAsE;gBACtE,oEAAoE;gBACpE,sEAAsE;gBACtE,sEAAsE;gBACtE,+DAA+D;gBAC/D,4CAA4C;gBAC5C,EAAE;gBACF,mEAAmE;gBACnE,mEAAmE;gBACnE,EAAE;gBACF,+CAA+C;gBAC/C,qEAAqE;gBACrE,oEAAoE;gBACpE,iBAAiB;gBACjB,MAAMC,UAAU,CAACpF,mBAAAA,WAAY,EAAE,EAAE4D,IAAI,CAAC,CAACnB,MAAQA,IAAIC,GAAG,KAAKxC;gBAC3D,MAAMnG,2BACJ,kBACAF,yBAAyB;oBACvBwL,OAAO;wBACL;4BACEC,SAASpF;4BACTqF,SAAS,WAAEH,2BAAAA,QAASrE,IAAI,oBAAI;4BAC5ByE,KAAK,EAAEJ,2BAAAA,QAASK,QAAQ;4BACxBC,UAAU;wBACZ;qBACD;gBACH;gBAEFrJ,OAAOE,QAAQ,CAACoJ,MAAM,CAACtD,QAAQ8C,WAAW;gBAC1C;YACF;YACA,IAAIzF,MAAMC,OAAO,CAAC0C,2BAAAA,QAASR,MAAM,GAAGC,UAAUO,QAAQR,MAAM;YAC5D,sEAAsE;YACtE,wEAAwE;YACxE,8DAA8D;YAC9D,EAAE;YACF,kEAAkE;YAClE,oEAAoE;YACpE,uEAAuE;YACvE,uEAAuE;YACvE,wEAAwE;YACxE,+DAA+D;YAC/D,EAAE;YACF,qEAAqE;YACrE,mEAAmE;YACnE,6BAA6B;YAC7B,oEAAoE;YACpE,8DAA8D;YAC9D7H,sBAAsB,WAAWkG;YACjCpG,WAAW,iBAAiB;gBAC1B8L,WAAW;gBACXC,eAAexJ,OAAOE,QAAQ,CAACuJ,QAAQ;YACzC;YACApE,UAAU;QACZ,EAAE,eAAM;YACNE,gBAAgB;YAChBF,UAAU;QACZ;IACF,GAAG;QACD9B;QACAM;QACAW;QACAE;QACAE;QACAmD;QACA7C;QACAE;QACA3B;QACA,oEAAoE;QACpE,uEAAuE;QACvE,wEAAwE;QACxE,6CAA6C;QAC7CE;KACD;IAED,IAAI,CAACJ,QAAQ;QACX,qBACE,KAACxF;YACCiF,KAAKA;WACDG;YACJuG,IAAI;gBACF;oBACEC,GAAG;oBACHC,QAAQ;oBACRC,aAAa;oBACbC,cAAc;oBACdC,OAAO;oBACPC,UAAU;oBACVC,YAAY;gBACd;mBACG7G;aACJ;sBAEA;;IAGP;IAEA,IAAIgC,WAAW,UAAU;QACvB,qBACE,MAAC9G;YAAM0E,KAAKA;YAAKkH,SAAS;WAAS/G;;8BACjC,KAACrF;oBAAMqM,UAAS;8BACbjH,kBACC;;gBAEHsC,OAAOjE,GAAG,CAAC,CAAC6I,OAAOC,sBAClB,KAACvM;wBAAkBqM,UAAU,AAACC,MAAMD,QAAQ,IAAY;kCACrDC,MAAME,OAAO;uBADJD;;;IAMpB;IAEA,MAAME,WAAW,CAAC5G,mBAAAA,WAAY,EAAE,EAAE4D,IAAI,CACpC,CAACwB,UAAYA,QAAQ1C,GAAG,KAAKxC;IAG/B,qBACE,MAACvF;QAAM0E,KAAKA;QAAKkH,SAAS;OAAO/G;;YAC9BF,wBAAU,KAACzE;gBAAWgM,SAAQ;0BAAMvH;iBAAwB;YAC5DU,aAAa,qBACZ,KAACxF;gBAAiBsM,MAAM;iBACtB9G,SAASd,MAAM,KAAK,kBACtB,KAACrE;gBAAWgM,SAAQ;gBAAQT,OAAM;0BAC/B;+BAGH,KAACxL;gBACCmM,MAAM;gBACNhI,OAAM;gBACNiI,OAAO9G;gBACP+G,UAAU,CAACC,QAAU/G,aAAa+G,MAAMC,MAAM,CAACH,KAAK;gBACpDF,MAAK;gBACLM,SAAS;0BAERpH,SAASpC,GAAG,CAAC,CAACwH,wBACb,KAAC1K;wBAA2BsM,OAAO5B,QAAQ1C,GAAG;kCAC3C,GAAG0C,QAAQrE,IAAI,CAAC,GAAG,EAAEqE,QAAQiC,eAAe,CAAC,IAAI,CAAC,GAChDzL,CAAAA,oBAAoBwJ,QAAQkC,YAAY,MAAM,WAAWlC,QAAQK,QAAQ,GAAG,IACzE,CAAC,GAAG,EAAE5J,iBAAiBuJ,UAAU,GACjC,EAAC;uBAJMA,QAAQ1C,GAAG;;YAS/BkE,CAAAA,4BAAAA,SAAUW,WAAW,kBACpB,KAAC1M;gBAAWgM,SAAQ;gBAAQT,OAAM;0BAC/BQ,SAASW,WAAW;iBAErB;YACHrH,YACCvD,UAAU,QAAS2G,KAAKpE,MAAM,KAAK,KAAKtC,eAAe,qBACrD,KAACpC;gBAAiBsM,MAAM;iBACtBxD,KAAKpE,MAAM,KAAK,kBAClB,KAACrE;gBAAWgM,SAAQ;gBAAQT,OAAM;0BAC/B,CAAC,0BAA0B,EAAE9F,YAAY,MAAM,CAAC;+BAGnD;;kCACE,MAAC3F;wBACC6M,WAAU;wBACVjB,SAAS;wBACTR,IAAI;4BAAE0B,UAAU;4BAAQC,QAAQ;wBAAE;;4BAEjCnE,YAAY3F,GAAG,CAAC,CAAC6E,oBAChB,KAAClI;oCAECwE,OAAOX,gBAAgBqE,IAAItF,OAAO;oCAClCiJ,OAAO5I,QAAQiF,IAAItF,OAAO,GAAG,YAAY;oCACzC0J,SAASrJ,QAAQiF,IAAItF,OAAO,GAAG,WAAW;oCAC1CwK,SAAS;wCACPjE,OAAOjB,IAAItF,OAAO;wCAClB2D,UAAU;oCACZ;mCAPK2B,IAAItF,OAAO;4BAUnBqD,eAAe+C,YAAYrE,MAAM,GAAGnD,uCACnC,KAACvB;gCAAiBsM,MAAM;gCAAIf,IAAI;oCAAE6B,WAAW;gCAAS;iCACpD;;;oBAEL/D,mBAAmBC,8BAClB,MAACnJ;wBAAM6M,WAAU;wBAAMjB,SAAS;;4BAC7B1C,gCACC,KAACxJ;gCACCyM,MAAK;gCACLa,SAAS,IACP5D,UAAUrD,aAAa3E;0CAGxB;iCAED;4BACH+H,8BACC,KAACzJ;gCACCyM,MAAK;gCACLe,UACErH,eAAe+C,YAAYrE,MAAM,GAAGnD;gCAEtC,yDAAyD;gCACzD,0DAA0D;gCAC1D,6BAA6B;gCAC7B4L,SAAS,IACP5D,UACEE,KAAK6D,GAAG,CACNpH,aAAa3E,wBACbuH,KAAKpE,MAAM;0CAKhB;iCAED;;yBAEJ;oBACH1B,QAAQ,qBACP,KAAC7C;wBAAM4L,SAAS;kCACb5C,SAAS/F,GAAG,CAAC,CAACqB,qBACb,MAACtE;gCAAuB4L,SAAS;;kDAC/B,KAAC1L;wCAAWgM,SAAQ;wCAAUT,OAAM;kDACjCnH,KAAKF,KAAK;;kDAEb,KAAC3E;wCACC2N,MAAK;wCACLC,cAAY,GAAG/I,KAAKF,KAAK,CAAC,MAAM,CAAC;wCACjCgH,IAAI;4CACFkC,SAAS;4CACTC,qBACE;4CACFC,KAAK;wCACP;kDAEClJ,KAAKtC,KAAK,CAACiB,GAAG,CAAC,CAACb,qBACf,KAACxC;gDAECwE,OAAON,iBAAiB1B,KAAKG,UAAU;gDACvCkJ,OACEvF,WAAW9D,KAAKG,UAAU,GAAG,YAAY;gDAE3C2J,SACEhG,WAAW9D,KAAKG,UAAU,GAAG,WAAW;gDAE1CyK,SAAS,IAAM7G,UAAU/D,KAAKG,UAAU;+CARnCH,KAAKG,UAAU;;;+BAhBhB+B,KAAKF,KAAK;yBA+BxB;;iBAGN;YACH8B,uBACC,MAAClG;gBAAM4L,SAAS;;kCACd,KAAC3L;wBACCmE,OAAM;wBACNiI,OAAOjG;wBACPkG,UAAU,CAACC,QAAUlG,QAAQkG,MAAMC,MAAM,CAACH,KAAK;wBAC/CF,MAAK;wBACLM,SAAS;;kCAEX,KAACxM;wBACCmE,OAAM;wBACNqJ,MAAK;wBACLpB,OAAO/F;wBACPgG,UAAU,CAACC,QAAUhG,SAASgG,MAAMC,MAAM,CAACH,KAAK;wBAChDF,MAAK;wBACLM,SAAS;;oBAEVjD,KAAKhD,KAAK,KAAK,sBACd,KAACvG;wBACCmE,OAAM;wBACNqJ,MAAK;wBACLC,cAAa;wBACbC,UAAUnE,KAAKhD,KAAK,KAAK;wBACzB6F,OAAO7F;wBACP8F,UAAU,CAACC,QAAU9F,SAAS8F,MAAMC,MAAM,CAACH,KAAK;wBAChD9B,OAAOb;wBACPkE,YAAYlE,kBAAkB,+BAA+B/F;wBAC7DwI,MAAK;wBACLM,SAAS;yBAET;oBACHjD,KAAK9C,OAAO,KAAK,sBAChB,KAACzG;wBACCmE,OAAM;wBACNsJ,cAAa;wBACbC,UAAUnE,KAAK9C,OAAO,KAAK;wBAC3B2F,OAAO3F;wBACP4F,UAAU,CAACC,QAAU5F,WAAW4F,MAAMC,MAAM,CAACH,KAAK;wBAClDF,MAAK;wBACLM,SAAS;wBACToB,SAAS;wBACTC,SAAS;yBAET;kCACJ,KAAChO;wBACCiO,uBACE,KAACpO;4BACCwM,MAAK;4BACL6B,SAASpH;4BACT0F,UAAU,CAACC,QACT1F,oBAAoB0F,MAAMC,MAAM,CAACwB,OAAO;;wBAI9C5J,OAAM;;oBAEP4C,6BAAe,KAACxH;wBAAMqM,UAAS;kCAAS7E;yBAAwB;kCACjE,KAACtH;wBACCwM,SAAQ;wBACRgB,UACE,CAAC9G,KAAKwD,IAAI,MACV,CAACtD,MAAMsD,IAAI,MACX,CAACH,aAAaW,EAAE,IAChBtD,WAAW;wBAEbkG,SAASnD;wBACTuB,IAAI;4BAAE6B,WAAW;wBAAa;kCAE7BnG,WAAW,YAAY,aAAa;;;iBAGvC;;;AAGV;AACAtC,QAAQyJ,WAAW,GAAG;AAEtB,OAAO,MAAMC,SAA8C;IACzDnG,KAAKxG;IACL4M,UAAU1N;IACVwN,aAAa;IACbrB,aACE;IACFwB,UAAUnP,MAAMoP,iBAAiB,CAACC,KAAK;IACvCC,MAAM;QACJC,MAAMjP,iBAAiBiP,IAAI;QAC3BpD,IAAI;YAAEK,OAAO;QAAU;IACzB;IACAgD,OAAO;QACLC,aAAazP,MAAM0P,YAAY,CAACC,OAAO;IACzC;IACAC,YAAY;QACV;YACEzI,MAAM;YACNwG,aAAa;YACbkC,WAAW7P,MAAM8P,kBAAkB,CAACC,UAAU;YAC9C5K,OAAO;QACT;QACA;YACEgC,MAAM;YACNwG,aAAa;YACbkC,WAAW7P,MAAM8P,kBAAkB,CAACC,UAAU;YAC9C5K,OAAO;QACT;KACD;AACH,EAAC;AAED,OAAO,MAAM6K,UAAgC;IAC3C;QACElH,KAAK1G,iBAAiBE;QACtBkM,MAAMxO,MAAMiQ,QAAQ,CAACC,MAAM;QAC3BlB,aAAa;QACbE,UAAU1N;QACVmM,aAAa;QACbwB,UAAUnP,MAAMoP,iBAAiB,CAACC,KAAK;QACvCC,MAAM;YACJC,MAAMjP,iBAAiBiP,IAAI;YAC3BpD,IAAI;gBAAEK,OAAO;YAAU;QACzB;QACA2D,MAAM;YACJrH,KAAK;YACLsH,aAAa9N;YACb4M,UAAU1N;YACVgE,OAAO,CAAC;QACV;IACF;CACD,CAAA;AAED,eAAeD,QAAO"}
|
|
@@ -24,6 +24,7 @@ import { BOOKINGS_TRANSFER_RESOURCE, bookerBookingsFilterValue, upcomingBookings
|
|
|
24
24
|
import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
25
25
|
import { describePaymentRisk } from "@aglyn/aglyn/app-utils/payment-risk";
|
|
26
26
|
import { isBookingReminderDue } from "../model/index.js";
|
|
27
|
+
import { bookingServiceStatus } from "../model/bookings.js";
|
|
27
28
|
import { BOOKING_FIELD_ASKS, bookingContactAsks } from "../model/booking-contact-fields.js";
|
|
28
29
|
import { BOOKING_PRICE_DISPLAYS, BOOKING_PRICE_LABELS, bookingPriceDisplay, bookingPriceText } from "../model/booking-price.js";
|
|
29
30
|
import { AppLink, CardDisplay, HelpTip, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
@@ -326,6 +327,36 @@ function formatWindows(windows) {
|
|
|
326
327
|
servicesStatus,
|
|
327
328
|
servicesFromCache
|
|
328
329
|
]);
|
|
330
|
+
/**
|
|
331
|
+
* A draft is activated here, and only here (AGL-3616): a service a draft
|
|
332
|
+
* writer made is set up and offered nowhere until a person looks at it and
|
|
333
|
+
* says it takes bookings. Deactivating sends a live one back to draft,
|
|
334
|
+
* which takes it off the site without deleting it; bookings it already
|
|
335
|
+
* holds stand. One field, so the seed guard the dialog's whole-document
|
|
336
|
+
* save needs has nothing to protect here.
|
|
337
|
+
*/ const handleServiceStatus = useCallback((service, status)=>async ()=>{
|
|
338
|
+
try {
|
|
339
|
+
await updateDoc(doc(firestore, 'hosts', hostId, 'services', service.$id), {
|
|
340
|
+
status,
|
|
341
|
+
updatedAt: Timestamp.now()
|
|
342
|
+
});
|
|
343
|
+
enqueueSnackbar(status === 'active' ? `${service.name} now takes bookings` : `${service.name} is a draft — it takes no bookings until activated`, {
|
|
344
|
+
variant: 'success',
|
|
345
|
+
persist: false
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
var _ref;
|
|
349
|
+
console.error(error);
|
|
350
|
+
enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'An error has occurred', {
|
|
351
|
+
variant: 'error',
|
|
352
|
+
allowDuplicate: true
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}, [
|
|
356
|
+
firestore,
|
|
357
|
+
hostId,
|
|
358
|
+
enqueueSnackbar
|
|
359
|
+
]);
|
|
329
360
|
const handleDeleteService = useCallback((service)=>async ()=>{
|
|
330
361
|
const confirmed = await confirm({
|
|
331
362
|
title: 'Delete this service?',
|
|
@@ -470,10 +501,27 @@ function formatWindows(windows) {
|
|
|
470
501
|
minWidth: 0
|
|
471
502
|
},
|
|
472
503
|
children: [
|
|
473
|
-
/*#__PURE__*/
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
504
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
505
|
+
direction: "row",
|
|
506
|
+
spacing: 1,
|
|
507
|
+
sx: {
|
|
508
|
+
alignItems: 'center',
|
|
509
|
+
minWidth: 0
|
|
510
|
+
},
|
|
511
|
+
children: [
|
|
512
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
513
|
+
variant: "body2",
|
|
514
|
+
noWrap: true,
|
|
515
|
+
children: service.name
|
|
516
|
+
}),
|
|
517
|
+
bookingServiceStatus(service.status) === 'draft' ? /*#__PURE__*/ _jsx(Tooltip, {
|
|
518
|
+
title: "Set up, and offered nowhere until you activate it",
|
|
519
|
+
children: /*#__PURE__*/ _jsx(Chip, {
|
|
520
|
+
label: "Draft",
|
|
521
|
+
size: "small"
|
|
522
|
+
})
|
|
523
|
+
}) : null
|
|
524
|
+
]
|
|
477
525
|
}),
|
|
478
526
|
/*#__PURE__*/ _jsx(Typography, {
|
|
479
527
|
variant: "caption",
|
|
@@ -510,6 +558,16 @@ function formatWindows(windows) {
|
|
|
510
558
|
},
|
|
511
559
|
children: 'Edit'
|
|
512
560
|
}),
|
|
561
|
+
bookingServiceStatus(service.status) === 'draft' ? /*#__PURE__*/ _jsx(Button, {
|
|
562
|
+
size: "small",
|
|
563
|
+
color: "primary",
|
|
564
|
+
onClick: handleServiceStatus(service, 'active'),
|
|
565
|
+
children: 'Activate'
|
|
566
|
+
}) : /*#__PURE__*/ _jsx(Button, {
|
|
567
|
+
size: "small",
|
|
568
|
+
onClick: handleServiceStatus(service, 'draft'),
|
|
569
|
+
children: 'Deactivate'
|
|
570
|
+
}),
|
|
513
571
|
/*#__PURE__*/ _jsx(Button, {
|
|
514
572
|
size: "small",
|
|
515
573
|
color: "error",
|