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

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 (47) hide show
  1. package/package.json +12 -12
  2. package/src/lib/components/book-meeting-action.d.ts +7 -4
  3. package/src/lib/components/book-meeting-action.js +5 -5
  4. package/src/lib/components/book-meeting-action.js.map +1 -1
  5. package/src/lib/components/booking.js +10 -10
  6. package/src/lib/components/booking.js.map +1 -1
  7. package/src/lib/components/bookings-console-page.js +17 -12
  8. package/src/lib/components/bookings-console-page.js.map +1 -1
  9. package/src/lib/model/booking-record.d.ts +74 -0
  10. package/src/lib/model/booking-record.js +131 -0
  11. package/src/lib/model/booking-record.js.map +1 -0
  12. package/src/lib/model/booking-time.d.ts +60 -0
  13. package/src/lib/model/booking-time.js +65 -0
  14. package/src/lib/model/booking-time.js.map +1 -0
  15. package/src/lib/model/bookings-record-routes.d.ts +46 -0
  16. package/src/lib/model/bookings-record-routes.js +63 -0
  17. package/src/lib/model/bookings-record-routes.js.map +1 -0
  18. package/src/lib/model/bookings-search-sources.d.ts +19 -0
  19. package/src/lib/model/bookings-search-sources.js +36 -0
  20. package/src/lib/model/bookings-search-sources.js.map +1 -0
  21. package/src/lib/model/bookings.d.ts +4 -4
  22. package/src/lib/model/bookings.js +3 -3
  23. package/src/lib/model/bookings.js.map +1 -1
  24. package/src/lib/model/index.d.ts +2 -0
  25. package/src/lib/model/index.js +2 -0
  26. package/src/lib/model/index.js.map +1 -1
  27. package/src/lib/plugin.js +4 -0
  28. package/src/lib/plugin.js.map +1 -1
  29. package/src/lib/server/billing-webhook.js +18 -9
  30. package/src/lib/server/billing-webhook.js.map +1 -1
  31. package/src/lib/server/booking-crm.d.ts +40 -24
  32. package/src/lib/server/booking-crm.js +84 -26
  33. package/src/lib/server/booking-crm.js.map +1 -1
  34. package/src/lib/server/booking-time-zone.d.ts +32 -0
  35. package/src/lib/server/booking-time-zone.js +59 -0
  36. package/src/lib/server/booking-time-zone.js.map +1 -0
  37. package/src/lib/server.js +56 -45
  38. package/src/lib/server.js.map +1 -1
  39. package/src/lib/tenant-emails.d.ts +29 -0
  40. package/src/lib/tenant-emails.js +134 -0
  41. package/src/lib/tenant-emails.js.map +1 -0
  42. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  43. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  44. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
  45. package/src/lib/testing/stand-in-tax-profile.d.ts +6 -1
  46. package/src/lib/testing/stand-in-tax-profile.js +5 -1
  47. package/src/lib/testing/stand-in-tax-profile.js.map +1 -1
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginRecordRouteContext } from '@aglyn/aglyn/plugin-manager/plugin-record-routes';
18
+ /**
19
+ * Where a booking is read, published for every other surface (AGL-3080).
20
+ *
21
+ * A contact's timeline opens the bookings it names and a person's page lists
22
+ * the bookings their address holds. Each asks the record-route registry for
23
+ * `booking` rather than spelling this plugin's console path, and gets `null`
24
+ * (text instead of a link) where this plugin is not loaded.
25
+ *
26
+ * Bookings are a site's, so only a site has an address. A booking has no page
27
+ * of its own — the Bookings page lists them — so a record answers the page,
28
+ * and a person's bookings are the page narrowed by
29
+ * {@link BOOKINGS_BOOKER_PARAM}.
30
+ */
31
+ /** The nav slug the shell resolves the Bookings page by. */
32
+ export declare const BOOKINGS_SLUG = "bookings";
33
+ /**
34
+ * The query key the Bookings page narrows to one booker by (AGL-2660) — the
35
+ * same word the CRM's contacts list and the Orders list use: one address,
36
+ * one word.
37
+ */
38
+ export declare const BOOKINGS_BOOKER_PARAM = "email";
39
+ /** The site's Bookings page, or `null` at the organization, where there is none. */
40
+ export declare function bookingsPageHref(context: PluginRecordRouteContext): string | null;
41
+ /**
42
+ * Called from the console registrar. The owner is named rather than read off
43
+ * the loader's marker, so the route registers the same way under a spec that
44
+ * calls the registrar directly.
45
+ */
46
+ export declare function registerBookingsRecordRoutes(): void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
17
+ import { registerPluginRecordRoute } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
18
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
19
+ /**
20
+ * Where a booking is read, published for every other surface (AGL-3080).
21
+ *
22
+ * A contact's timeline opens the bookings it names and a person's page lists
23
+ * the bookings their address holds. Each asks the record-route registry for
24
+ * `booking` rather than spelling this plugin's console path, and gets `null`
25
+ * (text instead of a link) where this plugin is not loaded.
26
+ *
27
+ * Bookings are a site's, so only a site has an address. A booking has no page
28
+ * of its own — the Bookings page lists them — so a record answers the page,
29
+ * and a person's bookings are the page narrowed by
30
+ * {@link BOOKINGS_BOOKER_PARAM}.
31
+ */ /** The nav slug the shell resolves the Bookings page by. */ export const BOOKINGS_SLUG = 'bookings';
32
+ /**
33
+ * The query key the Bookings page narrows to one booker by (AGL-2660) — the
34
+ * same word the CRM's contacts list and the Orders list use: one address,
35
+ * one word.
36
+ */ export const BOOKINGS_BOOKER_PARAM = 'email';
37
+ /** The site's Bookings page, or `null` at the organization, where there is none. */ export function bookingsPageHref(context) {
38
+ return context.host ? buildRoute(Route.HOST_PLUGIN, {
39
+ orgSlug: context.orgSlug,
40
+ host: context.host,
41
+ pluginSlug: BOOKINGS_SLUG
42
+ }) : null;
43
+ }
44
+ /**
45
+ * Called from the console registrar. The owner is named rather than read off
46
+ * the loader's marker, so the route registers the same way under a spec that
47
+ * calls the registrar directly.
48
+ */ export function registerBookingsRecordRoutes() {
49
+ registerPluginRecordRoute('booking', {
50
+ list: bookingsPageHref,
51
+ record: (context)=>bookingsPageHref(context),
52
+ byEmail: (context, email)=>{
53
+ const page = bookingsPageHref(context);
54
+ return page ? `${page}?${new URLSearchParams({
55
+ [BOOKINGS_BOOKER_PARAM]: email
56
+ }).toString()}` : null;
57
+ }
58
+ }, {
59
+ pluginId: BUNDLE_ID
60
+ });
61
+ }
62
+
63
+ //# sourceMappingURL=bookings-record-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/bookings-record-routes.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 { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n registerPluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * Where a booking is read, published for every other surface (AGL-3080).\n *\n * A contact's timeline opens the bookings it names and a person's page lists\n * the bookings their address holds. Each asks the record-route registry for\n * `booking` rather than spelling this plugin's console path, and gets `null`\n * (text instead of a link) where this plugin is not loaded.\n *\n * Bookings are a site's, so only a site has an address. A booking has no page\n * of its own — the Bookings page lists them — so a record answers the page,\n * and a person's bookings are the page narrowed by\n * {@link BOOKINGS_BOOKER_PARAM}.\n */\n\n/** The nav slug the shell resolves the Bookings page by. */\nexport const BOOKINGS_SLUG = 'bookings'\n\n/**\n * The query key the Bookings page narrows to one booker by (AGL-2660) — the\n * same word the CRM's contacts list and the Orders list use: one address,\n * one word.\n */\nexport const BOOKINGS_BOOKER_PARAM = 'email'\n\n/** The site's Bookings page, or `null` at the organization, where there is none. */\nexport function bookingsPageHref(context: PluginRecordRouteContext): string | null {\n return context.host\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug: context.orgSlug,\n host: context.host,\n pluginSlug: BOOKINGS_SLUG,\n })\n : null\n}\n\n/**\n * Called from the console registrar. The owner is named rather than read off\n * the loader's marker, so the route registers the same way under a spec that\n * calls the registrar directly.\n */\nexport function registerBookingsRecordRoutes(): void {\n registerPluginRecordRoute(\n 'booking',\n {\n list: bookingsPageHref,\n record: (context) => bookingsPageHref(context),\n byEmail: (context, email) => {\n const page = bookingsPageHref(context)\n return page\n ? `${page}?${new URLSearchParams({ [BOOKINGS_BOOKER_PARAM]: email }).toString()}`\n : null\n },\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["buildRoute","Route","registerPluginRecordRoute","BUNDLE_ID","BOOKINGS_SLUG","BOOKINGS_BOOKER_PARAM","bookingsPageHref","context","host","HOST_PLUGIN","orgSlug","pluginSlug","registerBookingsRecordRoutes","list","record","byEmail","email","page","URLSearchParams","toString","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SACEC,yBAAyB,QAEpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;CAYC,GAED,0DAA0D,GAC1D,OAAO,MAAMC,gBAAgB,WAAU;AAEvC;;;;CAIC,GACD,OAAO,MAAMC,wBAAwB,QAAO;AAE5C,kFAAkF,GAClF,OAAO,SAASC,iBAAiBC,OAAiC;IAChE,OAAOA,QAAQC,IAAI,GACfR,WAAWC,MAAMQ,WAAW,EAAE;QAC5BC,SAASH,QAAQG,OAAO;QACxBF,MAAMD,QAAQC,IAAI;QAClBG,YAAYP;IACd,KACA;AACN;AAEA;;;;CAIC,GACD,OAAO,SAASQ;IACdV,0BACE,WACA;QACEW,MAAMP;QACNQ,QAAQ,CAACP,UAAYD,iBAAiBC;QACtCQ,SAAS,CAACR,SAASS;YACjB,MAAMC,OAAOX,iBAAiBC;YAC9B,OAAOU,OACH,GAAGA,KAAK,CAAC,EAAE,IAAIC,gBAAgB;gBAAE,CAACb,sBAAsB,EAAEW;YAAM,GAAGG,QAAQ,IAAI,GAC/E;QACN;IACF,GACA;QAAEC,UAAUjB;IAAU;AAE1B"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ * Unless required by applicable law or agreed to in writing, software
9
+ * distributed under the License is distributed on an "AS IS" BASIS,
10
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ * See the License for the specific language governing permissions and
12
+ * limitations under the License.
13
+ */
14
+ import type { ConsoleSearchSource } from '@aglyn/aglyn';
15
+ /**
16
+ * What the console's search finds in a site's bookings (AGL-3080): its
17
+ * bookable services, by name, opening on the site's Bookings page.
18
+ */
19
+ export declare const BOOKINGS_SEARCH_SOURCES: readonly ConsoleSearchSource[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ * Unless required by applicable law or agreed to in writing, software
9
+ * distributed under the License is distributed on an "AS IS" BASIS,
10
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ * See the License for the specific language governing permissions and
12
+ * limitations under the License.
13
+ */ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
14
+ import { BOOKINGS_SLUG } from "./bookings-record-routes.js";
15
+ /**
16
+ * What the console's search finds in a site's bookings (AGL-3080): its
17
+ * bookable services, by name, opening on the site's Bookings page.
18
+ */ export const BOOKINGS_SEARCH_SOURCES = [
19
+ {
20
+ id: 'services',
21
+ group: 'Services',
22
+ noun: 'services',
23
+ scope: 'host',
24
+ collection: 'services',
25
+ nameField: 'name',
26
+ entitlementKey: 'servicesPerHost',
27
+ order: 180,
28
+ href: (_row, { orgSlug, host })=>host ? buildRoute(Route.HOST_PLUGIN, {
29
+ orgSlug,
30
+ host,
31
+ pluginSlug: BOOKINGS_SLUG
32
+ }) : null
33
+ }
34
+ ];
35
+
36
+ //# sourceMappingURL=bookings-search-sources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/bookings-search-sources.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\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 * http://www.apache.org/licenses/LICENSE-2.0\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 type { ConsoleSearchSource } from '@aglyn/aglyn'\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport { BOOKINGS_SLUG } from './bookings-record-routes'\n\n/**\n * What the console's search finds in a site's bookings (AGL-3080): its\n * bookable services, by name, opening on the site's Bookings page.\n */\nexport const BOOKINGS_SEARCH_SOURCES: readonly ConsoleSearchSource[] = [\n {\n id: 'services',\n group: 'Services',\n noun: 'services',\n scope: 'host',\n collection: 'services',\n nameField: 'name',\n entitlementKey: 'servicesPerHost',\n order: 180,\n href: (_row, { orgSlug, host }) =>\n host\n ? buildRoute(Route.HOST_PLUGIN, { orgSlug, host, pluginSlug: BOOKINGS_SLUG })\n : null,\n },\n]\n"],"names":["buildRoute","Route","BOOKINGS_SLUG","BOOKINGS_SEARCH_SOURCES","id","group","noun","scope","collection","nameField","entitlementKey","order","href","_row","orgSlug","host","HOST_PLUGIN","pluginSlug"],"mappings":"AAAA;;;;;;;;;;;;CAYC,GAGD,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SAASC,aAAa,QAAQ,8BAA0B;AAExD;;;CAGC,GACD,OAAO,MAAMC,0BAA0D;IACrE;QACEC,IAAI;QACJC,OAAO;QACPC,MAAM;QACNC,OAAO;QACPC,YAAY;QACZC,WAAW;QACXC,gBAAgB;QAChBC,OAAO;QACPC,MAAM,CAACC,MAAM,EAAEC,OAAO,EAAEC,IAAI,EAAE,GAC5BA,OACIf,WAAWC,MAAMe,WAAW,EAAE;gBAAEF;gBAASC;gBAAME,YAAYf;YAAc,KACzE;IACR;CACD,CAAA"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type CrmBookingRef } from '@aglyn/aglyn/app-utils/crm-booking';
17
+ import { type BookingRecordRef } from './booking-record';
18
18
  /**
19
19
  * Bookings v1 (AGL-159): services with weekly availability windows and
20
20
  * pure, timezone-explicit slot computation. All times are minutes since
@@ -122,8 +122,8 @@ export interface BookingLinkInput {
122
122
  };
123
123
  /** The site's `bookingPath` setting; the root when unset. */
124
124
  path?: string | null;
125
- /** The CRM record the link is dropped from, when there is one. */
126
- crmRef?: CrmBookingRef | null;
125
+ /** The record the link is dropped from, when there is one. */
126
+ recordRef?: BookingRecordRef | null;
127
127
  }
