@aglyn/plugins-bookings 1.0.0-beta.220 → 1.0.0-beta.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +3 -2
  2. package/package.json +11 -11
  3. package/src/lib/components/book-meeting-action.js +5 -3
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.d.ts +33 -0
  6. package/src/lib/components/booking.js +260 -54
  7. package/src/lib/components/booking.js.map +1 -1
  8. package/src/lib/components/bookings-console-page.js +139 -15
  9. package/src/lib/components/bookings-console-page.js.map +1 -1
  10. package/src/lib/declarations.console-server.d.ts +34 -0
  11. package/src/lib/declarations.console-server.js +84 -0
  12. package/src/lib/declarations.console-server.js.map +1 -0
  13. package/src/lib/model/booking-contact-fields.d.ts +88 -0
  14. package/src/lib/model/booking-contact-fields.js +130 -0
  15. package/src/lib/model/booking-contact-fields.js.map +1 -0
  16. package/src/lib/model/booking-price.d.ts +51 -0
  17. package/src/lib/model/booking-price.js +61 -0
  18. package/src/lib/model/booking-price.js.map +1 -0
  19. package/src/lib/model/booking-record.d.ts +7 -0
  20. package/src/lib/model/booking-record.js +11 -2
  21. package/src/lib/model/booking-record.js.map +1 -1
  22. package/src/lib/model/bookings-record-counts.d.ts +25 -0
  23. package/src/lib/model/bookings-record-counts.js +34 -0
  24. package/src/lib/model/bookings-record-counts.js.map +1 -0
  25. package/src/lib/model/bookings.d.ts +61 -4
  26. package/src/lib/model/bookings.js +81 -15
  27. package/src/lib/model/bookings.js.map +1 -1
  28. package/src/lib/model/index.d.ts +2 -0
  29. package/src/lib/model/index.js +2 -0
  30. package/src/lib/model/index.js.map +1 -1
  31. package/src/lib/plugin.js +14 -0
  32. package/src/lib/plugin.js.map +1 -1
  33. package/src/lib/server/billing-webhook.js +44 -21
  34. package/src/lib/server/billing-webhook.js.map +1 -1
  35. package/src/lib/server/booking-crm.js +4 -1
  36. package/src/lib/server/booking-crm.js.map +1 -1
  37. package/src/lib/server/person-eraser.d.ts +36 -0
  38. package/src/lib/server/person-eraser.js +48 -0
  39. package/src/lib/server/person-eraser.js.map +1 -0
  40. package/src/lib/server/refund.js +2 -2
  41. package/src/lib/server/refund.js.map +1 -1
  42. package/src/lib/server.js +97 -36
  43. package/src/lib/server.js.map +1 -1
  44. package/src/lib/transfer/bookings-transfer-common.d.ts +68 -0
  45. package/src/lib/transfer/bookings-transfer-common.js +130 -0
  46. package/src/lib/transfer/bookings-transfer-common.js.map +1 -0
  47. package/src/lib/transfer/bookings-transfer.d.ts +86 -0
  48. package/src/lib/transfer/bookings-transfer.js +613 -0
  49. package/src/lib/transfer/bookings-transfer.js.map +1 -0
  50. package/src/lib/transfer/bookings-transfer.server.d.ts +26 -0
  51. package/src/lib/transfer/bookings-transfer.server.js +31 -0
  52. package/src/lib/transfer/bookings-transfer.server.js.map +1 -0
package/README.md CHANGED
@@ -33,12 +33,13 @@ This package is a first-party Aglyn plugin. It is loaded through Aglyn's plugin
33
33
  - A **Bookings** nav item and page at `/bookings`, behind the `bookings` feature flag. The page is code-split and loads when opened.
34
34
  - A "Book a meeting" widget in the `crmRecordBooking` slot. The CRM plugin hosts that zone on a record; this plugin draws the control in it, so neither package imports the other.
35
35
  - A plugin config schema (`BOOKINGS_CONFIG_SCHEMA`) with two settings: `maxDaysAhead` (the booking horizon in days) and `bookingPath` (the page on the site that holds the Booking block, used to build booking links).
36
+ - A record count source for `booking` (`plugin-record-counts`): how many bookings a site took, which a campaign's conversion report divides by.
36
37
 
