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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +3 -2
  2. package/package.json +11 -11
  3. package/src/lib/components/book-meeting-action.js +5 -3
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.d.ts +33 -0
  6. package/src/lib/components/booking.js +260 -54
  7. package/src/lib/components/booking.js.map +1 -1
  8. package/src/lib/components/bookings-console-page.js +139 -15
  9. package/src/lib/components/bookings-console-page.js.map +1 -1
  10. package/src/lib/declarations.console-server.d.ts +34 -0
  11. package/src/lib/declarations.console-server.js +84 -0
  12. package/src/lib/declarations.console-server.js.map +1 -0
  13. package/src/lib/model/booking-contact-fields.d.ts +88 -0
  14. package/src/lib/model/booking-contact-fields.js +130 -0
  15. package/src/lib/model/booking-contact-fields.js.map +1 -0
  16. package/src/lib/model/booking-price.d.ts +51 -0
  17. package/src/lib/model/booking-price.js +61 -0
  18. package/src/lib/model/booking-price.js.map +1 -0
  19. package/src/lib/model/booking-record.d.ts +7 -0
  20. package/src/lib/model/booking-record.js +11 -2
  21. package/src/lib/model/booking-record.js.map +1 -1
  22. package/src/lib/model/bookings-record-counts.d.ts +25 -0
  23. package/src/lib/model/bookings-record-counts.js +34 -0
  24. package/src/lib/model/bookings-record-counts.js.map +1 -0
  25. package/src/lib/model/bookings.d.ts +61 -4
  26. package/src/lib/model/bookings.js +81 -15
  27. package/src/lib/model/bookings.js.map +1 -1
  28. package/src/lib/model/index.d.ts +2 -0
  29. package/src/lib/model/index.js +2 -0
  30. package/src/lib/model/index.js.map +1 -1
  31. package/src/lib/plugin.js +14 -0
  32. package/src/lib/plugin.js.map +1 -1
  33. package/src/lib/server/billing-webhook.js +33 -15
  34. package/src/lib/server/billing-webhook.js.map +1 -1
  35. package/src/lib/server/booking-crm.js +4 -1
  36. package/src/lib/server/booking-crm.js.map +1 -1
  37. package/src/lib/server/person-eraser.d.ts +36 -0
  38. package/src/lib/server/person-eraser.js +48 -0
  39. package/src/lib/server/person-eraser.js.map +1 -0
  40. package/src/lib/server/refund.js +2 -2
  41. package/src/lib/server/refund.js.map +1 -1
  42. package/src/lib/server.js +97 -36
  43. package/src/lib/server.js.map +1 -1
  44. package/src/lib/transfer/bookings-transfer-common.d.ts +68 -0
  45. package/src/lib/transfer/bookings-transfer-common.js +130 -0
  46. package/src/lib/transfer/bookings-transfer-common.js.map +1 -0
  47. package/src/lib/transfer/bookings-transfer.d.ts +86 -0
  48. package/src/lib/transfer/bookings-transfer.js +613 -0
  49. package/src/lib/transfer/bookings-transfer.js.map +1 -0
  50. package/src/lib/transfer/bookings-transfer.server.d.ts +26 -0
  51. package/src/lib/transfer/bookings-transfer.server.js +31 -0
  52. package/src/lib/transfer/bookings-transfer.server.js.map +1 -0
@@ -29,6 +29,12 @@ export interface HostBookingService {
29
29
  description?: string;
30
30
  /** Optional price; 0/absent means free. */
31
31
  priceUsd?: number;
32
+ /**
33
+ * How the price is stated (AGL-3475): `fixed` (absent) charges `priceUsd`;
34
+ * `varies`, `estimate` and `contact` show their label and book with no
35
+ * charge. Read through `bookingPriceDisplay` / `bookingChargeUsd`.
36
+ */
37
+ priceDisplay?: 'fixed' | 'varies' | 'estimate' | 'contact';
32
38
  /**
33
39
  * Weekly availability: `windows[weekday]` (0 = Sunday … 6 = Saturday)
34
40
  * lists open intervals in minutes since midnight, host-local.
@@ -51,6 +57,13 @@ export interface HostBookingService {
51
57
  * has to opt out of saying so.
52
58
  */
53
59
  crmMeetingActivity?: boolean;
60
+ /**
61
+ * Ask the booker for a phone number (AGL-3493): `optional` or `required`;
62
+ * absent is off. Read through `bookingFieldAsk`.
63
+ */
64
+ askPhone?: 'off' | 'optional' | 'required';
65
+ /** Ask the booker for the address the service is at (AGL-3493); as above. */
66
+ askAddress?: 'off' | 'optional' | 'required';
54
67
  }
55
68
  /** Booked interval as epoch-ms instants. */