128
128
  /**
129
129
  * The public URL a visitor books a service at (AGL-2660), or `null` for a
@@ -134,7 +134,7 @@ export interface BookingLinkInput {
134
134
  * and nothing narrower. The Bookings plugin renders no route of its own
135
135
  * either; the widget is a block the site owner placed on a page, so the
136
136
  * link is that page (the `bookingPath` setting) with the service
137
- * preselected and, when dropped from a CRM record, the record carried
137
+ * preselected and, when dropped from a record, the record carried
138
138
  * along so the booking can be attributed even when the booker uses a
139
139
  * different address.
140
140
  */
@@ -16,11 +16,11 @@
16
16
  */ // Leaf paths, not the barrel: this model is read by the client bundle and
17
17
  // the `/server` entry alike, and either barrel would drag the other's
18
18
  // surface into a bundle that has no use for it.
19
- import { BOOKING_SERVICE_PARAM, CRM_BOOKING_REF_PARAM, formatCrmBookingRef } from "@aglyn/aglyn/app-utils/crm-booking";
20
19
  import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
21
20
  // The leaf entry: the package root extends the Firestore SDK's `Timestamp`,
22
21
  // and this model is read on published pages.
23
22
  import { zonedDateTime } from "@aglyn/shared-util-timestamp/zoned-time";