37
38
  ### On the server
38
39
 
39
40
  `@aglyn/plugins-bookings/server` imports `firebase-admin` and is kept out of the client entry point.
40
41
 
41
- - `registerBookingsApi()` (the `tenantApi` surface) registers the site-facing routes `bookings/slots` (open slots for a service) and `bookings/book` (take a booking; a paid service goes through Stripe Checkout).
42
+ - `registerBookingsApi()` (the `tenantApi` surface) registers the site-facing routes `bookings/slots` (open slots for a service, a page of `BOOKING_SLOT_PAGE_DAYS` whole days at a time; `from` asks for the page its `nextFromMs` named) and `bookings/book` (take a booking; a paid service goes through Stripe Checkout).
42
43
  - `registerBookingsConsoleApi()` (the `consoleApi` surface) registers `bookings/reminders`, `bookings/refund` and `bookings/booking-analytics`, a handler on the platform billing webhook that confirms paid bookings, and a bookings-by-service figure reader.
43
44
  - Loading the module registers two scheduled plugin jobs: `expire-stale-holds` and `booking-reminders` (a reminder email about a day before a confirmed booking). This is why `package.json` lists `./src/lib/server.*` under `sideEffects`.
44
45
 
@@ -48,7 +49,7 @@ Routes are served by the host app's API dispatcher under `/api/`, for example `/
48
49
 
49
50
  | import | contents |
50
51
  | -- | -- |
51
- | `@aglyn/plugins-bookings` | `BUNDLE_ID`, `registerBookingsConsole`, the site half, and the pure model (`computeOpenSlots`, `isSlotOpen`, `bookingLinkFor`, `normalizeBookingPath`, the `HostBookingService` and `BookingSlot` types) |
52
+ | `@aglyn/plugins-bookings` | `BUNDLE_ID`, `registerBookingsConsole`, the site half, and the pure model (`computeOpenSlots`, `computeOpenSlotPage`, `isSlotOpen`, `bookingLinkFor`, `normalizeBookingPath`, the `HostBookingService` and `BookingSlot` types) |
52
53
  | `@aglyn/plugins-bookings/site` | `registerBookingsPlugin` and `BOOKINGS_BUNDLE` only. This is what a published page loads, so it carries no console code |
53
54
  | `@aglyn/plugins-bookings/server` | `registerBookingsApi`, `registerBookingsConsoleApi`, `slotsHandler`, `bookHandler`, `scanBookingReminders` |
54
55
  | `@aglyn/plugins-bookings/*` | any module under `src/lib/` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-bookings",
3
- "version": "1.0.0-beta.220",
3
+ "version": "1.0.0-beta.222",
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.220",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.220",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.220",
31
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.220",
32
- "@aglyn/shared-util-email": "1.0.0-beta.220",
33
- "@aglyn/shared-util-http": "1.0.0-beta.220",
34
- "@aglyn/shared-util-timestamp": "1.0.0-beta.220",
35
- "@aglyn/tenant-data-admin": "1.0.0-beta.220",
36
- "@aglyn/tenant-feature-instance": "1.0.0-beta.220",
37
- "@aglyn/tenant-runtime": "1.0.0-beta.220",
28
+ "@aglyn/aglyn": "1.0.0-beta.222",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.222",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.222",
31
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.222",
32
+ "@aglyn/shared-util-email": "1.0.0-beta.222",
33
+ "@aglyn/shared-util-http": "1.0.0-beta.222",
34
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.222",
35
+ "@aglyn/tenant-data-admin": "1.0.0-beta.222",
36
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.222",
37
+ "@aglyn/tenant-runtime": "1.0.0-beta.222",
38
38
  "@swc/helpers": "0.5.23"
39
39
  },
