@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.
- package/package.json +12 -12
- package/src/lib/components/book-meeting-action.d.ts +7 -4
- package/src/lib/components/book-meeting-action.js +5 -5
- 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/components/bookings-console-page.js +17 -12
- package/src/lib/components/bookings-console-page.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 +46 -0
- package/src/lib/model/bookings-record-routes.js +63 -0
- package/src/lib/model/bookings-record-routes.js.map +1 -0
- package/src/lib/model/bookings-search-sources.d.ts +19 -0
- package/src/lib/model/bookings-search-sources.js +36 -0
- package/src/lib/model/bookings-search-sources.js.map +1 -0
- 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/plugin.js +4 -0
- package/src/lib/plugin.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 +56 -45
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.d.ts +29 -0
- package/src/lib/tenant-emails.js +134 -0
- package/src/lib/tenant-emails.js.map +1 -0
- package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
- package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
- package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
- package/src/lib/testing/stand-in-tax-profile.d.ts +6 -1
- package/src/lib/testing/stand-in-tax-profile.js +5 -1
- 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
|
|
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"}
|
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
|
package/src/lib/plugin.js.map
CHANGED
|
@@ -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,
|
|
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 —
|
|
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
|