23
+ import { BOOKING_RECORD_PARAM, BOOKING_SERVICE_PARAM, formatBookingRecordRef } from "./booking-record.js";
24
24
  export const BOOKING_MIN_DURATION_MINUTES = 5;
25
25
  export const BOOKING_MAX_DURATION_MINUTES = 8 * 60;
26
26
  /** Slot enumeration horizon; the console/API never look further out. */ export const BOOKING_MAX_DAYS_AHEAD = 60;
@@ -108,7 +108,7 @@ export const REMINDER_WINDOW_END_HOURS = 25;
108
108
  * and nothing narrower. The Bookings plugin renders no route of its own
109
109
  * either; the widget is a block the site owner placed on a page, so the
110
110
  * link is that page (the `bookingPath` setting) with the service
111
- * preselected and, when dropped from a CRM record, the record carried
111
+ * preselected and, when dropped from a record, the record carried
112
112
  * along so the booking can be attributed even when the booker uses a
113
113
  * different address.
114
114
  */ export function bookingLinkFor(input) {
@@ -117,7 +117,7 @@ export const REMINDER_WINDOW_END_HOURS = 25;
117
117
  const query = new URLSearchParams({
118
118
  [BOOKING_SERVICE_PARAM]: input.service.id
119
119
  });
120
- if (input.crmRef) query.set(CRM_BOOKING_REF_PARAM, formatCrmBookingRef(input.crmRef));
120
+ if (input.recordRef) query.set(BOOKING_RECORD_PARAM, formatBookingRecordRef(input.recordRef));
121
121
  return `${origin}${normalizeBookingPath(input.path)}?${query.toString()}`;
122
122
  }