40
40
  "peerDependencies": {
@@ -27,6 +27,7 @@ import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconBu
27
27
  import { collection, doc, limit, query } from "firebase/firestore";
28
28
  import { useParams } from "next/navigation";
29
29
  import { useCallback, useState } from "react";
30
+ import { bookingPriceText } from "../model/booking-price.js";
30
31
  /**
31
32
  * WHETHER THIS SITE HAS A BOOKING DOOR (AGL-2660).
32
33
  *
@@ -200,7 +201,7 @@ BookMeetingButton.displayName = 'BookMeetingButton';
200
201
  }) : /*#__PURE__*/ _jsx(Stack, {
201
202
  spacing: 1.5,
202
203
  children: services.map((service)=>{
203
- var _service_name, _service_priceUsd, _service_durationMinutes;
204
+ var _service_name, _service_durationMinutes;
204
205
  const id = String(service['$id']);
205
206
  const name = String((_service_name = service['name']) != null ? _service_name : '');
206
207
  const link = bookingLinkFor({
@@ -211,7 +212,8 @@ BookMeetingButton.displayName = 'BookMeetingButton';
211
212
  path,
212
213
  recordRef
213
214
  });
214
- const price = Number((_service_priceUsd = service['priceUsd']) != null ? _service_priceUsd : 0);
215
+ // `$150`, `free`, or the label the service states its price as (AGL-3475).
216
+ const price = bookingPriceText(service);
215
217
  return /*#__PURE__*/ _jsxs(Stack, {
216
218
  direction: "row",
217
219
  spacing: 1,
@@ -234,7 +236,7 @@ BookMeetingButton.displayName = 'BookMeetingButton';
234
236
  variant: "caption",
235
237
  color: "text.secondary",
236
238
  noWrap: true,
237
- children: `${Number((_service_durationMinutes = service['durationMinutes']) != null ? _service_durationMinutes : 0)} min` + (price > 0 ? ` · $${price}` : ' · free')
239
+ children: `${Number((_service_durationMinutes = service['durationMinutes']) != null ? _service_durationMinutes : 0)} min · ${price === 'Free' ? 'free' : price}`
238
240
  }),
239
241
  link ? /*#__PURE__*/ _jsx(Typography, {
240
242
  variant: "caption",
@@ -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'\n\n\n/**\n * WHETHER THIS SITE HAS A BOOKING DOOR (AGL-2660).\n *\n * The action is offered only where a booking can actually be taken: the\n * Bookings plugin runs on the site — the org's set less the site's own\n * deny-list, which is the one resolver every other per-site gate reads —\n * and the org holds the `bookings` entitlement, without which the booking\n * API refuses every request. One listen on the site document answers the\n * deny-list and, for the link, the site's public naming.\n *\n * At the organization level the record's own capturing site is the site\n * asked about, which is how a contact captured by one brand of an agency\n * offers that brand's services and not another's. A record no site has\n * captured has no door.\n */\nexport function useBookingDoor(\n hostId: string | null | undefined,\n org?: Partial<AglynOrgBilling> | null,\n): { open: boolean; host: Record<string, unknown> | null } {\n const firestore = useFirestore()\n const { data: host, status } = useFirestoreDoc<Record<string, unknown>>(\n () => (hostId ? doc(firestore, 'hosts', hostId) : null),\n [firestore, hostId],\n )\n const open =\n Boolean(hostId) &&\n status === 'success' &&\n Boolean(host) &&\n isHostPluginEnabled(org ?? null, host as never, BUNDLE_ID) &&\n checkEntitlement(org ?? null, 'bookings')\n return { open, host: host ?? null }\n}\n\nexport interface BookMeetingButtonProps {\n /**\n * The site whose services are offered — the mounted site, or at the\n * organization level the record's own capturing site; `null` for a\n * record no site has captured, which offers nothing.\n */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n /**\n * The record the link is dropped from, by its kind in the record system's\n * own words (`contact`), carried as `?crm=kind:id`.\n */\n kind: string\n recordId: string\n /**\n * When given, every service also offers **Insert into email** and the\n * chosen link is handed here — the email dialog's use.\n */\n onInsert?: (link: string) => void\n /** `chip` is the compact form the email dialog carries beside its fields. */\n variant?: 'button' | 'chip'\n}\n\n/**\n * THE \"BOOK A MEETING\" ACTION A RECORD CARRIES (AGL-2660): one control, and\n * the dialog it opens, mounted only while open. Renders NOTHING where the\n * site has no booking door, rather than a disabled control with a reason —\n * a site that has never enabled Bookings should not read every record page\n * as missing something.\n */\nexport function BookMeetingButton(props: BookMeetingButtonProps) {\n const { hostId, org, kind, recordId, onInsert, variant = 'button' } = props\n const door = useBookingDoor(hostId, org)\n const [open, setOpen] = useState(false)\n if (!door.open || !hostId || !recordId) return null\n const icon = <MdiIcon path={mdiCalendarClock.path} size={0.8} />\n return (\n <>\n {variant === 'chip' ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n icon={icon}\n label=\"Insert booking link\"\n onClick={() => setOpen(true)}\n // A flex item in the dialog's column, kept to its own width.\n sx={{ alignSelf: 'flex-start' }}\n />\n ) : (\n <Button size=\"small\" variant=\"outlined\" startIcon={icon} onClick={() => setOpen(true)}>\n {'Book a meeting'}\n </Button>\n )}\n {open ? (\n <BookMeetingDialog\n open\n onClose={() => setOpen(false)}\n hostId={hostId}\n host={door.host}\n org={org}\n kind={kind}\n recordId={recordId}\n onInsert={onInsert}\n />\n ) : null}\n </>\n )\n}\nBookMeetingButton.displayName = 'BookMeetingButton'\n\ninterface BookMeetingDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n /** The site document the door already read — its public naming. */\n host: Record<string, unknown> | null\n org?: Partial<AglynOrgBilling> | null\n kind: string\n recordId: string\n onInsert?: (link: string) => void\n}\n\n/**\n * The site's bookable services, each with the link a visitor books it at.\n *\n * The link is the Bookings plugin's own builder over three facts read here:\n * the site's public naming (off the site document), the page the Booking\n * block lives on (the plugin's per-site `bookingPath` setting), and the\n * record this dialog was opened from. A site with no public address yet\n * lists its services and says why there is no link.\n */\nexport function BookMeetingDialog(props: BookMeetingDialogProps) {\n const { open, onClose, hostId, host, org, kind, recordId, onInsert } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId } = useOrgDataScope({ hostId, orgId: org?.$id })\n const { config, ready: configReady } = useSitePluginConfig(orgId, hostId, BUNDLE_ID)\n const { data: serviceDocs, status } = useFirestoreCollection<Record<string, unknown>>(\n () => query(collection(firestore, 'hosts', hostId, 'services'), limit(100)),\n [firestore, hostId],\n { idField: '$id' },\n )\n const services = (serviceDocs ?? [])\n .filter((service) => !service['deletedAt'])\n .sort((a, b) => String(a['name'] ?? '').localeCompare(String(b['name'] ?? '')))\n\n // Where services are set up, for the empty state — addressable only\n // under a site route, where the URL names the org and the site.\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const bookingsHref =\n params?.orgSlug && params?.host\n ? bookingsPageHref({ orgSlug: String(params.orgSlug), host: String(params.host) })\n : null\n\n const site = {\n cname: host?.['cname'] as string | null | undefined,\n subdomain: host?.['subdomain'] as string | null | undefined,\n }\n const path = configReady ? String(config['bookingPath'] ?? BOOKING_PATH_DEFAULT) : null\n const recordRef = { kind, id: recordId }\n\n const copy = useCallback(\n async (link: string) => {\n try {\n await navigator.clipboard.writeText(link)\n enqueueSnackbar('Booking link copied', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The link could not be copied', { variant: 'error', allowDuplicate: true })\n }\n },\n [enqueueSnackbar],\n )\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{'Book a meeting'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Each link opens your site’s booking page on one service, with this ' +\n 'record attached so the booking lands on its timeline — whatever ' +\n 'address the person books with.'}\n </Typography>\n {status === 'loading' || path === null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No bookable services yet. '}\n {bookingsHref ? <AppLink href={bookingsHref}>{'Set up services'}</AppLink> : null}\n </Typography>\n ) : (\n <Stack spacing={1.5}>\n {services.map((service) => {\n const id = String(service['$id'])\n const name = String(service['name'] ?? '')\n const link = bookingLinkFor({ site, service: { id }, path, recordRef })\n const price = Number(service['priceUsd'] ?? 0)\n return (\n <Stack key={id} direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {name}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {`${Number(service['durationMinutes'] ?? 0)} min` +\n (price > 0 ? ` · $${price}` : ' · free')}\n </Typography>\n {link ? (\n <Typography variant=\"caption\" sx={{ wordBreak: 'break-all' }}>\n {link}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This site has no public address yet, so there is no link.'}\n </Typography>\n )}\n </Stack>\n <Tooltip title=\"Copy link\">\n <span>\n <IconButton\n size=\"small\"\n aria-label={`Copy link for ${name}`}\n disabled={!link}\n onClick={() => link && void copy(link)}\n >\n <MdiIcon path={mdiContentCopy.path} size={0.8} />\n </IconButton>\n </span>\n </Tooltip>\n {onInsert ? (\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={!link}\n onClick={() => {\n if (!link) return\n onInsert(link)\n onClose()\n }}\n >\n {'Insert into email'}\n </Button>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n )}\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Close'}</Button>\n </DialogActions>\n </Dialog>\n )\n}\nBookMeetingDialog.displayName = 'BookMeetingDialog'\n\nexport default BookMeetingButton\n"],"names":["checkEntitlement","isHostPluginEnabled","bookingsPageHref","BOOKING_PATH_DEFAULT","bookingLinkFor","BUNDLE_ID","mdiCalendarClock","mdiContentCopy","AppLink","MdiIcon","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","useSitePluginConfig","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","IconButton","Stack","Tooltip","Typography","collection","doc","limit","query","useParams","useCallback","useState","useBookingDoor","hostId","org","firestore","data","host","status","open","Boolean","BookMeetingButton","props","kind","recordId","onInsert","variant","door","setOpen","icon","path","size","label","onClick","sx","alignSelf","startIcon","BookMeetingDialog","onClose","displayName","config","enqueueSnackbar","orgId","$id","ready","configReady","serviceDocs","idField","services","filter","service","sort","a","b","String","localeCompare","params","bookingsHref","orgSlug","site","cname","subdomain","recordRef","id","copy","link","navigator","clipboard","writeText","persist","error","console","allowDuplicate","maxWidth","fullWidth","display","flexDirection","gap","color","length","href","spacing","map","name","price","Number","direction","alignItems","flex","minWidth","noWrap","wordBreak","title","span","aria-label","disabled"],"mappings":"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"}
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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"}
@@ -21,6 +21,39 @@ export interface BookingProps {
21
21
  heading?: string;
22
22
  successMessage?: string;
23
23
  }
24
+ interface SlotOption {
25
+ startsAtMs: number;
26
+ endsAtMs: number;
27
+ }
28
+ /** One day of the strip: the visitor's local calendar day and its times. */
29
+ export interface BookingSlotDay {
30
+ /** Local midnight that starts the day, in epoch ms — key and sort order. */
31
+ startMs: number;
32
+ slots: SlotOption[];
33
+ }
34
+ /**
35
+ * Slots grouped by the VISITOR's local day, in order (AGL-3492).
36
+ *
37
+ * A page of the listing is whole days in the SERVICE's zone, so for a
38
+ * visitor in another zone the last day of a page can be one the page ended
39
+ * part way through. `nextFromMs` is where the page stopped — every open time
40
+ * before it is loaded — so a day that runs past it is held back until the
41
+ * next page completes it, rather than shown with its later times missing.
42
+ */
43
+ export declare function groupSlotsByLocalDay(slots: SlotOption[], nextFromMs: number | null): BookingSlotDay[];
44
+ /** A day chip's label: `Mon, Oct 5` in the visitor's locale. */
45
+ export declare function bookingDayLabel(startMs: number): string;
46
+ /** A time chip's label: `2:30 PM` in the visitor's locale. */
47
+ export declare function bookingTimeLabel(startsAtMs: number): string;
48
+ /**
49
+ * A day's times in the parts of the day a visitor scans for (AGL-3492): a
50
+ * day open 8 to 5 at 15-minute steps holds 33 starts, and one unbroken run
51
+ * of chips that long reads as noise. Empty parts are left out.
52
+ */
53
+ export declare function partsOfDay(slots: SlotOption[]): Array<{
54
+ label: string;
55
+ slots: SlotOption[];
56
+ }>;
24
57
  /**
25
58
  * Booking widget (AGL-160): service picker → open-slot picker → contact
26
59
  * form, driven by the tenant /api/bookings endpoints (server-validated,