@aglyn/plugins-bookings 1.0.0-beta.219 → 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.
- package/package.json +12 -12
- package/src/lib/components/book-meeting-action.d.ts +7 -4
- package/src/lib/components/book-meeting-action.js +2 -2
- package/src/lib/components/book-meeting-action.js.map +1 -1
- package/src/lib/components/booking.js +10 -10
- package/src/lib/components/booking.js.map +1 -1
- package/src/lib/model/booking-record.d.ts +74 -0
- package/src/lib/model/booking-record.js +131 -0
- package/src/lib/model/booking-record.js.map +1 -0
- package/src/lib/model/booking-time.d.ts +60 -0
- package/src/lib/model/booking-time.js +65 -0
- package/src/lib/model/booking-time.js.map +1 -0
- package/src/lib/model/bookings-record-routes.d.ts +15 -0
- package/src/lib/model/bookings-record-routes.js +1 -1
- package/src/lib/model/bookings-record-routes.js.map +1 -1
- package/src/lib/model/bookings-search-sources.js +4 -2
- package/src/lib/model/bookings-search-sources.js.map +1 -1
- package/src/lib/model/bookings.d.ts +4 -4
- package/src/lib/model/bookings.js +3 -3
- package/src/lib/model/bookings.js.map +1 -1
- package/src/lib/model/index.d.ts +2 -0
- package/src/lib/model/index.js +2 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/server/billing-webhook.js +18 -9
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/booking-crm.d.ts +40 -24
- package/src/lib/server/booking-crm.js +84 -26
- package/src/lib/server/booking-crm.js.map +1 -1
- package/src/lib/server/booking-time-zone.d.ts +32 -0
- package/src/lib/server/booking-time-zone.js +59 -0
- package/src/lib/server/booking-time-zone.js.map +1 -0
- package/src/lib/server.js +46 -35
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.js +19 -4
- package/src/lib/tenant-emails.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/booking-record.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 * WHAT A BOOKING CARRIES BACK TO THE RECORD IT CAME FROM (AGL-2660).\n *\n * A rep drops a booking link into an email from a record; the visitor books\n * through it; the booking comes back to the record as a meeting on its\n * timeline and, when the service asks for one, a follow-up task. The record\n * system draws the \"Book a meeting\" zone and hands it the record by its kind\n * and id; this plugin builds the link, carries the record on it, and files\n * the meeting back through the core's record-timeline seam, where the owner\n * finds the record. Neither plugin imports the other: what this module holds\n * — the link's query keys, the wire form of the record it carries, and the\n * words and the day of what is filed — is this plugin's own.\n */\n\nimport {\n isValidTimeZone,\n zonedDateTime,\n} from '@aglyn/shared-util-timestamp/zoned-time'\n\n/**\n * The query key a booking link carries the record on: `?crm=contact:abc`.\n *\n * The reference rides the LINK rather than relying on the booker's address\n * because the two routinely differ — a rep emails somebody's work address\n * and they book with a personal one — and a booking that could only be\n * matched by address would then land on nobody. The widget copies the value\n * onto the booking request, the booking row keeps it as `crmRef`, and the\n * server hands it to the record system to file the meeting under. The key\n * is the one links already sent carry, so it is never renamed.\n */\nexport const BOOKING_RECORD_PARAM = 'crm'\n\n/**\n * The query key that preselects a service in the booking widget:\n * `?service={serviceId}`. The widget lists every active service and this\n * opens it on the one the link was about.\n */\nexport const BOOKING_SERVICE_PARAM = 'service'\n\n/** A record of the record system's, by its kind and id in its own words. */\nexport interface BookingRecordRef {\n kind: string\n id: string\n}\n\n/**\n * What a kind and an id may look like inside the reference: a lowercase\n * word, and an id with nothing a path or a query could be built from. The\n * value arrives on a public, unauthenticated request; which kinds a record\n * system keeps is its own to say when the booking is filed.\n */\nconst REF_KIND_PATTERN = /^[a-z][a-z-]{0,31}$/\nconst REF_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/** `contact:abc` — the wire form of a reference. */\nexport function formatBookingRecordRef(ref: BookingRecordRef): string {\n return `${ref.kind}:${ref.id}`\n}\n\n/**\n * The reference a query value names, or `null` for anything that is not one.\n *\n * Strict on purpose: the value arrives on a public, unauthenticated booking\n * request, so a malformed one is dropped rather than stored — a booking\n * still lands, and is matched by the booker's address instead.\n */\nexport function parseBookingRecordRef(raw: unknown): BookingRecordRef | null {\n if (typeof raw !== 'string') return null\n const separator = raw.indexOf(':')\n if (separator <= 0) return null\n const kind = raw.slice(0, separator)\n const id = raw.slice(separator + 1)\n if (!REF_KIND_PATTERN.test(kind) || !REF_ID_PATTERN.test(id)) return null\n return { kind, id }\n}\n\n/** The slot, as the meeting entry and the task print it. */\nfunction formatSlot(startsAtMs: number, timezone: string): string {\n try {\n return new Intl.DateTimeFormat('en-US', {\n timeZone: timezone,\n dateStyle: 'full',\n timeStyle: 'short',\n }).format(new Date(startsAtMs))\n } catch {\n // An unknown zone name on the service falls back to UTC rather than\n // failing the write: a meeting filed with a slightly wrong clock is\n // better than no meeting at all.\n return new Intl.DateTimeFormat('en-US', {\n timeZone: 'UTC',\n dateStyle: 'full',\n timeStyle: 'short',\n }).format(new Date(startsAtMs))\n }\n}\n\n/**\n * What the meeting entry on the timeline says — the service and the slot,\n * in the service's own timezone, with the zone named so a rep in another\n * one is not misled. The body, not the subject: `subject` is the field a\n * sent email carries, and the timeline draws it as one.\n */\nexport function bookingMeetingBody(input: {\n serviceName: string\n startsAtMs: number\n timezone?: string | null\n}): string {\n const timezone = input.timezone || 'UTC'\n const service = String(input.serviceName ?? '').trim() || 'Booking'\n return `${service} — ${formatSlot(input.startsAtMs, timezone)} (${timezone})`\n}\n\n/** The title of the task a service files when it asks for a follow-up. */\nexport function bookingFollowUpTitle(serviceName: string): string {\n const service = String(serviceName ?? '').trim() || 'the booking'\n return `Follow up after ${service}`\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\n/**\n * Weekday (0 = Sunday … 6 = Saturday) of an instant in a timezone, read in\n * UTC when the zone name is one `Intl` does not know — for the reason\n * `formatSlot` gives.\n */\nfunction weekdayIn(atMs: number, timezone: string): number {\n return zonedDateTime(atMs, isValidTimeZone(timezone) ? timezone : 'UTC').weekday\n}\n\n/**\n * When the follow-up falls due: ONE BUSINESS DAY after the slot, at the\n * slot's own clock time.\n *\n * A meeting on a Friday is followed up on Monday, one on a Saturday or a\n * Sunday on Monday too; any other day is followed up the next. Whole days\n * are added to the instant rather than a calendar walked, so the arithmetic\n * cannot land on a clock time that does not exist across a DST change —\n * the task is due \"about this time tomorrow\", and an hour either way is not\n * a fact the task keeps.\n */\nexport function bookingFollowUpDueMs(\n endsAtMs: number,\n timezone?: string | null,\n): number {\n const weekday = weekdayIn(endsAtMs, timezone || 'UTC')\n // Friday (5) skips the weekend; Saturday (6) reaches Monday in two days.\n const daysAhead = weekday === 5 ? 3 : weekday === 6 ? 2 : 1\n return endsAtMs + daysAhead * DAY_MS\n}\n"],"names":["isValidTimeZone","zonedDateTime","BOOKING_RECORD_PARAM","BOOKING_SERVICE_PARAM","REF_KIND_PATTERN","REF_ID_PATTERN","formatBookingRecordRef","ref","kind","id","parseBookingRecordRef","raw","separator","indexOf","slice","test","formatSlot","startsAtMs","timezone","Intl","DateTimeFormat","timeZone","dateStyle","timeStyle","format","Date","bookingMeetingBody","input","service","String","serviceName","trim","bookingFollowUpTitle","DAY_MS","weekdayIn","atMs","weekday","bookingFollowUpDueMs","endsAtMs","daysAhead"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SACEA,eAAe,EACfC,aAAa,QACR,0CAAyC;AAEhD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,uBAAuB,MAAK;AAEzC;;;;CAIC,GACD,OAAO,MAAMC,wBAAwB,UAAS;AAQ9C;;;;;CAKC,GACD,MAAMC,mBAAmB;AACzB,MAAMC,iBAAiB;AAEvB,kDAAkD,GAClD,OAAO,SAASC,uBAAuBC,GAAqB;IAC1D,OAAO,GAAGA,IAAIC,IAAI,CAAC,CAAC,EAAED,IAAIE,EAAE,EAAE;AAChC;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,sBAAsBC,GAAY;IAChD,IAAI,OAAOA,QAAQ,UAAU,OAAO;IACpC,MAAMC,YAAYD,IAAIE,OAAO,CAAC;IAC9B,IAAID,aAAa,GAAG,OAAO;IAC3B,MAAMJ,OAAOG,IAAIG,KAAK,CAAC,GAAGF;IAC1B,MAAMH,KAAKE,IAAIG,KAAK,CAACF,YAAY;IACjC,IAAI,CAACR,iBAAiBW,IAAI,CAACP,SAAS,CAACH,eAAeU,IAAI,CAACN,KAAK,OAAO;IACrE,OAAO;QAAED;QAAMC;IAAG;AACpB;AAEA,0DAA0D,GAC1D,SAASO,WAAWC,UAAkB,EAAEC,QAAgB;IACtD,IAAI;QACF,OAAO,IAAIC,KAAKC,cAAc,CAAC,SAAS;YACtCC,UAAUH;YACVI,WAAW;YACXC,WAAW;QACb,GAAGC,MAAM,CAAC,IAAIC,KAAKR;IACrB,EAAE,eAAM;QACN,oEAAoE;QACpE,oEAAoE;QACpE,iCAAiC;QACjC,OAAO,IAAIE,KAAKC,cAAc,CAAC,SAAS;YACtCC,UAAU;YACVC,WAAW;YACXC,WAAW;QACb,GAAGC,MAAM,CAAC,IAAIC,KAAKR;IACrB;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASS,mBAAmBC,KAIlC;QAEwBA;IADvB,MAAMT,WAAWS,MAAMT,QAAQ,IAAI;IACnC,MAAMU,UAAUC,QAAOF,qBAAAA,MAAMG,WAAW,YAAjBH,qBAAqB,IAAII,IAAI,MAAM;IAC1D,OAAO,GAAGH,QAAQ,GAAG,EAAEZ,WAAWW,MAAMV,UAAU,EAAEC,UAAU,EAAE,EAAEA,SAAS,CAAC,CAAC;AAC/E;AAEA,wEAAwE,GACxE,OAAO,SAASc,qBAAqBF,WAAmB;IACtD,MAAMF,UAAUC,OAAOC,sBAAAA,cAAe,IAAIC,IAAI,MAAM;IACpD,OAAO,CAAC,gBAAgB,EAAEH,SAAS;AACrC;AAEA,MAAMK,SAAS,KAAK,KAAK,KAAK;AAE9B;;;;CAIC,GACD,SAASC,UAAUC,IAAY,EAAEjB,QAAgB;IAC/C,OAAOjB,cAAckC,MAAMnC,gBAAgBkB,YAAYA,WAAW,OAAOkB,OAAO;AAClF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,qBACdC,QAAgB,EAChBpB,QAAwB;IAExB,MAAMkB,UAAUF,UAAUI,UAAUpB,YAAY;IAChD,yEAAyE;IACzE,MAAMqB,YAAYH,YAAY,IAAI,IAAIA,YAAY,IAAI,IAAI;IAC1D,OAAOE,WAAWC,YAAYN;AAChC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The zone a booking's time is told in, to the guest and to the site's
|
|
19
|
+
* managers alike (AGL-3432).
|
|
20
|
+
*
|
|
21
|
+
* THE SERVICE WINS: its weekly windows are defined in its own zone, so that is
|
|
22
|
+
* the zone the slot was offered in. A service that names none (or names one
|
|
23
|
+
* this runtime no longer accepts) reads in the site's zone, then the
|
|
24
|
+
* workspace's, then UTC — `resolveSiteTimeZone`'s chain, so a booking and the
|
|
25
|
+
* site's own dates agree.
|
|
26
|
+
*
|
|
27
|
+
* Every candidate is validated, never trusted: a zone that cannot format would
|
|
28
|
+
* throw a `RangeError` in the middle of a send.
|
|
29
|
+
*/
|
|
30
|
+
export declare function bookingTimeZone(input: {
|
|
31
|
+
service?: {
|
|
32
|
+
timezone?: unknown;
|
|
33
|
+
} | null;
|
|
34
|
+
host?: {
|
|
35
|
+
timeZone?: string;
|
|
36
|
+
} | null;
|
|
37
|
+
org?: {
|
|
38
|
+
timeZone?: string;
|
|
39
|
+
} | null;
|
|
40
|
+
}): string;
|
|
41
|
+
/**
|
|
42
|
+
* The zone a booking document carries, when it carries a usable one.
|
|
43
|
+
*
|
|
44
|
+
* Bookings written before the zone was stored have none, and a reader then
|
|
45
|
+
* resolves it from the service and site with {@link bookingTimeZone}.
|
|
46
|
+
*/
|
|
47
|
+
export declare function storedBookingTimeZone(booking: {
|
|
48
|
+
timezone?: unknown;
|
|
49
|
+
} | null | undefined): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* A booking's start as a guest reads it — "Friday, April 24, 2026 at 3:00 PM"
|
|
52
|
+
* — in `timeZone`. The caller names the zone beside it; the string alone does
|
|
53
|
+
* not say which zone it is in.
|
|
54
|
+
*
|
|
55
|
+
* Never the server's own zone: a formatter with no `timeZone` reads in UTC on
|
|
56
|
+
* the platform's servers and prints a wall-clock time hours away from the
|
|
57
|
+
* appointment. An unusable zone formats in UTC, which is what the caller then
|
|
58
|
+
* names.
|
|
59
|
+
*/
|
|
60
|
+
export declare function formatBookingWhen(startsAtMs: number, timeZone: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
*/ // The leaf, not the barrel: this model is read by the client bundle too, and
|
|
17
|
+
// the zone rules are a dependency-free module.
|
|
18
|
+
import { isSupportedTimeZone, resolveSiteTimeZone } from "@aglyn/aglyn/app-utils/collection-entry-date";
|
|
19
|
+
/**
|
|
20
|
+
* The zone a booking's time is told in, to the guest and to the site's
|
|
21
|
+
* managers alike (AGL-3432).
|
|
22
|
+
*
|
|
23
|
+
* THE SERVICE WINS: its weekly windows are defined in its own zone, so that is
|
|
24
|
+
* the zone the slot was offered in. A service that names none (or names one
|
|
25
|
+
* this runtime no longer accepts) reads in the site's zone, then the
|
|
26
|
+
* workspace's, then UTC — `resolveSiteTimeZone`'s chain, so a booking and the
|
|
27
|
+
* site's own dates agree.
|
|
28
|
+
*
|
|
29
|
+
* Every candidate is validated, never trusted: a zone that cannot format would
|
|
30
|
+
* throw a `RangeError` in the middle of a send.
|
|
31
|
+
*/ export function bookingTimeZone(input) {
|
|
32
|
+
var _ref, _input_org, _input_host;
|
|
33
|
+
var _input_service;
|
|
34
|
+
const serviceZone = String((_ref = (_input_service = input.service) == null ? void 0 : _input_service.timezone) != null ? _ref : '').trim();
|
|
35
|
+
if (isSupportedTimeZone(serviceZone)) return serviceZone;
|
|
36
|
+
return resolveSiteTimeZone((_input_org = input.org) != null ? _input_org : null, (_input_host = input.host) != null ? _input_host : null);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The zone a booking document carries, when it carries a usable one.
|
|
40
|
+
*
|
|
41
|
+
* Bookings written before the zone was stored have none, and a reader then
|
|
42
|
+
* resolves it from the service and site with {@link bookingTimeZone}.
|
|
43
|
+
*/ export function storedBookingTimeZone(booking) {
|
|
44
|
+
var _ref;
|
|
45
|
+
const zone = String((_ref = booking == null ? void 0 : booking.timezone) != null ? _ref : '').trim();
|
|
46
|
+
return isSupportedTimeZone(zone) ? zone : undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A booking's start as a guest reads it — "Friday, April 24, 2026 at 3:00 PM"
|
|
50
|
+
* — in `timeZone`. The caller names the zone beside it; the string alone does
|
|
51
|
+
* not say which zone it is in.
|
|
52
|
+
*
|
|
53
|
+
* Never the server's own zone: a formatter with no `timeZone` reads in UTC on
|
|
54
|
+
* the platform's servers and prints a wall-clock time hours away from the
|
|
55
|
+
* appointment. An unusable zone formats in UTC, which is what the caller then
|
|
56
|
+
* names.
|
|
57
|
+
*/ export function formatBookingWhen(startsAtMs, timeZone) {
|
|
58
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
59
|
+
timeZone: isSupportedTimeZone(timeZone) ? timeZone : 'UTC',
|
|
60
|
+
dateStyle: 'full',
|
|
61
|
+
timeStyle: 'short'
|
|
62
|
+
}).format(new Date(startsAtMs));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=booking-time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/booking-time.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// The leaf, not the barrel: this model is read by the client bundle too, and\n// the zone rules are a dependency-free module.\nimport {\n isSupportedTimeZone,\n resolveSiteTimeZone,\n} from '@aglyn/aglyn/app-utils/collection-entry-date'\n\n/**\n * The zone a booking's time is told in, to the guest and to the site's\n * managers alike (AGL-3432).\n *\n * THE SERVICE WINS: its weekly windows are defined in its own zone, so that is\n * the zone the slot was offered in. A service that names none (or names one\n * this runtime no longer accepts) reads in the site's zone, then the\n * workspace's, then UTC — `resolveSiteTimeZone`'s chain, so a booking and the\n * site's own dates agree.\n *\n * Every candidate is validated, never trusted: a zone that cannot format would\n * throw a `RangeError` in the middle of a send.\n */\nexport function bookingTimeZone(input: {\n service?: { timezone?: unknown } | null\n host?: { timeZone?: string } | null\n org?: { timeZone?: string } | null\n}): string {\n const serviceZone = String(input.service?.timezone ?? '').trim()\n if (isSupportedTimeZone(serviceZone)) return serviceZone\n return resolveSiteTimeZone(input.org ?? null, input.host ?? null)\n}\n\n/**\n * The zone a booking document carries, when it carries a usable one.\n *\n * Bookings written before the zone was stored have none, and a reader then\n * resolves it from the service and site with {@link bookingTimeZone}.\n */\nexport function storedBookingTimeZone(booking: {\n timezone?: unknown\n} | null | undefined): string | undefined {\n const zone = String(booking?.timezone ?? '').trim()\n return isSupportedTimeZone(zone) ? zone : undefined\n}\n\n/**\n * A booking's start as a guest reads it — \"Friday, April 24, 2026 at 3:00 PM\"\n * — in `timeZone`. The caller names the zone beside it; the string alone does\n * not say which zone it is in.\n *\n * Never the server's own zone: a formatter with no `timeZone` reads in UTC on\n * the platform's servers and prints a wall-clock time hours away from the\n * appointment. An unusable zone formats in UTC, which is what the caller then\n * names.\n */\nexport function formatBookingWhen(startsAtMs: number, timeZone: string): string {\n return new Intl.DateTimeFormat('en-US', {\n timeZone: isSupportedTimeZone(timeZone) ? timeZone : 'UTC',\n dateStyle: 'full',\n timeStyle: 'short',\n }).format(new Date(startsAtMs))\n}\n"],"names":["isSupportedTimeZone","resolveSiteTimeZone","bookingTimeZone","input","serviceZone","String","service","timezone","trim","org","host","storedBookingTimeZone","booking","zone","undefined","formatBookingWhen","startsAtMs","timeZone","Intl","DateTimeFormat","dateStyle","timeStyle","format","Date"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,6EAA6E;AAC7E,+CAA+C;AAC/C,SACEA,mBAAmB,EACnBC,mBAAmB,QACd,+CAA8C;AAErD;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,gBAAgBC,KAI/B;cAG4BA,YAAmBA;QAFnBA;IAA3B,MAAMC,cAAcC,gBAAOF,iBAAAA,MAAMG,OAAO,qBAAbH,eAAeI,QAAQ,mBAAI,IAAIC,IAAI;IAC9D,IAAIR,oBAAoBI,cAAc,OAAOA;IAC7C,OAAOH,qBAAoBE,aAAAA,MAAMM,GAAG,YAATN,aAAa,OAAMA,cAAAA,MAAMO,IAAI,YAAVP,cAAc;AAC9D;AAEA;;;;;CAKC,GACD,OAAO,SAASQ,sBAAsBC,OAElB;;IAClB,MAAMC,OAAOR,eAAOO,2BAAAA,QAASL,QAAQ,mBAAI,IAAIC,IAAI;IACjD,OAAOR,oBAAoBa,QAAQA,OAAOC;AAC5C;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASC,kBAAkBC,UAAkB,EAAEC,QAAgB;IACpE,OAAO,IAAIC,KAAKC,cAAc,CAAC,SAAS;QACtCF,UAAUjB,oBAAoBiB,YAAYA,WAAW;QACrDG,WAAW;QACXC,WAAW;IACb,GAAGC,MAAM,CAAC,IAAIC,KAAKP;AACrB"}
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
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";
|
|
18
33
|
/**
|
|
19
34
|
* The query key the Bookings page narrows to one booker by (AGL-2660) — the
|
|
20
35
|
* same word the CRM's contacts list and the Orders list use: one address,
|
|
@@ -28,7 +28,7 @@ import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
|
28
28
|
* of its own — the Bookings page lists them — so a record answers the page,
|
|
29
29
|
* and a person's bookings are the page narrowed by
|
|
30
30
|
* {@link BOOKINGS_BOOKER_PARAM}.
|
|
31
|
-
*/ /** The nav slug the shell resolves the Bookings page by. */ const BOOKINGS_SLUG = 'bookings';
|
|
31
|
+
*/ /** The nav slug the shell resolves the Bookings page by. */ export const BOOKINGS_SLUG = 'bookings';
|
|
32
32
|
/**
|
|
33
33
|
* The query key the Bookings page narrows to one booker by (AGL-2660) — the
|
|
34
34
|
* same word the CRM's contacts list and the Orders list use: one address,
|
|
@@ -1 +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. */\
|
|
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"}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* See the License for the specific language governing permissions and
|
|
12
12
|
* limitations under the License.
|
|
13
13
|
*/ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
|
|
14
|
+
import { BOOKINGS_SLUG } from "./bookings-record-routes.js";
|
|
14
15
|
/**
|
|
15
16
|
* What the console's search finds in a site's bookings (AGL-3080): its
|
|
16
17
|
* bookable services, by name, opening on the site's Bookings page.
|
|
@@ -24,9 +25,10 @@
|
|
|
24
25
|
nameField: 'name',
|
|
25
26
|
entitlementKey: 'servicesPerHost',
|
|
26
27
|
order: 180,
|
|
27
|
-
href: (_row, { orgSlug, host })=>host ? buildRoute(Route.
|
|
28
|
+
href: (_row, { orgSlug, host })=>host ? buildRoute(Route.HOST_PLUGIN, {
|
|
28
29
|
orgSlug,
|
|
29
|
-
host
|
|
30
|
+
host,
|
|
31
|
+
pluginSlug: BOOKINGS_SLUG
|
|
30
32
|
}) : null
|
|
31
33
|
}
|
|
32
34
|
];
|
|
@@ -1 +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'\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
|
|
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
|
|
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
|
|
126
|
-
|
|
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
|
|
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
|
|
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.
|
|
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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"}
|
package/src/lib/model/index.d.ts
CHANGED
package/src/lib/model/index.js
CHANGED
|
@@ -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"}
|
|
@@ -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 —
|
|
201
|
-
body: `A
|
|
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,
|
|
487
|
+
var _booking_name, _booking_serviceName1, _booking_name1, _booking_serviceName2, _ref10, _booking_serviceName3;
|
|
482
488
|
var _this;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
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
|
|
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
|