123
123
 
@@ -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 {\n BOOKING_SERVICE_PARAM,\n CRM_BOOKING_REF_PARAM,\n type CrmBookingRef,\n formatCrmBookingRef,\n} from '@aglyn/aglyn/app-utils/crm-booking'\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'\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 CRM record the link is dropped from, when there is one. */\n crmRef?: CrmBookingRef | 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 CRM 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.crmRef) query.set(CRM_BOOKING_REF_PARAM, formatCrmBookingRef(input.crmRef))\n return `${origin}${normalizeBookingPath(input.path)}?${query.toString()}`\n}\n"],"names":["BOOKING_SERVICE_PARAM","CRM_BOOKING_REF_PARAM","formatCrmBookingRef","hostPublicOrigin","zonedDateTime","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","crmRef","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 * 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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"}
@@ -20,3 +20,5 @@
20
20
  * other plugins/apps via `@aglyn/plugins-bookings/model`.
21
21
  */
22
22
  export * from './bookings';
23
+ export * from './booking-record';
24
+ export * from './booking-time';
@@ -18,5 +18,7 @@
18
18
  * Context-free — importable by client components, /server handlers, and
19
19
  * other plugins/apps via `@aglyn/plugins-bookings/model`.
20
20
  */ export * from "./bookings.js";