56
69
  export interface BookedInterval {
@@ -92,13 +105,57 @@ export interface BookingSlot {
92
105
  startsAtMs: number;
93
106
  endsAtMs: number;
94
107
  }
108
+ /** The walk's resolution: a slot may start on any quarter hour. */
109
+ export declare const BOOKING_SLOT_STEP_MINUTES = 15;
110
+ /**
111
+ * The most starts one calendar day can hold at that resolution — 96. The
112
+ * per-day unit every bound on a slot listing is counted in.
113
+ */
114
+ export declare const BOOKING_MAX_SLOTS_PER_DAY: number;
115
+ /**
116
+ * Days of open times one slot listing answers with, and the length of the
117
+ * Booking widget's day strip (AGL-3492). One constant, read by both halves:
118
+ * the listing is bounded in DAYS so every day the strip shows carries every
119
+ * open time it has. A flat slot count was the bound before, and at 15-minute
120
+ * steps 120 of them ran out three and a half days in — the widget then drew
121
+ * those days as the whole calendar and cut the fourth at lunch.
122
+ */
123
+ export declare const BOOKING_SLOT_PAGE_DAYS = 14;
95
124
  /**
96
- * Enumerates open slots for a service between two instants: windows are
97
- * walked in the service timezone, quantized to the service duration, and
98
- * intervals overlapping `booked` (or starting before `fromMs`) drop out.
99
- * Bounded: at most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.
125
+ * The hard ceiling on one listing: a page of days, every one of them open
126
+ * around the clock. Only a service that really is open that much reaches it,
127
+ * and the page then ends early with its next page named, never truncated
128
+ * silently.
129
+ */
130
+ export declare const BOOKING_SLOT_PAGE_MAX_SLOTS: number;
131
+ /**
132
+ * Enumerates open slots for a service between two instants. Bounded: at
133
+ * most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.
100
134
  */
101
135
  export declare function computeOpenSlots(service: HostBookingService, fromMs: number, toMs: number, booked?: BookedInterval[], limit?: number): BookingSlot[];
136
+ /** One page of a slot listing (AGL-3492). */
137
+ export interface BookingSlotPage {
138
+ /** Every open slot of the page's days, in order. */
139
+ slots: BookingSlot[];
140
+ /**
141
+ * Where the next page starts, or `null` once the page reached `toMs`.
142
+ * Every open slot before it is in `slots` — the guarantee a reader needs
143
+ * to tell a day it holds whole from one the page ended inside.
144
+ */
145
+ nextFromMs: number | null;
146
+ }
147
+ /**
148
+ * The open slots of the next `days` service-local days that have any, from
149
+ * `fromMs` up to `toMs` — whole days, never a day cut part way through
150
+ * (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;
151
+ * reaching it ends the page early with `nextFromMs` naming the first slot
152
+ * left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a
153
+ * horizon further out than that is answered a page at a time.
154
+ */
155
+ export declare function computeOpenSlotPage(service: HostBookingService, fromMs: number, toMs: number, booked?: BookedInterval[], { days, maxSlots, }?: {
156
+ days?: number;
157
+ maxSlots?: number;
158
+ }): BookingSlotPage;
102
159
  /** True when the exact slot is open for the service (booking API check). */
103
160
  export declare function isSlotOpen(service: HostBookingService, startsAtMs: number, booked?: BookedInterval[]): boolean;
104
161
  /**
@@ -48,26 +48,47 @@ export const REMINDER_WINDOW_END_HOURS = 25;
48
48
  const startsAtMs = Number((_booking_startsAtMs = booking.startsAtMs) != null ? _booking_startsAtMs : 0);
49
49
  return booking.status !== 'canceled' && !booking.reminderSentAt && Boolean(booking.email) && startsAtMs >= nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000 && startsAtMs <= nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000;
50
50
  }
51
+ /** The walk's resolution: a slot may start on any quarter hour. */ export const BOOKING_SLOT_STEP_MINUTES = 15;
51
52
  /**
52
- * Enumerates open slots for a service between two instants: windows are
53
- * walked in the service timezone, quantized to the service duration, and
54
- * intervals overlapping `booked` (or starting before `fromMs`) drop out.
55
- * Bounded: at most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.
56
- */ export function computeOpenSlots(service, fromMs, toMs, booked = [], limit = 200) {
53
+ * The most starts one calendar day can hold at that resolution — 96. The
54
+ * per-day unit every bound on a slot listing is counted in.
55
+ */ export const BOOKING_MAX_SLOTS_PER_DAY = 24 * 60 / BOOKING_SLOT_STEP_MINUTES;
56
+ /**
57
+ * Days of open times one slot listing answers with, and the length of the
58
+ * Booking widget's day strip (AGL-3492). One constant, read by both halves:
59
+ * the listing is bounded in DAYS so every day the strip shows carries every
60
+ * open time it has. A flat slot count was the bound before, and at 15-minute
61
+ * steps 120 of them ran out three and a half days in — the widget then drew
62
+ * those days as the whole calendar and cut the fourth at lunch.
63
+ */ export const BOOKING_SLOT_PAGE_DAYS = 14;
64
+ /**
65
+ * The hard ceiling on one listing: a page of days, every one of them open
66
+ * around the clock. Only a service that really is open that much reaches it,
67
+ * and the page then ends early with its next page named, never truncated
68
+ * silently.
69
+ */ export const BOOKING_SLOT_PAGE_MAX_SLOTS = BOOKING_SLOT_PAGE_DAYS * BOOKING_MAX_SLOTS_PER_DAY;
70
+ /**
71
+ * Walks the open slots of a service from `fromMs`, handing each to `take`
72
+ * with the service-local day it falls on, until `take` answers `false` or
73
+ * the walk reaches `toMs` (never more than BOOKING_MAX_DAYS_AHEAD past
74
+ * `fromMs`). Answers where it stopped: the first start it did not hand over
75
+ * — the slot `take` refused, or the first one past the walked range — and
76
+ * whether that was `toMs` itself, so nothing is left to walk.
77
+ *
78
+ * Windows are read in the service timezone, 15 minutes at a time —
79
+ * O(minutes/15) and immune to DST arithmetic because weekday and minutes
80
+ * come from Intl per instant. Intervals overlapping `booked` drop out.
81
+ */ function walkOpenSlots(service, fromMs, toMs, booked, take) {
57
82
  const timezone = service.timezone || 'UTC';
58
83
  const duration = Math.min(Math.max(Math.round(service.durationMinutes || 0), BOOKING_MIN_DURATION_MINUTES), BOOKING_MAX_DURATION_MINUTES);
59
84
  const durationMs = duration * 60000;
60
85
  const horizonMs = Math.min(toMs, fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60000);
61
- const slots = [];
62
- // Walk in 15-minute steps and keep instants that start a window-aligned
63
- // slot — O(minutes/15) and immune to DST arithmetic because weekday and
64
- // minutes come from Intl per instant.
65
- const stepMs = 15 * 60000;
66
- const alignedFrom = Math.ceil(fromMs / stepMs) * stepMs;
67
- for(let atMs = alignedFrom; atMs + durationMs <= horizonMs && slots.length < limit; atMs += stepMs){
86
+ const stepMs = BOOKING_SLOT_STEP_MINUTES * 60000;
87
+ let atMs = Math.ceil(fromMs / stepMs) * stepMs;
88
+ for(; atMs + durationMs <= horizonMs; atMs += stepMs){
68
89
  var _ref;
69
90
  var _service_windows;
70
- const { weekday, hour, minute } = zonedDateTime(atMs, timezone);
91
+ const { year, month, day, weekday, hour, minute } = zonedDateTime(atMs, timezone);
71
92
  const minutes = hour * 60 + minute;
72
93
  const windows = (_ref = (_service_windows = service.windows) == null ? void 0 : _service_windows[weekday]) != null ? _ref : [];
73
94
  const fitsWindow = windows.some((window)=>minutes >= window.start && minutes + duration <= window.end);
@@ -75,13 +96,58 @@ export const REMINDER_WINDOW_END_HOURS = 25;
75
96
  const endMs = atMs + durationMs;
76
97
  const collides = booked.some((interval)=>atMs < interval.endsAtMs && endMs > interval.startsAtMs);
77
98
  if (collides) continue;
78
- slots.push({
99
+ if (!take({
79
100
  startsAtMs: atMs,
80
101
  endsAtMs: endMs
81
- });
102
+ }, `${year}-${month}-${day}`)) {
103
+ return {
104
+ stoppedAtMs: atMs,
105
+ reachedEnd: false
106
+ };
107
+ }
82
108
  }
109
+ // The first start the walk did not try, not the horizon itself: a slot
110
+ // that starts before the cap and ends after it belongs to the next walk.
111
+ return {
112
+ stoppedAtMs: atMs,
113
+ reachedEnd: horizonMs >= toMs
114
+ };
115
+ }
116
+ /**
117
+ * Enumerates open slots for a service between two instants. Bounded: at
118
+ * most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.
119
+ */ export function computeOpenSlots(service, fromMs, toMs, booked = [], limit = 200) {
120
+ const slots = [];
121
+ if (limit <= 0) return slots;
122
+ walkOpenSlots(service, fromMs, toMs, booked, (slot)=>{
123
+ slots.push(slot);
124
+ return slots.length < limit;
125
+ });
83
126
  return slots;
84
127
  }
128
+ /**
129
+ * The open slots of the next `days` service-local days that have any, from
130
+ * `fromMs` up to `toMs` — whole days, never a day cut part way through
131
+ * (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;
132
+ * reaching it ends the page early with `nextFromMs` naming the first slot
133
+ * left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a
134
+ * horizon further out than that is answered a page at a time.
135
+ */ export function computeOpenSlotPage(service, fromMs, toMs, booked = [], { days = BOOKING_SLOT_PAGE_DAYS, maxSlots = BOOKING_SLOT_PAGE_MAX_SLOTS } = {}) {
136
+ const slots = [];
137
+ const dayKeys = new Set();
138
+ const { stoppedAtMs, reachedEnd } = walkOpenSlots(service, fromMs, toMs, booked, (slot, dayKey)=>{
139
+ if (slots.length >= maxSlots || !dayKeys.has(dayKey) && dayKeys.size >= days) {
140
+ return false;
141
+ }
142
+ dayKeys.add(dayKey);
143
+ slots.push(slot);
144
+ return true;
145
+ });
146
+ return {
147
+ slots,
148
+ nextFromMs: reachedEnd ? null : stoppedAtMs
149
+ };
150
+ }
85
151
  /** True when the exact slot is open for the service (booking API check). */ export function isSlotOpen(service, startsAtMs, booked = []) {
86
152
  const slots = computeOpenSlots(service, startsAtMs, startsAtMs + BOOKING_MAX_DURATION_MINUTES * 60000, booked, 1);
87
153
  return slots.length > 0 && slots[0].startsAtMs === startsAtMs;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/bookings.ts"],"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// Leaf paths, not the barrel: this model is read by the client bundle and\n// the `/server` entry alike, and either barrel would drag the other's\n// surface into a bundle that has no use for it.\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\n// The leaf entry: the package root extends the Firestore SDK's `Timestamp`,\n// and this model is read on published pages.\nimport { zonedDateTime } from '@aglyn/shared-util-timestamp/zoned-time'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n type BookingRecordRef,\n formatBookingRecordRef,\n} from './booking-record'\n\n/**\n * Bookings v1 (AGL-159): services with weekly availability windows and\n * pure, timezone-explicit slot computation. All times are minutes since\n * midnight in the HOST's timezone; instants are epoch milliseconds. No\n * I/O here — collision checks against stored bookings happen in the\n * booking API over the values these helpers produce.\n */\n\n/** `hosts/{hostId}/services/{id}` doc. */\nexport interface HostBookingService {\n name: string\n durationMinutes: number\n description?: string\n /** Optional price; 0/absent means free. */\n priceUsd?: number\n /**\n * Weekly availability: `windows[weekday]` (0 = Sunday … 6 = Saturday)\n * lists open intervals in minutes since midnight, host-local.\n */\n windows?: Partial<Record<number, Array<{ start: number; end: number }>>>\n /** IANA timezone the windows are defined in, e.g. \"America/Chicago\". */\n timezone?: string\n /**\n * Create a follow-up task on the CRM record when this service is booked\n * (AGL-2660) — due one business day after the slot, on whoever holds the\n * relationship. Off unless the service says so.\n */\n crmFollowUpTask?: boolean\n /**\n * File the booking as a `meeting` on the CRM record's timeline (AGL-2660).\n * Absent means ON: a booking is a meeting the record has, and the service\n * has to opt out of saying so.\n */\n crmMeetingActivity?: boolean\n}\n\n/** Booked interval as epoch-ms instants. */\nexport interface BookedInterval {\n startsAtMs: number\n endsAtMs: number\n}\n\nexport const BOOKING_MIN_DURATION_MINUTES = 5\nexport const BOOKING_MAX_DURATION_MINUTES = 8 * 60\n/** Slot enumeration horizon; the console/API never look further out. */\nexport const BOOKING_MAX_DAYS_AHEAD = 60\n\n/**\n * The band a booking has to fall in to earn its 24-hour reminder, in hours\n * from now (AGL-160, scheduled in AGL-2431).\n *\n * IN THE MODEL, not beside the scan that uses them, so the console card can\n * apply the identical window without importing server code into a client\n * bundle. That sharing is the point rather than a convenience: a card that\n * drew its own boundary would report a queue depth the job does not act on,\n * and a merchant checking whether reminders are working would be reading a\n * number produced by different arithmetic than the sender's.\n */\nexport const REMINDER_WINDOW_START_HOURS = 23\nexport const REMINDER_WINDOW_END_HOURS = 25\n\n/**\n * Whether `booking` is one this pass would mail — the three tests the scan\n * applies, in one place both halves call.\n *\n * `nowMs` is a parameter and not `Date.now()` so the card and the job can be\n * asked the same question about the same instant, and so a test can pin a\n * booking exactly on either edge of the band.\n */\nexport function isBookingReminderDue(\n booking: {\n status?: string\n email?: string\n reminderSentAt?: unknown\n startsAtMs?: number\n },\n nowMs: number,\n): boolean {\n const startsAtMs = Number(booking.startsAtMs ?? 0)\n return (\n booking.status !== 'canceled' &&\n !booking.reminderSentAt &&\n Boolean(booking.email) &&\n startsAtMs >= nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000 &&\n startsAtMs <= nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000\n )\n}\n\nexport interface BookingSlot {\n startsAtMs: number\n endsAtMs: number\n}\n\n/**\n * Enumerates open slots for a service between two instants: windows are\n * walked in the service timezone, quantized to the service duration, and\n * intervals overlapping `booked` (or starting before `fromMs`) drop out.\n * Bounded: at most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.\n */\nexport function computeOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n limit = 200,\n): BookingSlot[] {\n const timezone = service.timezone || 'UTC'\n const duration = Math.min(\n Math.max(\n Math.round(service.durationMinutes || 0),\n BOOKING_MIN_DURATION_MINUTES,\n ),\n BOOKING_MAX_DURATION_MINUTES,\n )\n const durationMs = duration * 60_000\n const horizonMs = Math.min(\n toMs,\n fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60_000,\n )\n const slots: BookingSlot[] = []\n // Walk in 15-minute steps and keep instants that start a window-aligned\n // slot — O(minutes/15) and immune to DST arithmetic because weekday and\n // minutes come from Intl per instant.\n const stepMs = 15 * 60_000\n const alignedFrom = Math.ceil(fromMs / stepMs) * stepMs\n for (\n let atMs = alignedFrom;\n atMs + durationMs <= horizonMs && slots.length < limit;\n atMs += stepMs\n ) {\n const { weekday, hour, minute } = zonedDateTime(atMs, timezone)\n const minutes = hour * 60 + minute\n const windows = service.windows?.[weekday] ?? []\n const fitsWindow = windows.some(\n (window) =>\n minutes >= window.start && minutes + duration <= window.end,\n )\n if (!fitsWindow) continue\n const endMs = atMs + durationMs\n const collides = booked.some(\n (interval) =>\n atMs < interval.endsAtMs && endMs > interval.startsAtMs,\n )\n if (collides) continue\n slots.push({ startsAtMs: atMs, endsAtMs: endMs })\n }\n return slots\n}\n\n/** True when the exact slot is open for the service (booking API check). */\nexport function isSlotOpen(\n service: HostBookingService,\n startsAtMs: number,\n booked: BookedInterval[] = [],\n): boolean {\n const slots = computeOpenSlots(\n service,\n startsAtMs,\n startsAtMs + BOOKING_MAX_DURATION_MINUTES * 60_000,\n booked,\n 1,\n )\n return slots.length > 0 && slots[0].startsAtMs === startsAtMs\n}\n\n/**\n * The page on the site that holds the Booking block, as the plugin's\n * `bookingPath` setting stores it (AGL-2660). The site root by default: a\n * site that put the block on its home page needs no setting, and a site\n * that put it elsewhere says where once, per site.\n */\nexport const BOOKING_PATH_DEFAULT = '/'\n\n/** `/book`, `book/`, ` /book?x ` → `/book`; anything empty → the root. */\nexport function normalizeBookingPath(raw: unknown): string {\n const text = String(raw ?? '')\n .trim()\n .split(/[?#]/)[0]\n if (!text) return BOOKING_PATH_DEFAULT\n const leading = text.startsWith('/') ? text : `/${text}`\n const trimmed = leading.replace(/\\/+$/, '')\n return trimmed || BOOKING_PATH_DEFAULT\n}\n\nexport interface BookingLinkInput {\n /** The host's public naming — its custom domain or its subdomain. */\n site: { cname?: string | null; subdomain?: string | null } | null | undefined\n /** The service the link opens the widget on. */\n service: { id: string }\n /** The site's `bookingPath` setting; the root when unset. */\n path?: string | null\n /** The record the link is dropped from, when there is one. */\n recordRef?: BookingRecordRef | null\n}\n\n/**\n * The public URL a visitor books a service at (AGL-2660), or `null` for a\n * site with no public origin yet.\n *\n * PER SERVICE, not per member: the model carries no staff or per-member\n * notion — a service has one calendar — so there is one link per service\n * and nothing narrower. The Bookings plugin renders no route of its own\n * either; the widget is a block the site owner placed on a page, so the\n * link is that page (the `bookingPath` setting) with the service\n * preselected and, when dropped from a record, the record carried\n * along so the booking can be attributed even when the booker uses a\n * different address.\n */\nexport function bookingLinkFor(input: BookingLinkInput): string | null {\n const origin = hostPublicOrigin(input.site)\n if (!origin) return null\n const query = new URLSearchParams({ [BOOKING_SERVICE_PARAM]: input.service.id })\n if (input.recordRef) query.set(BOOKING_RECORD_PARAM, formatBookingRecordRef(input.recordRef))\n return `${origin}${normalizeBookingPath(input.path)}?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","zonedDateTime","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","BOOKING_MIN_DURATION_MINUTES","BOOKING_MAX_DURATION_MINUTES","BOOKING_MAX_DAYS_AHEAD","REMINDER_WINDOW_START_HOURS","REMINDER_WINDOW_END_HOURS","isBookingReminderDue","booking","nowMs","startsAtMs","Number","status","reminderSentAt","Boolean","email","computeOpenSlots","service","fromMs","toMs","booked","limit","timezone","duration","Math","min","max","round","durationMinutes","durationMs","horizonMs","slots","stepMs","alignedFrom","ceil","atMs","length","weekday","hour","minute","minutes","windows","fitsWindow","some","window","start","end","endMs","collides","interval","endsAtMs","push","isSlotOpen","BOOKING_PATH_DEFAULT","normalizeBookingPath","raw","text","String","trim","split","leading","startsWith","trimmed","replace","bookingLinkFor","input","origin","site","query","URLSearchParams","id","recordRef","set","path","toString"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/bookings.ts"],"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// Leaf paths, not the barrel: this model is read by the client bundle and\n// the `/server` entry alike, and either barrel would drag the other's\n// surface into a bundle that has no use for it.\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\n// The leaf entry: the package root extends the Firestore SDK's `Timestamp`,\n// and this model is read on published pages.\nimport { zonedDateTime } from '@aglyn/shared-util-timestamp/zoned-time'\nimport {\n BOOKING_RECORD_PARAM,\n BOOKING_SERVICE_PARAM,\n type BookingRecordRef,\n formatBookingRecordRef,\n} from './booking-record'\n\n/**\n * Bookings v1 (AGL-159): services with weekly availability windows and\n * pure, timezone-explicit slot computation. All times are minutes since\n * midnight in the HOST's timezone; instants are epoch milliseconds. No\n * I/O here — collision checks against stored bookings happen in the\n * booking API over the values these helpers produce.\n */\n\n/** `hosts/{hostId}/services/{id}` doc. */\nexport interface HostBookingService {\n name: string\n durationMinutes: number\n description?: string\n /** Optional price; 0/absent means free. */\n priceUsd?: number\n /**\n * How the price is stated (AGL-3475): `fixed` (absent) charges `priceUsd`;\n * `varies`, `estimate` and `contact` show their label and book with no\n * charge. Read through `bookingPriceDisplay` / `bookingChargeUsd`.\n */\n priceDisplay?: 'fixed' | 'varies' | 'estimate' | 'contact'\n /**\n * Weekly availability: `windows[weekday]` (0 = Sunday … 6 = Saturday)\n * lists open intervals in minutes since midnight, host-local.\n */\n windows?: Partial<Record<number, Array<{ start: number; end: number }>>>\n /** IANA timezone the windows are defined in, e.g. \"America/Chicago\". */\n timezone?: string\n /**\n * Create a follow-up task on the CRM record when this service is booked\n * (AGL-2660) — due one business day after the slot, on whoever holds the\n * relationship. Off unless the service says so.\n */\n crmFollowUpTask?: boolean\n /**\n * File the booking as a `meeting` on the CRM record's timeline (AGL-2660).\n * Absent means ON: a booking is a meeting the record has, and the service\n * has to opt out of saying so.\n */\n crmMeetingActivity?: boolean\n /**\n * Ask the booker for a phone number (AGL-3493): `optional` or `required`;\n * absent is off. Read through `bookingFieldAsk`.\n */\n askPhone?: 'off' | 'optional' | 'required'\n /** Ask the booker for the address the service is at (AGL-3493); as above. */\n askAddress?: 'off' | 'optional' | 'required'\n}\n\n/** Booked interval as epoch-ms instants. */\nexport interface BookedInterval {\n startsAtMs: number\n endsAtMs: number\n}\n\nexport const BOOKING_MIN_DURATION_MINUTES = 5\nexport const BOOKING_MAX_DURATION_MINUTES = 8 * 60\n/** Slot enumeration horizon; the console/API never look further out. */\nexport const BOOKING_MAX_DAYS_AHEAD = 60\n\n/**\n * The band a booking has to fall in to earn its 24-hour reminder, in hours\n * from now (AGL-160, scheduled in AGL-2431).\n *\n * IN THE MODEL, not beside the scan that uses them, so the console card can\n * apply the identical window without importing server code into a client\n * bundle. That sharing is the point rather than a convenience: a card that\n * drew its own boundary would report a queue depth the job does not act on,\n * and a merchant checking whether reminders are working would be reading a\n * number produced by different arithmetic than the sender's.\n */\nexport const REMINDER_WINDOW_START_HOURS = 23\nexport const REMINDER_WINDOW_END_HOURS = 25\n\n/**\n * Whether `booking` is one this pass would mail — the three tests the scan\n * applies, in one place both halves call.\n *\n * `nowMs` is a parameter and not `Date.now()` so the card and the job can be\n * asked the same question about the same instant, and so a test can pin a\n * booking exactly on either edge of the band.\n */\nexport function isBookingReminderDue(\n booking: {\n status?: string\n email?: string\n reminderSentAt?: unknown\n startsAtMs?: number\n },\n nowMs: number,\n): boolean {\n const startsAtMs = Number(booking.startsAtMs ?? 0)\n return (\n booking.status !== 'canceled' &&\n !booking.reminderSentAt &&\n Boolean(booking.email) &&\n startsAtMs >= nowMs + REMINDER_WINDOW_START_HOURS * 60 * 60 * 1000 &&\n startsAtMs <= nowMs + REMINDER_WINDOW_END_HOURS * 60 * 60 * 1000\n )\n}\n\nexport interface BookingSlot {\n startsAtMs: number\n endsAtMs: number\n}\n\n/** The walk's resolution: a slot may start on any quarter hour. */\nexport const BOOKING_SLOT_STEP_MINUTES = 15\n\n/**\n * The most starts one calendar day can hold at that resolution — 96. The\n * per-day unit every bound on a slot listing is counted in.\n */\nexport const BOOKING_MAX_SLOTS_PER_DAY =\n (24 * 60) / BOOKING_SLOT_STEP_MINUTES\n\n/**\n * Days of open times one slot listing answers with, and the length of the\n * Booking widget's day strip (AGL-3492). One constant, read by both halves:\n * the listing is bounded in DAYS so every day the strip shows carries every\n * open time it has. A flat slot count was the bound before, and at 15-minute\n * steps 120 of them ran out three and a half days in — the widget then drew\n * those days as the whole calendar and cut the fourth at lunch.\n */\nexport const BOOKING_SLOT_PAGE_DAYS = 14\n\n/**\n * The hard ceiling on one listing: a page of days, every one of them open\n * around the clock. Only a service that really is open that much reaches it,\n * and the page then ends early with its next page named, never truncated\n * silently.\n */\nexport const BOOKING_SLOT_PAGE_MAX_SLOTS =\n BOOKING_SLOT_PAGE_DAYS * BOOKING_MAX_SLOTS_PER_DAY\n\n/**\n * Walks the open slots of a service from `fromMs`, handing each to `take`\n * with the service-local day it falls on, until `take` answers `false` or\n * the walk reaches `toMs` (never more than BOOKING_MAX_DAYS_AHEAD past\n * `fromMs`). Answers where it stopped: the first start it did not hand over\n * — the slot `take` refused, or the first one past the walked range — and\n * whether that was `toMs` itself, so nothing is left to walk.\n *\n * Windows are read in the service timezone, 15 minutes at a time —\n * O(minutes/15) and immune to DST arithmetic because weekday and minutes\n * come from Intl per instant. Intervals overlapping `booked` drop out.\n */\nfunction walkOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[],\n take: (slot: BookingSlot, dayKey: string) => boolean,\n): { stoppedAtMs: number; reachedEnd: boolean } {\n const timezone = service.timezone || 'UTC'\n const duration = Math.min(\n Math.max(\n Math.round(service.durationMinutes || 0),\n BOOKING_MIN_DURATION_MINUTES,\n ),\n BOOKING_MAX_DURATION_MINUTES,\n )\n const durationMs = duration * 60_000\n const horizonMs = Math.min(\n toMs,\n fromMs + BOOKING_MAX_DAYS_AHEAD * 24 * 60 * 60_000,\n )\n const stepMs = BOOKING_SLOT_STEP_MINUTES * 60_000\n let atMs = Math.ceil(fromMs / stepMs) * stepMs\n for (; atMs + durationMs <= horizonMs; atMs += stepMs) {\n const { year, month, day, weekday, hour, minute } = zonedDateTime(\n atMs,\n timezone,\n )\n const minutes = hour * 60 + minute\n const windows = service.windows?.[weekday] ?? []\n const fitsWindow = windows.some(\n (window) =>\n minutes >= window.start && minutes + duration <= window.end,\n )\n if (!fitsWindow) continue\n const endMs = atMs + durationMs\n const collides = booked.some(\n (interval) =>\n atMs < interval.endsAtMs && endMs > interval.startsAtMs,\n )\n if (collides) continue\n if (!take({ startsAtMs: atMs, endsAtMs: endMs }, `${year}-${month}-${day}`)) {\n return { stoppedAtMs: atMs, reachedEnd: false }\n }\n }\n // The first start the walk did not try, not the horizon itself: a slot\n // that starts before the cap and ends after it belongs to the next walk.\n return { stoppedAtMs: atMs, reachedEnd: horizonMs >= toMs }\n}\n\n/**\n * Enumerates open slots for a service between two instants. Bounded: at\n * most `limit` slots, never beyond BOOKING_MAX_DAYS_AHEAD.\n */\nexport function computeOpenSlots(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n limit = 200,\n): BookingSlot[] {\n const slots: BookingSlot[] = []\n if (limit <= 0) return slots\n walkOpenSlots(service, fromMs, toMs, booked, (slot) => {\n slots.push(slot)\n return slots.length < limit\n })\n return slots\n}\n\n/** One page of a slot listing (AGL-3492). */\nexport interface BookingSlotPage {\n /** Every open slot of the page's days, in order. */\n slots: BookingSlot[]\n /**\n * Where the next page starts, or `null` once the page reached `toMs`.\n * Every open slot before it is in `slots` — the guarantee a reader needs\n * to tell a day it holds whole from one the page ended inside.\n */\n nextFromMs: number | null\n}\n\n/**\n * The open slots of the next `days` service-local days that have any, from\n * `fromMs` up to `toMs` — whole days, never a day cut part way through\n * (AGL-3492). `maxSlots` is the ceiling for a service open around the clock;\n * reaching it ends the page early with `nextFromMs` naming the first slot\n * left out. A walk is also never longer than BOOKING_MAX_DAYS_AHEAD, so a\n * horizon further out than that is answered a page at a time.\n */\nexport function computeOpenSlotPage(\n service: HostBookingService,\n fromMs: number,\n toMs: number,\n booked: BookedInterval[] = [],\n {\n days = BOOKING_SLOT_PAGE_DAYS,\n maxSlots = BOOKING_SLOT_PAGE_MAX_SLOTS,\n }: { days?: number; maxSlots?: number } = {},\n): BookingSlotPage {\n const slots: BookingSlot[] = []\n const dayKeys = new Set<string>()\n const { stoppedAtMs, reachedEnd } = walkOpenSlots(\n service,\n fromMs,\n toMs,\n booked,\n (slot, dayKey) => {\n if (\n slots.length >= maxSlots ||\n (!dayKeys.has(dayKey) && dayKeys.size >= days)\n ) {\n return false\n }\n dayKeys.add(dayKey)\n slots.push(slot)\n return true\n },\n )\n return {\n slots,\n nextFromMs: reachedEnd ? null : stoppedAtMs,\n }\n}\n\n/** True when the exact slot is open for the service (booking API check). */\nexport function isSlotOpen(\n service: HostBookingService,\n startsAtMs: number,\n booked: BookedInterval[] = [],\n): boolean {\n const slots = computeOpenSlots(\n service,\n startsAtMs,\n startsAtMs + BOOKING_MAX_DURATION_MINUTES * 60_000,\n booked,\n 1,\n )\n return slots.length > 0 && slots[0].startsAtMs === startsAtMs\n}\n\n/**\n * The page on the site that holds the Booking block, as the plugin's\n * `bookingPath` setting stores it (AGL-2660). The site root by default: a\n * site that put the block on its home page needs no setting, and a site\n * that put it elsewhere says where once, per site.\n */\nexport const BOOKING_PATH_DEFAULT = '/'\n\n/** `/book`, `book/`, ` /book?x ` → `/book`; anything empty → the root. */\nexport function normalizeBookingPath(raw: unknown): string {\n const text = String(raw ?? '')\n .trim()\n .split(/[?#]/)[0]\n if (!text) return BOOKING_PATH_DEFAULT\n const leading = text.startsWith('/') ? text : `/${text}`\n const trimmed = leading.replace(/\\/+$/, '')\n return trimmed || BOOKING_PATH_DEFAULT\n}\n\nexport interface BookingLinkInput {\n /** The host's public naming — its custom domain or its subdomain. */\n site: { cname?: string | null; subdomain?: string | null } | null | undefined\n /** The service the link opens the widget on. */\n service: { id: string }\n /** The site's `bookingPath` setting; the root when unset. */\n path?: string | null\n /** The record the link is dropped from, when there is one. */\n recordRef?: BookingRecordRef | null\n}\n\n/**\n * The public URL a visitor books a service at (AGL-2660), or `null` for a\n * site with no public origin yet.\n *\n * PER SERVICE, not per member: the model carries no staff or per-member\n * notion — a service has one calendar — so there is one link per service\n * and nothing narrower. The Bookings plugin renders no route of its own\n * either; the widget is a block the site owner placed on a page, so the\n * link is that page (the `bookingPath` setting) with the service\n * preselected and, when dropped from a record, the record carried\n * along so the booking can be attributed even when the booker uses a\n * different address.\n */\nexport function bookingLinkFor(input: BookingLinkInput): string | null {\n const origin = hostPublicOrigin(input.site)\n if (!origin) return null\n const query = new URLSearchParams({ [BOOKING_SERVICE_PARAM]: input.service.id })\n if (input.recordRef) query.set(BOOKING_RECORD_PARAM, formatBookingRecordRef(input.recordRef))\n return `${origin}${normalizeBookingPath(input.path)}?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","zonedDateTime","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","formatBookingRecordRef","BOOKING_MIN_DURATION_MINUTES","BOOKING_MAX_DURATION_MINUTES","BOOKING_MAX_DAYS_AHEAD","REMINDER_WINDOW_START_HOURS","REMINDER_WINDOW_END_HOURS","isBookingReminderDue","booking","nowMs","startsAtMs","Number","status","reminderSentAt","Boolean","email","BOOKING_SLOT_STEP_MINUTES","BOOKING_MAX_SLOTS_PER_DAY","BOOKING_SLOT_PAGE_DAYS","BOOKING_SLOT_PAGE_MAX_SLOTS","walkOpenSlots","service","fromMs","toMs","booked","take","timezone","duration","Math","min","max","round","durationMinutes","durationMs","horizonMs","stepMs","atMs","ceil","year","month","day","weekday","hour","minute","minutes","windows","fitsWindow","some","window","start","end","endMs","collides","interval","endsAtMs","stoppedAtMs","reachedEnd","computeOpenSlots","limit","slots","slot","push","length","computeOpenSlotPage","days","maxSlots","dayKeys","Set","dayKey","has","size","add","nextFromMs","isSlotOpen","BOOKING_PATH_DEFAULT","normalizeBookingPath","raw","text","String","trim","split","leading","startsWith","trimmed","replace","bookingLinkFor","input","origin","site","query","URLSearchParams","id","recordRef","set","path","toString"],"mappings":"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"}
@@ -22,3 +22,5 @@
22
22
  export * from './bookings';
23
23
  export * from './booking-record';
24
24
  export * from './booking-time';
25
+ export * from './booking-contact-fields';
26
+ export * from './booking-price';
@@ -20,5 +20,7 @@
20
20
  */ export * from "./bookings.js";
21
21
  export * from "./booking-record.js";
22
22
  export * from "./booking-time.js";
23
+ export * from "./booking-contact-fields.js";
24
+ export * from "./booking-price.js";
23
25
 
24
26
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/index.ts"],"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/**\n * Bookings domain model (AGL-412): slot computation relocated from core app-utils.\n * Context-free — importable by client components, /server handlers, and\n * other plugins/apps via `@aglyn/plugins-bookings/model`.\n */\nexport * from './bookings'\nexport * from './booking-record'\nexport * from './booking-time'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,gBAAY;AAC1B,cAAc,sBAAkB;AAChC,cAAc,oBAAgB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/index.ts"],"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/**\n * Bookings domain model (AGL-412): slot computation relocated from core app-utils.\n * Context-free — importable by client components, /server handlers, and\n * other plugins/apps via `@aglyn/plugins-bookings/model`.\n */\nexport * from './bookings'\nexport * from './booking-record'\nexport * from './booking-time'\nexport * from './booking-contact-fields'\nexport * from './booking-price'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,gBAAY;AAC1B,cAAc,sBAAkB;AAChC,cAAc,oBAAgB;AAC9B,cAAc,8BAA0B;AACxC,cAAc,qBAAiB"}
package/src/lib/plugin.js CHANGED
@@ -14,12 +14,15 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import * as Aglyn from "@aglyn/aglyn";
17
+ import { registerPluginTransferResourceUi } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
17
18
  import { mdiCalendarClock } from "@aglyn/shared-data-mdi";
18
19
  import { lazy } from "react";
19
20
  import { BUNDLE_ID } from "./constants/bundle-common.js";
21
+ import { registerBookingsRecordCounts } from "./model/bookings-record-counts.js";
20
22
  import { registerBookingsRecordRoutes } from "./model/bookings-record-routes.js";
21
23
  import { BOOKINGS_SEARCH_SOURCES } from "./model/bookings-search-sources.js";
22
24
  import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
25
+ import { BOOKINGS_TRANSFER_RESOURCE } from "./transfer/bookings-transfer-common.js";
23
26
  /** Code-split: the Bookings console page only loads when opened. */ const BookingsConsolePage = lazy(()=>import("./components/bookings-console-page.js"));
24
27
  /** "Book a meeting", loaded where a record page draws the zone for it. */ const BookMeetingButton = lazy(()=>import("./components/book-meeting-action.js"));
25
28
  /**
@@ -27,9 +30,20 @@ import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
27
30
  * at console app load — the page is lazy (no besigner/canvas code).
28
31
  */ export function registerBookingsConsole() {
29
32
  registerBookingsRecordRoutes();
33
+ // How many bookings a site took, counted for another plugin's figure.
34
+ registerBookingsRecordCounts();
30
35
  // Per-plugin settings (AGL-428): the schema powers the generic form on
31
36
  // the Plugins & add-ons hub and defaults-merged reads everywhere.
32
37
  Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA);
38
+ // The export dialog's name for the site's bookings; exported, never imported.
39
+ registerPluginTransferResourceUi(BOOKINGS_TRANSFER_RESOURCE, {
40
+ label: 'Bookings',
41
+ icon: {
42
+ path: mdiCalendarClock.path
43
+ }
44
+ }, {
45
+ pluginId: BUNDLE_ID
46
+ });
33
47
  Aglyn.registerConsoleExtension({
34
48
  pluginId: BUNDLE_ID,
35
49
  displayName: 'Bookings',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/bookings/src/lib/plugin.ts"],"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 { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerBookingsRecordRoutes } from './model/bookings-record-routes'\nimport { BOOKINGS_SEARCH_SOURCES } from './model/bookings-search-sources'\nimport { BOOKINGS_CONFIG_SCHEMA } from './plugin-config'\n\n/** Code-split: the Bookings console page only loads when opened. */\nconst BookingsConsolePage = lazy(\n () => import('./components/bookings-console-page'),\n)\n\n/** \"Book a meeting\", loaded where a record page draws the zone for it. */\nconst BookMeetingButton = lazy(() => import('./components/book-meeting-action'))\n\n/**\n * Console half only: registers the Bookings nav item + page. Safe to call\n * at console app load — the page is lazy (no besigner/canvas code).\n */\nexport function registerBookingsConsole(): void {\n registerBookingsRecordRoutes()\n // Per-plugin settings (AGL-428): the schema powers the generic form on\n // the Plugins & add-ons hub and defaults-merged reads everywhere.\n Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA)\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Bookings',\n featureFlag: 'bookings',\n searchSources: BOOKINGS_SEARCH_SOURCES,\n // The control a CRM record carries for booking a meeting with the person.\n // The CRM hosts the zone and hands it the record; the services, the page\n // they are booked on and the link are this plugin's, so the control is\n // too. It draws nothing where this site takes no bookings.\n widgets: [\n {\n slot: 'crmRecordBooking',\n widgetId: 'bookings-book-meeting',\n title: 'Book a meeting',\n Component: BookMeetingButton,\n },\n ],\n navItems: [\n {\n label: 'Bookings',\n href: '/bookings',\n // Reuse the existing release-flag nav-tab so staff-preview gating is\n // unchanged now that the tab comes from the plugin (AGL-395).\n navTabId: 'nav-tab-bookings',\n icon: { path: mdiCalendarClock.path },\n header: {\n title: 'Bookings',\n icon: { path: mdiCalendarClock.path },\n docsTopic: 'bookings',\n },\n Component: BookingsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","mdiCalendarClock","lazy","BUNDLE_ID","registerBookingsRecordRoutes","BOOKINGS_SEARCH_SOURCES","BOOKINGS_CONFIG_SCHEMA","BookingsConsolePage","BookMeetingButton","registerBookingsConsole","registerPluginConfigSchema","registerConsoleExtension","pluginId","displayName","featureFlag","searchSources","widgets","slot","widgetId","title","Component","navItems","label","href","navTabId","icon","path","header","docsTopic"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,4BAA4B,QAAQ,oCAAgC;AAC7E,SAASC,uBAAuB,QAAQ,qCAAiC;AACzE,SAASC,sBAAsB,QAAQ,qBAAiB;AAExD,kEAAkE,GAClE,MAAMC,sBAAsBL,KAC1B,IAAM,MAAM,CAAC;AAGf,wEAAwE,GACxE,MAAMM,oBAAoBN,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,OAAO,SAASO;IACdL;IACA,uEAAuE;IACvE,kEAAkE;IAClEJ,MAAMU,0BAA0B,CAACJ;IACjCN,MAAMW,wBAAwB,CAAC;QAC7BC,UAAUT;QACVU,aAAa;QACbC,aAAa;QACbC,eAAeV;QACf,0EAA0E;QAC1E,yEAAyE;QACzE,uEAAuE;QACvE,2DAA2D;QAC3DW,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWZ;YACb;SACD;QACDa,UAAU;YACR;gBACEC,OAAO;gBACPC,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVC,MAAM;oBAAEC,MAAMzB,iBAAiByB,IAAI;gBAAC;gBACpCC,QAAQ;oBACNR,OAAO;oBACPM,MAAM;wBAAEC,MAAMzB,iBAAiByB,IAAI;oBAAC;oBACpCE,WAAW;gBACb;gBACAR,WAAWb;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/bookings/src/lib/plugin.ts"],"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 { registerPluginTransferResourceUi } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport { mdiCalendarClock } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerBookingsRecordCounts } from './model/bookings-record-counts'\nimport { registerBookingsRecordRoutes } from './model/bookings-record-routes'\nimport { BOOKINGS_SEARCH_SOURCES } from './model/bookings-search-sources'\nimport { BOOKINGS_CONFIG_SCHEMA } from './plugin-config'\nimport { BOOKINGS_TRANSFER_RESOURCE } from './transfer/bookings-transfer-common'\n\n/** Code-split: the Bookings console page only loads when opened. */\nconst BookingsConsolePage = lazy(\n () => import('./components/bookings-console-page'),\n)\n\n/** \"Book a meeting\", loaded where a record page draws the zone for it. */\nconst BookMeetingButton = lazy(() => import('./components/book-meeting-action'))\n\n/**\n * Console half only: registers the Bookings nav item + page. Safe to call\n * at console app load — the page is lazy (no besigner/canvas code).\n */\nexport function registerBookingsConsole(): void {\n registerBookingsRecordRoutes()\n // How many bookings a site took, counted for another plugin's figure.\n registerBookingsRecordCounts()\n // Per-plugin settings (AGL-428): the schema powers the generic form on\n // the Plugins & add-ons hub and defaults-merged reads everywhere.\n Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA)\n // The export dialog's name for the site's bookings; exported, never imported.\n registerPluginTransferResourceUi(\n BOOKINGS_TRANSFER_RESOURCE,\n { label: 'Bookings', icon: { path: mdiCalendarClock.path } },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Bookings',\n featureFlag: 'bookings',\n searchSources: BOOKINGS_SEARCH_SOURCES,\n // The control a CRM record carries for booking a meeting with the person.\n // The CRM hosts the zone and hands it the record; the services, the page\n // they are booked on and the link are this plugin's, so the control is\n // too. It draws nothing where this site takes no bookings.\n widgets: [\n {\n slot: 'crmRecordBooking',\n widgetId: 'bookings-book-meeting',\n title: 'Book a meeting',\n Component: BookMeetingButton,\n },\n ],\n navItems: [\n {\n label: 'Bookings',\n href: '/bookings',\n // Reuse the existing release-flag nav-tab so staff-preview gating is\n // unchanged now that the tab comes from the plugin (AGL-395).\n navTabId: 'nav-tab-bookings',\n icon: { path: mdiCalendarClock.path },\n header: {\n title: 'Bookings',\n icon: { path: mdiCalendarClock.path },\n docsTopic: 'bookings',\n },\n Component: BookingsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginTransferResourceUi","mdiCalendarClock","lazy","BUNDLE_ID","registerBookingsRecordCounts","registerBookingsRecordRoutes","BOOKINGS_SEARCH_SOURCES","BOOKINGS_CONFIG_SCHEMA","BOOKINGS_TRANSFER_RESOURCE","BookingsConsolePage","BookMeetingButton","registerBookingsConsole","registerPluginConfigSchema","label","icon","path","pluginId","registerConsoleExtension","displayName","featureFlag","searchSources","widgets","slot","widgetId","title","Component","navItems","href","navTabId","header","docsTopic"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,gCAAgC,QAAQ,wDAAuD;AACxG,SAASC,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,4BAA4B,QAAQ,oCAAgC;AAC7E,SAASC,4BAA4B,QAAQ,oCAAgC;AAC7E,SAASC,uBAAuB,QAAQ,qCAAiC;AACzE,SAASC,sBAAsB,QAAQ,qBAAiB;AACxD,SAASC,0BAA0B,QAAQ,yCAAqC;AAEhF,kEAAkE,GAClE,MAAMC,sBAAsBP,KAC1B,IAAM,MAAM,CAAC;AAGf,wEAAwE,GACxE,MAAMQ,oBAAoBR,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,OAAO,SAASS;IACdN;IACA,sEAAsE;IACtED;IACA,uEAAuE;IACvE,kEAAkE;IAClEL,MAAMa,0BAA0B,CAACL;IACjC,8EAA8E;IAC9EP,iCACEQ,4BACA;QAAEK,OAAO;QAAYC,MAAM;YAAEC,MAAMd,iBAAiBc,IAAI;QAAC;IAAE,GAC3D;QAAEC,UAAUb;IAAU;IAExBJ,MAAMkB,wBAAwB,CAAC;QAC7BD,UAAUb;QACVe,aAAa;QACbC,aAAa;QACbC,eAAed;QACf,0EAA0E;QAC1E,yEAAyE;QACzE,uEAAuE;QACvE,2DAA2D;QAC3De,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;SACD;QACDgB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVd,MAAM;oBAAEC,MAAMd,iBAAiBc,IAAI;gBAAC;gBACpCc,QAAQ;oBACNL,OAAO;oBACPV,MAAM;wBAAEC,MAAMd,iBAAiBc,IAAI;oBAAC;oBACpCe,WAAW;gBACb;gBACAL,WAAWhB;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}
@@ -31,6 +31,7 @@ import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile
31
31
  import { fileBookingOnCrm } from "./booking-crm.js";
32
32
  import { bookingTimeZoneFor } from "./booking-time-zone.js";
33
33
  import { formatBookingWhen } from "../model/booking-time.js";
34
+ import { bookingContactLines, readBookingPhone } from "../model/booking-contact-fields.js";
34
35
  /**
35
36
  * Paid-booking section of the platform Stripe webhook (AGL-170/418):
36
37
  * payment confirms the pendingPayment hold — relocated verbatim from the
@@ -218,7 +219,7 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
218
219
  var _object_metadata1;
219
220
  const { hostId, bookingId } = (_object_metadata1 = object.metadata) != null ? _object_metadata1 : {};
220
221
  if (hostId && bookingId) {
221
- var _ref6, _ref7, _ref8, _ref9;
222
+ var _ref6, _ref7, _ref8, _ref9, _booking_name, _booking_serviceName;
222
223
  var _object_metadata2, _object_payment_intent1, _object_metadata3;
223
224
  const firestore = firebaseAdmin.app().firestore();
224
225
  const bookingRef = firestore.collection('hosts').doc(String(hostId)).collection('bookings').doc(String(bookingId));
@@ -461,7 +462,7 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
461
462
  // The amount is `amount_total`, the money that moved, per AGL-1698 and
462
463
  // AGL-1711 — the booking document stores no price to re-derive it from.
463
464
  if (booking['email']) {
464
- var _booking_serviceName;
465
+ var _booking_serviceName1;
465
466
  void recordCapturedContact(_extends({
466
467
  orgId: '',
467
468
  hostId: String(hostId),
@@ -472,7 +473,12 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
472
473
  } : {}),
473
474
  // Paid, so a relationship (AGL-3232): the lead the request-time
474
475
  // capture filed for the address is closed onto the contact.
475
- surface: 'relationship',
476
+ surface: 'relationship'
477
+ }, readBookingPhone(booking['phone']) ? {
478
+ profileFill: {
479
+ phone: readBookingPhone(booking['phone'])
480
+ }
481
+ } : {}, {
476
482
  // Paid, so a customer (AGL-2612) — the request-time capture
477
483
  // said `lead`, and this is the door that has seen the money.
478
484
  lifecycleFloor: 'customer'
@@ -482,27 +488,39 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
482
488
  interaction: {
483
489
  source: 'booking',
484
490
  refId: String(bookingId),
485
- summary: `Paid for "${String((_booking_serviceName = booking['serviceName']) != null ? _booking_serviceName : 'a service').slice(0, 60)}" ($${(serviceCents / 100).toFixed(2)})`
491
+ summary: `Paid for "${String((_booking_serviceName1 = booking['serviceName']) != null ? _booking_serviceName1 : 'a service').slice(0, 60)}" ($${(serviceCents / 100).toFixed(2)})`
486
492
  }
487
493
  }));
488
494
  }
495
+ // In the booking's own zone, and named (AGL-3432): a formatter with
496
+ // no zone reads in the server's — UTC — and tells a paying guest the
497
+ // wrong hour, and an empty zone prints "()".
498
+ const timezone = await bookingTimeZoneFor(firestore, String(hostId), booking);
499
+ const when = formatBookingWhen(Number(booking['startsAtMs']), timezone);
500
+ const paid = `$${(paidAmountCents / 100).toFixed(2)}`;
501
+ // The site's managers hear of a PAID booking too (AGL-3500): the
502
+ // free path tells them when the request lands, and a paid one is
503
+ // only a booking once this event has cleared the charge, so this is
504
+ // its moment. Who, what, when, what was paid, and the phone and
505
+ // address the service asked for.
506
+ const contactLines = bookingContactLines(booking);
507
+ void notifyHostManagers(String(hostId), {
508
+ type: 'content.booking',
509
+ title: 'New booking on {site}',
510
+ body: `${String((_booking_name = booking['name']) != null ? _booking_name : '') || 'A guest'}` + (booking['email'] ? ` (${String(booking['email'])})` : '') + ` booked ${String((_booking_serviceName = booking['serviceName']) != null ? _booking_serviceName : '') || 'a service'} ` + `on {site} for ${when} (${timezone}). ${paid} was paid.` + (contactLines.length ? ` ${contactLines.join('. ')}.` : ''),
511
+ link: `/${hostId}/bookings`
512
+ });
489
513
  // Confirmation email now that payment cleared (env-gated inside
490
514
  // sendEmail, which no-ops when Resend isn't configured).
491
515
  if (booking['email']) {
492
- var _booking_name, _booking_serviceName1, _booking_name1, _booking_serviceName2, _ref10, _booking_serviceName3;
516
+ var _booking_name1, _booking_serviceName2, _booking_name2, _booking_serviceName3, _ref10, _booking_serviceName4;
493
517
  var _this;
494
- // In the booking's own zone, and named (AGL-3432): a formatter with
495
- // no zone reads in the server's — UTC — and tells a paying guest the
496
- // wrong hour, and an empty zone prints "()".
497
- const timezone = await bookingTimeZoneFor(firestore, String(hostId), booking);
498
- const when = formatBookingWhen(Number(booking['startsAtMs']), timezone);
499
- const paid = `$${(paidAmountCents / 100).toFixed(2)}`;
500
- const fallbackText = `Hi ${(_booking_name = booking['name']) != null ? _booking_name : ''},\n\nPayment received — ` + `"${(_booking_serviceName1 = booking['serviceName']) != null ? _booking_serviceName1 : 'your booking'}" is ` + `confirmed for ${when} (${timezone}). You paid ${paid}.` + `\n\nReference: ${bookingId}`;
518
+ const fallbackText = `Hi ${(_booking_name1 = booking['name']) != null ? _booking_name1 : ''},\n\nPayment received — ` + `"${(_booking_serviceName2 = booking['serviceName']) != null ? _booking_serviceName2 : 'your booking'}" is ` + `confirmed for ${when} (${timezone}). You paid ${paid}.` + `\n\nReference: ${bookingId}`;
501
519
  // Site-owner-designed template when published (AGL-770); null keeps
502
520
  // the built-in copy.
503
521
  const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'booking-confirmed', {
504
- name: String((_booking_name1 = booking['name']) != null ? _booking_name1 : ''),
505
- 'service.name': String((_booking_serviceName2 = booking['serviceName']) != null ? _booking_serviceName2 : ''),
522
+ name: String((_booking_name2 = booking['name']) != null ? _booking_name2 : ''),
523
+ 'service.name': String((_booking_serviceName3 = booking['serviceName']) != null ? _booking_serviceName3 : ''),
506
524
  when,
507
525
  timezone,
508
526
  'booking.payment': `You paid ${paid}.`,
@@ -513,7 +531,7 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
513
531
  const branding = resolveBrandingProfile((_this = await getOrgForHost(String(hostId)).catch(()=>null)) == null ? void 0 : _this.org);
514
532
  await sendEmail(_extends({
515
533
  to: String(booking['email']),
516
- subject: (_ref10 = designed == null ? void 0 : designed.subject) != null ? _ref10 : `Booking confirmed: ${(_booking_serviceName3 = booking['serviceName']) != null ? _booking_serviceName3 : ''}`,
534
+ subject: (_ref10 = designed == null ? void 0 : designed.subject) != null ? _ref10 : `Booking confirmed: ${(_booking_serviceName4 = booking['serviceName']) != null ? _booking_serviceName4 : ''}`,
517
535
  text: (designed == null ? void 0 : designed.text) || fallbackText
518
536
  }, (designed == null ? void 0 : designed.html) ? {
519
537
  html: designed.html