21
+ export * from "./booking-record.js";
22
+ export * from "./booking-time.js";
21
23
 
22
24
  //# 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'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,gBAAY"}
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"}
package/src/lib/plugin.js CHANGED
@@ -17,6 +17,8 @@
17
17
  import { mdiCalendarClock } from "@aglyn/shared-data-mdi";
18
18
  import { lazy } from "react";
19
19
  import { BUNDLE_ID } from "./constants/bundle-common.js";
20
+ import { registerBookingsRecordRoutes } from "./model/bookings-record-routes.js";
21
+ import { BOOKINGS_SEARCH_SOURCES } from "./model/bookings-search-sources.js";
20
22
  import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
21
23
  /** Code-split: the Bookings console page only loads when opened. */ const BookingsConsolePage = lazy(()=>import("./components/bookings-console-page.js"));
22
24
  /** "Book a meeting", loaded where a record page draws the zone for it. */ const BookMeetingButton = lazy(()=>import("./components/book-meeting-action.js"));
@@ -24,6 +26,7 @@ import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
24
26
  * Console half only: registers the Bookings nav item + page. Safe to call
25
27
  * at console app load — the page is lazy (no besigner/canvas code).
26
28
  */ export function registerBookingsConsole() {
29
+ registerBookingsRecordRoutes();
27
30
  // Per-plugin settings (AGL-428): the schema powers the generic form on
28
31
  // the Plugins & add-ons hub and defaults-merged reads everywhere.
29
32
  Aglyn.registerPluginConfigSchema(BOOKINGS_CONFIG_SCHEMA);
@@ -31,6 +34,7 @@ import { BOOKINGS_CONFIG_SCHEMA } from "./plugin-config.js";
31
34
  pluginId: BUNDLE_ID,
32
35
  displayName: 'Bookings',
33
36
  featureFlag: 'bookings',
37
+ searchSources: BOOKINGS_SEARCH_SOURCES,
34
38
  // The control a CRM record carries for booking a meeting with the person.
35
39
  // The CRM hosts the zone and hands it the record; the services, the page
36
40
  // they are booked on and the link are this plugin's, so the control is
@@ -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 { 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 // 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 // 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","BOOKINGS_CONFIG_SCHEMA","BookingsConsolePage","BookMeetingButton","registerBookingsConsole","registerPluginConfigSchema","registerConsoleExtension","pluginId","displayName","featureFlag","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,sBAAsB,QAAQ,qBAAiB;AAExD,kEAAkE,GAClE,MAAMC,sBAAsBH,KAC1B,IAAM,MAAM,CAAC;AAGf,wEAAwE,GACxE,MAAMI,oBAAoBJ,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,OAAO,SAASK;IACd,uEAAuE;IACvE,kEAAkE;IAClEP,MAAMQ,0BAA0B,CAACJ;IACjCJ,MAAMS,wBAAwB,CAAC;QAC7BC,UAAUP;QACVQ,aAAa;QACbC,aAAa;QACb,0EAA0E;QAC1E,yEAAyE;QACzE,uEAAuE;QACvE,2DAA2D;QAC3DC,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWX;YACb;SACD;QACDY,UAAU;YACR;gBACEC,OAAO;gBACPC,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVC,MAAM;oBAAEC,MAAMtB,iBAAiBsB,IAAI;gBAAC;gBACpCC,QAAQ;oBACNR,OAAO;oBACPM,MAAM;wBAAEC,MAAMtB,iBAAiBsB,IAAI;oBAAC;oBACpCE,WAAW;gBACb;gBACAR,WAAWZ;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 { 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"}
@@ -29,6 +29,8 @@ import { bookingPlatformNetCents, shouldSendBookingPlatformPurchase } from "../m
29
29
  import { sendEmail } from "@aglyn/shared-util-email";
30
30
  import { pluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
31
31
  import { fileBookingOnCrm } from "./booking-crm.js";
32
+ import { bookingTimeZoneFor } from "./booking-time-zone.js";
33
+ import { formatBookingWhen } from "../model/booking-time.js";
32
34
  /**
33
35
  * Paid-booking section of the platform Stripe webhook (AGL-170/418):
34
36
  * payment confirms the pendingPayment hold — relocated verbatim from the
@@ -195,10 +197,14 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
195
197
  };
196
198
  const recovered = await reverseBookingSellerShare(booking.ref, object);
197
199
  if (recovered.cents > 0 && hostId) {
200
+ var _booking_get1, _booking_get2;
201
+ const recoveredText = `$${(recovered.cents / 100).toFixed(2)}`;
202
+ const guest = String((_booking_get1 = booking.get('name')) != null ? _booking_get1 : '').trim();
203
+ const service = String((_booking_get2 = booking.get('serviceName')) != null ? _booking_get2 : '').trim() || 'a service';
198
204
  await notifyHostManagers(hostId, {
199
205
  type: 'content.booking',
200
- title: `Payout adjusted — $${(recovered.cents / 100).toFixed(2)} recovered for a lost chargeback`,
201
- body: `A booking deposit was charged back and the dispute was lost, so the amount ` + `transferred to you for it has been reversed. If your balance does not cover ` + `it, Stripe recovers the remainder from your future payouts.`,
206
+ title: `Payout adjusted — ${recoveredText} recovered for a lost chargeback`,
207
+ body: `${guest ? `${guest}'s payment` : 'A payment'} for ${service} on ` + `{site} was charged back and the dispute was lost, so ` + `the ${recoveredText} transferred to you for it has been taken back. ` + `If your balance does not cover it, Stripe recovers the rest from ` + `your future payouts.`,
202
208
  link: `/${hostId}/bookings`
203
209
  });
204
210
  }
@@ -478,20 +484,23 @@ export const bookingsBillingWebhookHandler = async ({ type, object })=>{
478
484
  // Confirmation email now that payment cleared (env-gated inside
479
485
  // sendEmail, which no-ops when Resend isn't configured).
480
486
  if (booking['email']) {
481
- var _booking_name, _booking_serviceName1, _booking_name1, _booking_serviceName2, _booking_timezone, _ref10, _booking_serviceName3;
487
+ var _booking_name, _booking_serviceName1, _booking_name1, _booking_serviceName2, _ref10, _booking_serviceName3;
482
488
  var _this;
483
- const when = new Date(Number(booking['startsAtMs'])).toLocaleString('en-US', {
484
- dateStyle: 'full',
485
- timeStyle: 'short'
486
- });
487
- 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}.\n\nReference: ${bookingId}`;
489
+ // In the booking's own zone, and named (AGL-3432): a formatter with
490
+ // no zone reads in the server's — UTC — and tells a paying guest the
491
+ // wrong hour, and an empty zone prints "()".
492
+ const timezone = await bookingTimeZoneFor(firestore, String(hostId), booking);
493
+ const when = formatBookingWhen(Number(booking['startsAtMs']), timezone);
494
+ const paid = `$${(paidAmountCents / 100).toFixed(2)}`;
495
+ 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}`;
488
496
  // Site-owner-designed template when published (AGL-770); null keeps
489
497
  // the built-in copy.
490
498
  const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), String(hostId), 'booking-confirmed', {
491
499
  name: String((_booking_name1 = booking['name']) != null ? _booking_name1 : ''),
492
500
  'service.name': String((_booking_serviceName2 = booking['serviceName']) != null ? _booking_serviceName2 : ''),
493
501
  when,
494
- timezone: String((_booking_timezone = booking['timezone']) != null ? _booking_timezone : ''),
502
+ timezone,
503
+ 'booking.payment': `You paid ${paid}.`,
495
504
  'booking.ref': String(bookingId)
496
505
  });
497
506
  // White-label sender identity (White-Label Phase 3), matching the