@aglyn/plugins-bookings 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +53 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/book-meeting-action.d.ts +91 -0
  8. package/src/lib/components/book-meeting-action.js +296 -0
  9. package/src/lib/components/book-meeting-action.js.map +1 -0
  10. package/src/lib/components/booking.d.ts +33 -0
  11. package/src/lib/components/booking.js +463 -0
  12. package/src/lib/components/booking.js.map +1 -0
  13. package/src/lib/components/bookings-console-page.d.ts +12 -0
  14. package/src/lib/components/bookings-console-page.js +743 -0
  15. package/src/lib/components/bookings-console-page.js.map +1 -0
  16. package/src/lib/constants/bundle-common.d.ts +26 -0
  17. package/src/lib/constants/bundle-common.js +26 -0
  18. package/src/lib/constants/bundle-common.js.map +1 -0
  19. package/src/lib/model/booking-purchase-analytics.d.ts +129 -0
  20. package/src/lib/model/booking-purchase-analytics.js +197 -0
  21. package/src/lib/model/booking-purchase-analytics.js.map +1 -0
  22. package/src/lib/model/bookings.d.ts +141 -0
  23. package/src/lib/model/bookings.js +124 -0
  24. package/src/lib/model/bookings.js.map +1 -0
  25. package/src/lib/model/index.d.ts +22 -0
  26. package/src/lib/model/index.js +22 -0
  27. package/src/lib/model/index.js.map +1 -0
  28. package/src/lib/plugin-config.d.ts +24 -0
  29. package/src/lib/plugin-config.js +51 -0
  30. package/src/lib/plugin-config.js.map +1 -0
  31. package/src/lib/plugin.d.ts +22 -0
  32. package/src/lib/plugin.js +70 -0
  33. package/src/lib/plugin.js.map +1 -0
  34. package/src/lib/server/billing-webhook.d.ts +23 -0
  35. package/src/lib/server/billing-webhook.js +345 -0
  36. package/src/lib/server/billing-webhook.js.map +1 -0
  37. package/src/lib/server/booking-analytics.d.ts +68 -0
  38. package/src/lib/server/booking-analytics.js +115 -0
  39. package/src/lib/server/booking-analytics.js.map +1 -0
  40. package/src/lib/server/booking-crm.d.ts +39 -0
  41. package/src/lib/server/booking-crm.js +67 -0
  42. package/src/lib/server/booking-crm.js.map +1 -0
  43. package/src/lib/server/booking-figures.d.ts +32 -0
  44. package/src/lib/server/booking-figures.js +155 -0
  45. package/src/lib/server/booking-figures.js.map +1 -0
  46. package/src/lib/server/refund.d.ts +65 -0
  47. package/src/lib/server/refund.js +292 -0
  48. package/src/lib/server/refund.js.map +1 -0
  49. package/src/lib/server.d.ts +63 -0
  50. package/src/lib/server.js +968 -0
  51. package/src/lib/server.js.map +1 -0
  52. package/src/lib/site.d.ts +27 -0
  53. package/src/lib/site.js +50 -0
  54. package/src/lib/site.js.map +1 -0
  55. package/src/lib/testing/stand-in-tax-profile.d.ts +29 -0
  56. package/src/lib/testing/stand-in-tax-profile.js +64 -0
  57. package/src/lib/testing/stand-in-tax-profile.js.map +1 -0
  58. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  59. package/src/lib/utils/generate-preset-id.js +25 -0
  60. package/src/lib/utils/generate-preset-id.js.map +1 -0
  61. package/src/lib/utils/use-booking-purchase-event.d.ts +65 -0
  62. package/src/lib/utils/use-booking-purchase-event.js +147 -0
  63. package/src/lib/utils/use-booking-purchase-event.js.map +1 -0
@@ -0,0 +1,743 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
+ import { checkQuota, crmContactByEmailHref, pluginDocsHelp } from "@aglyn/aglyn";
20
+ import { BOOKINGS_BOOKER_PARAM, normalizeContactEmail } from "@aglyn/aglyn";
21
+ import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
22
+ import { isBookingReminderDue } from "../model/index.js";
23
+ import { AppLink, CardDisplay, HelpTip, useConfirmationContext } from "@aglyn/shared-ui-jsx";
24
+ import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
25
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
26
+ import { Timestamp } from "@aglyn/shared-util-timestamp";
27
+ import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Switch, TextField, Typography } from "@mui/material";
28
+ import { collection, doc, limit, orderBy, query, setDoc, updateDoc } from "firebase/firestore";
29
+ import { useParams, useSearchParams } from "next/navigation";
30
+ import { useCallback, useState } from "react";
31
+ import { useFirestore, useFirestoreCollection, useHostResourceApi, useUser, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
32
+ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
33
+ const WEEKDAYS = [
34
+ 'Sun',
35
+ 'Mon',
36
+ 'Tue',
37
+ 'Wed',
38
+ 'Thu',
39
+ 'Fri',
40
+ 'Sat'
41
+ ];
42
+ /** "09:00-12:00, 13:00-17:00" → open intervals in minutes. */ function parseWindows(input) {
43
+ const windows = [];
44
+ for (const chunk of input.split(',')){
45
+ const match = chunk.trim().match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
46
+ if (!match) continue;
47
+ const start = Number(match[1]) * 60 + Number(match[2]);
48
+ const end = Number(match[3]) * 60 + Number(match[4]);
49
+ if (end > start && end <= 24 * 60) windows.push({
50
+ start,
51
+ end
52
+ });
53
+ }
54
+ return windows;
55
+ }
56
+ function formatWindows(windows) {
57
+ const pad = (minutes)=>`${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
58
+ return (windows != null ? windows : []).map((window)=>`${pad(window.start)}-${pad(window.end)}`).join(', ');
59
+ }
60
+ /**
61
+ * Bookings manager (AGL-159): bookable services with weekly availability
62
+ * windows, and the upcoming-bookings list with cancel. Visitors book via
63
+ * the org /api/bookings endpoints; confirmations email through the
64
+ * env-gated Resend path. Plan-gated (`bookings` flag + `servicesPerHost`).
65
+ */ export function BookingsConsolePage(props) {
66
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6;
67
+ const { hostId, entitled, org } = props;
68
+ const firestore = useFirestore();
69
+ const createHostResource = useHostResourceApi();
70
+ const { enqueueSnackbar } = useSnackbar();
71
+ const { confirm } = useConfirmationContext();
72
+ // The id token the refund route authenticates with (AGL-2315).
73
+ const { data: user } = useUser();
74
+ /*
75
+ * Where a booker's CONTACT is (AGL-2622). A booking updated a person in
76
+ * the CRM by the address it carried, and the row links there by that
77
+ * address — the Contacts list holds the id nothing here does and opens
78
+ * the record on one match. The address comes from the SHARED builder
79
+ * rather than the CRM plugin, the way the commerce dialog's does: a
80
+ * plugin importing a sibling is a cycle waiting for its third edge, and
81
+ * the shared builder is pinned against the CRM's own routes by a spec.
82
+ * `null` until the route params settle, so no row renders a link to
83
+ * nowhere.
84
+ */ const params = useParams();
85
+ const siteContext = (params == null ? void 0 : params.orgSlug) && (params == null ? void 0 : params.host) ? {
86
+ orgSlug: String(params.orgSlug),
87
+ host: String(params.host)
88
+ } : null;
89
+ /*
90
+ * The list narrowed to one booker (AGL-2660): a contact's record links
91
+ * here with `?email=`, and the page answers with every booking that
92
+ * address holds — past ones too, because that link asks about a
93
+ * person's history rather than the week ahead. The same match rule as
94
+ * the row's own "View in CRM": the address, normalized, equal. `null`
95
+ * outside a router, where there is no query to read.
96
+ */ const searchParams = useSearchParams();
97
+ const bookerFilter = normalizeContactEmail(searchParams == null ? void 0 : searchParams.get(BOOKINGS_BOOKER_PARAM));
98
+ const { basePath } = props;
99
+ const { data: serviceDocs, status: servicesStatus, /**
100
+ * The rows this editor is seeded from are unconfirmed by the server
101
+ * (AGL-1358). Editing copies a whole stored service into `draft` and
102
+ * writes all of it back, so `merge: true` protects nothing. `windows` is
103
+ * the weekly availability map, rebuilt in full on every save: a cached
104
+ * seed re-opens slots that were closed and closes ones customers can
105
+ * already book, and `priceUsd` goes back to whatever that snapshot
106
+ * charged.
107
+ */ fromCache: servicesFromCache } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'services'), limit(100)), [
108
+ firestore,
109
+ hostId
110
+ ], {
111
+ idField: '$id'
112
+ });
113
+ const { data: bookingDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'bookings'), orderBy('startsAtMs', 'desc'), limit(100)), [
114
+ firestore,
115
+ hostId
116
+ ], {
117
+ idField: '$id'
118
+ });
119
+ const services = [
120
+ ...serviceDocs != null ? serviceDocs : []
121
+ ].filter((service)=>!service.deletedAt).sort((a, b)=>{
122
+ var _a_name, _b_name;
123
+ return String((_a_name = a.name) != null ? _a_name : '').localeCompare(String((_b_name = b.name) != null ? _b_name : ''));
124
+ });
125
+ const upcoming = [
126
+ ...bookingDocs != null ? bookingDocs : []
127
+ ].filter((booking)=>booking.endsAtMs >= Date.now()).sort((a, b)=>a.startsAtMs - b.startsAtMs);
128
+ // Newest first when narrowed to a booker — the listener already orders
129
+ // by start, descending — so the history reads down from the latest.
130
+ const shown = bookerFilter ? (bookingDocs != null ? bookingDocs : []).filter((booking)=>normalizeContactEmail(booking.email) === bookerFilter) : upcoming;
131
+ /**
132
+ * The 24-hour reminder queue (AGL-2431).
133
+ *
134
+ * Reminders are sent by the `booking-reminders` beat, not by anything on
135
+ * this page, and until that beat existed they were never sent at all — the
136
+ * feature had a designed email template, white-label branding and an
137
+ * `x-cron-secret` door, and no scheduler pointed at it. So the merchant had
138
+ * no way to tell working from broken, and still would not if this card
139
+ * merely asserted that reminders happen.
140
+ *
141
+ * READ-ONLY, and deliberately no "send now" button: the beat runs hourly
142
+ * and stamps `reminderSentAt`, so a manual sender would race it for a
143
+ * reminder nobody can un-send. Same call AGL-2227 made for the recovery
144
+ * queue.
145
+ *
146
+ * `isBookingReminderDue` is the sender's own predicate, against one instant
147
+ * shared by both counts, so this is the queue the next pass will drain
148
+ * rather than an approximation of it.
149
+ */ const reminderNowMs = Date.now();
150
+ const reminderQueue = (bookingDocs != null ? bookingDocs : []).filter((booking)=>isBookingReminderDue(booking, reminderNowMs)).length;
151
+ const remindersSent = (bookingDocs != null ? bookingDocs : []).filter((booking)=>booking.reminderSentAt).length;
152
+ const [draft, setDraft] = useState(null);
153
+ const handleAdd = useCallback(()=>{
154
+ if (!entitled) {
155
+ return void enqueueSnackbar('Bookings require a Starter plan — see Billing to upgrade', {
156
+ variant: 'warning',
157
+ persist: false
158
+ });
159
+ }
160
+ const quota = checkQuota(org, 'servicesPerHost', services.length);
161
+ if (!quota.allowed) {
162
+ return void enqueueSnackbar(`Service limit reached (${quota.limit}) — upgrade in Billing`, {
163
+ variant: 'warning',
164
+ persist: false
165
+ });
166
+ }
167
+ setDraft({
168
+ id: null,
169
+ name: '',
170
+ durationMinutes: '30',
171
+ priceUsd: '0',
172
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
173
+ description: '',
174
+ windowText: WEEKDAYS.map((_, index)=>index >= 1 && index <= 5 ? '09:00-17:00' : ''),
175
+ // A booking is a meeting the record has; the follow-up is opt-in.
176
+ crmMeetingActivity: true,
177
+ crmFollowUpTask: false
178
+ });
179
+ }, [
180
+ entitled,
181
+ org,
182
+ services.length,
183
+ enqueueSnackbar
184
+ ]);
185
+ const handleSave = useCallback(async ()=>{
186
+ if (!draft || !draft.name.trim()) return;
187
+ const windows = {};
188
+ draft.windowText.forEach((text, weekday)=>{
189
+ const parsed = parseWindows(text);
190
+ if (parsed.length) windows[weekday] = parsed;
191
+ });
192
+ const fields = _extends({
193
+ name: draft.name.trim().slice(0, 80),
194
+ durationMinutes: Math.max(5, Math.min(480, Math.round(Number(draft.durationMinutes) || 30))),
195
+ priceUsd: Math.max(0, Math.round(Number(draft.priceUsd) || 0)),
196
+ timezone: draft.timezone.trim() || 'UTC'
197
+ }, draft.description.trim() && {
198
+ description: draft.description.trim().slice(0, 500)
199
+ }, {
200
+ windows,
201
+ // Written explicitly rather than only when on: an edit that switches
202
+ // the meeting off has to land a `false`, and `merge: true` would keep
203
+ // an absent key exactly as it was.
204
+ crmMeetingActivity: draft.crmMeetingActivity,
205
+ crmFollowUpTask: draft.crmFollowUpTask
206
+ });
207
+ try {
208
+ if (draft.id) {
209
+ /**
210
+ * Edit stays client-direct (no quota consumed) — and is refused when
211
+ * its seed was never confirmed by the server (AGL-1358). `fields` is
212
+ * every editable key of the stored service, `windows` included, so
213
+ * `merge: true` has nothing left to protect.
214
+ *
215
+ * The guard WRAPS the write. An early return is a shape you can keep
216
+ * while losing the protection; here the write is only reachable
217
+ * through the verdict.
218
+ */ const verdict = await writeGuardedBySeed({
219
+ subject: 'service',
220
+ unreadable: servicesStatus === 'error',
221
+ fromCache: servicesFromCache
222
+ }, async ()=>{
223
+ await setDoc(doc(firestore, 'hosts', hostId, 'services', draft.id), _extends({}, fields, {
224
+ updatedAt: Timestamp.now()
225
+ }), {
226
+ merge: true
227
+ });
228
+ });
229
+ // Before `setDraft(null)`, so a refusal keeps the dialog open with
230
+ // every window that was typed. A save that silently does nothing
231
+ // sends the user back to retype a form that will be refused again
232
+ // just as quietly.
233
+ if (!verdict.ok) {
234
+ return void enqueueSnackbar(verdict.message, {
235
+ variant: 'warning',
236
+ persist: false
237
+ });
238
+ }
239
+ } else {
240
+ // New service rides the quota-enforcing resources API (AGL-473) —
241
+ // it also re-checks the `bookings` entitlement server-side.
242
+ await createHostResource({
243
+ hostId,
244
+ resource: 'service',
245
+ data: fields
246
+ });
247
+ }
248
+ setDraft(null);
249
+ enqueueSnackbar('Service saved', {
250
+ variant: 'success',
251
+ persist: false
252
+ });
253
+ } catch (error) {
254
+ var _ref;
255
+ console.error(error);
256
+ enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'An error has occurred', {
257
+ variant: 'error',
258
+ allowDuplicate: true
259
+ });
260
+ }
261
+ }, [
262
+ draft,
263
+ firestore,
264
+ hostId,
265
+ createHostResource,
266
+ enqueueSnackbar,
267
+ servicesStatus,
268
+ servicesFromCache
269
+ ]);
270
+ const handleDeleteService = useCallback((service)=>async ()=>{
271
+ const confirmed = await confirm({
272
+ title: 'Delete this service?',
273
+ description: `"${service.name}" stops accepting bookings.`,
274
+ confirmationText: 'Delete',
275
+ confirmationButtonProps: {
276
+ color: 'error'
277
+ }
278
+ }).then(()=>true).catch(()=>false);
279
+ if (!confirmed) return;
280
+ await updateDoc(doc(firestore, 'hosts', hostId, 'services', service.$id), {
281
+ deletedAt: Timestamp.now()
282
+ });
283
+ }, [
284
+ confirm,
285
+ firestore,
286
+ hostId
287
+ ]);
288
+ /**
289
+ * Cancelling a PAID booking refunds the guest (AGL-2315).
290
+ *
291
+ * This used to be one `updateDoc` writing `status: 'canceled'`, for a paid
292
+ * booking exactly as for a free one. The slot reopened, the appointment was
293
+ * gone, and the guest had paid for it — the money moved nowhere and nothing
294
+ * in the console said so. That was survivable only while the charge settled
295
+ * in Aglyn's own balance and could be given back by hand; a paid booking is
296
+ * a destination charge now, so the funds are at the MERCHANT and there is no
297
+ * later opportunity to notice.
298
+ *
299
+ * So a paid booking cancels through the refund route, which reverses the
300
+ * merchant's share and Aglyn's fee, and which sets `canceled` itself once
301
+ * the money is actually back. A FAILED refund deliberately leaves the
302
+ * booking standing: a cancelled-and-unrefunded appointment is the worst of
303
+ * the three outcomes, and an admin who sees the row still there knows the
304
+ * cancel did not happen.
305
+ */ const handleCancelBooking = useCallback((booking)=>async ()=>{
306
+ var _booking_paidAmountCents, _booking_refundedCents;
307
+ const paidCents = Math.max(0, Number((_booking_paidAmountCents = booking.paidAmountCents) != null ? _booking_paidAmountCents : 0));
308
+ const refundedCents = Math.max(0, Number((_booking_refundedCents = booking.refundedCents) != null ? _booking_refundedCents : 0));
309
+ const outstandingCents = Math.max(0, paidCents - refundedCents);
310
+ const refundable = outstandingCents > 0;
311
+ const confirmed = await confirm({
312
+ title: 'Cancel this booking?',
313
+ description: `${booking.name} (${booking.email}) — the slot reopens.` + (refundable ? ` $${(outstandingCents / 100).toFixed(2)} is refunded to them` + ' through Stripe.' : ''),
314
+ confirmationText: refundable ? 'Cancel and refund' : 'Cancel booking',
315
+ confirmationButtonProps: {
316
+ color: 'error'
317
+ }
318
+ }).then(()=>true).catch(()=>false);
319
+ if (!confirmed) return;
320
+ if (refundable) {
321
+ var _ref;
322
+ var _globalThis_crypto_randomUUID, _globalThis_crypto;
323
+ // `randomUUID` needs a secure context, which the console always is,
324
+ // but fall back rather than throw on a refund.
325
+ const attemptKey = (_ref = (_globalThis_crypto = globalThis.crypto) == null ? void 0 : (_globalThis_crypto_randomUUID = _globalThis_crypto.randomUUID) == null ? void 0 : _globalThis_crypto_randomUUID.call(_globalThis_crypto)) != null ? _ref : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
326
+ try {
327
+ const response = await authorizedFetch(user, '/api/bookings/refund', {
328
+ method: 'POST',
329
+ headers: {
330
+ 'Content-Type': 'application/json',
331
+ 'Idempotency-Key': attemptKey
332
+ },
333
+ body: JSON.stringify({
334
+ hostId,
335
+ bookingId: booking.$id
336
+ })
337
+ });
338
+ const payload = await response.json().catch(()=>null);
339
+ if (!response.ok) {
340
+ var _ref1;
341
+ // A 409 is a guard REFUSING with a body that says what to do
342
+ // instead — a booking paid before the PaymentIntent was recorded
343
+ // names the Stripe dashboard — not the refund machinery failing.
344
+ // It surfaces verbatim, the AGL-1818 rule.
345
+ enqueueSnackbar((_ref1 = payload == null ? void 0 : payload.error) != null ? _ref1 : 'Refund failed', {
346
+ variant: response.status === 409 ? 'warning' : 'error',
347
+ allowDuplicate: true
348
+ });
349
+ // The booking stays. See the note above: cancelled-and-unrefunded
350
+ // is worse than not cancelled.
351
+ return;
352
+ }
353
+ // The route wrote `canceled` itself once the money was back.
354
+ enqueueSnackbar('Booking canceled and refunded', {
355
+ variant: 'success',
356
+ persist: false
357
+ });
358
+ return;
359
+ } catch (unused) {
360
+ enqueueSnackbar('Refund failed — the booking was not canceled', {
361
+ variant: 'error',
362
+ allowDuplicate: true
363
+ });
364
+ return;
365
+ }
366
+ }
367
+ await updateDoc(doc(firestore, 'hosts', hostId, 'bookings', booking.$id), {
368
+ status: 'canceled'
369
+ });
370
+ enqueueSnackbar('Booking canceled', {
371
+ variant: 'success',
372
+ persist: false
373
+ });
374
+ }, [
375
+ confirm,
376
+ firestore,
377
+ hostId,
378
+ enqueueSnackbar,
379
+ user
380
+ ]);
381
+ return /*#__PURE__*/ _jsxs(Stack, {
382
+ spacing: 3,
383
+ children: [
384
+ /*#__PURE__*/ _jsx(CardDisplay, {
385
+ header: 'Services',
386
+ help: pluginDocsHelp('bookings', {
387
+ anchor: '#set-up-bookings',
388
+ excerpt: 'The bookable services this site offers — how long each takes, ' + 'what it costs, and the hours it can be booked in.'
389
+ }),
390
+ contentGutterX: true,
391
+ contentGutterY: true,
392
+ children: /*#__PURE__*/ _jsxs(Stack, {
393
+ spacing: 1,
394
+ children: [
395
+ services.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
396
+ variant: "body2",
397
+ color: "text.secondary",
398
+ children: 'Define a bookable service — duration, price, and weekly ' + 'availability — then visitors can book from your site.'
399
+ }) : services.map((service)=>{
400
+ var _service_timezone;
401
+ return /*#__PURE__*/ _jsxs(Stack, {
402
+ direction: "row",
403
+ spacing: 1,
404
+ sx: {
405
+ alignItems: 'center'
406
+ },
407
+ children: [
408
+ /*#__PURE__*/ _jsxs(Stack, {
409
+ sx: {
410
+ flex: 1,
411
+ minWidth: 0
412
+ },
413
+ children: [
414
+ /*#__PURE__*/ _jsx(Typography, {
415
+ variant: "body2",
416
+ noWrap: true,
417
+ children: service.name
418
+ }),
419
+ /*#__PURE__*/ _jsx(Typography, {
420
+ variant: "caption",
421
+ color: "text.secondary",
422
+ noWrap: true,
423
+ children: `${service.durationMinutes} min` + (Number(service.priceUsd) > 0 ? ` · $${service.priceUsd}` : ' · free') + ` · ${(_service_timezone = service.timezone) != null ? _service_timezone : 'UTC'}`
424
+ })
425
+ ]
426
+ }),
427
+ /*#__PURE__*/ _jsx(Button, {
428
+ size: "small",
429
+ onClick: ()=>{
430
+ var _service_name, _service_durationMinutes, _service_priceUsd, _service_timezone, _service_description;
431
+ return setDraft({
432
+ id: service.$id,
433
+ name: (_service_name = service.name) != null ? _service_name : '',
434
+ durationMinutes: String((_service_durationMinutes = service.durationMinutes) != null ? _service_durationMinutes : 30),
435
+ priceUsd: String((_service_priceUsd = service.priceUsd) != null ? _service_priceUsd : 0),
436
+ timezone: (_service_timezone = service.timezone) != null ? _service_timezone : 'UTC',
437
+ description: (_service_description = service.description) != null ? _service_description : '',
438
+ windowText: WEEKDAYS.map((_, index)=>{
439
+ var _service_windows;
440
+ return formatWindows((_service_windows = service.windows) == null ? void 0 : _service_windows[index]);
441
+ }),
442
+ // Absent reads as ON for the meeting and OFF for the
443
+ // follow-up — the model's defaults, seeded so the
444
+ // switches show what the service actually does.
445
+ crmMeetingActivity: service.crmMeetingActivity !== false,
446
+ crmFollowUpTask: service.crmFollowUpTask === true
447
+ });
448
+ },
449
+ children: 'Edit'
450
+ }),
451
+ /*#__PURE__*/ _jsx(Button, {
452
+ size: "small",
453
+ color: "error",
454
+ onClick: handleDeleteService(service),
455
+ children: 'Delete'
456
+ })
457
+ ]
458
+ }, service.$id);
459
+ }),
460
+ /*#__PURE__*/ _jsx(Button, {
461
+ size: "small",
462
+ color: "primary",
463
+ sx: {
464
+ alignSelf: 'flex-start'
465
+ },
466
+ onClick: handleAdd,
467
+ children: 'Add service'
468
+ }),
469
+ /*#__PURE__*/ _jsx(QuotaReadoutComponent, {
470
+ ready: org != null,
471
+ used: services.length,
472
+ limit: checkQuota(org, 'servicesPerHost', services.length).limit,
473
+ noun: "service"
474
+ })
475
+ ]
476
+ })
477
+ }),
478
+ /*#__PURE__*/ _jsxs(CardDisplay, {
479
+ header: bookerFilter ? `Bookings for ${bookerFilter}` : 'Upcoming bookings',
480
+ help: pluginDocsHelp('bookings', {
481
+ anchor: '#manage',
482
+ excerpt: 'Bookings taken on this site, with the customer and the slot each ' + 'one holds.'
483
+ }),
484
+ contentGutterX: true,
485
+ contentGutterY: true,
486
+ children: [
487
+ bookerFilter ? /*#__PURE__*/ _jsxs(Typography, {
488
+ variant: "caption",
489
+ color: "text.secondary",
490
+ component: "div",
491
+ sx: {
492
+ mb: 1
493
+ },
494
+ children: [
495
+ 'Every booking this address holds, newest first. ',
496
+ basePath ? /*#__PURE__*/ _jsx(AppLink, {
497
+ href: basePath,
498
+ children: 'Show upcoming bookings'
499
+ }) : null
500
+ ]
501
+ }) : null,
502
+ /*#__PURE__*/ _jsxs(Typography, {
503
+ variant: "caption",
504
+ color: "text.secondary",
505
+ component: "div",
506
+ sx: {
507
+ mb: 1
508
+ },
509
+ children: [
510
+ `24-hour reminders · ${reminderQueue} due in the next pass · ` + `${remindersSent} already sent`,
511
+ /*#__PURE__*/ _jsx(HelpTip, _extends({}, pluginDocsHelp('bookings', {
512
+ anchor: '#reminders'
513
+ }), {
514
+ sx: {
515
+ ml: 0.5,
516
+ fontSize: '0.9em'
517
+ }
518
+ }))
519
+ ]
520
+ }),
521
+ shown.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
522
+ variant: "body2",
523
+ color: "text.secondary",
524
+ children: bookerFilter ? 'No bookings for this address.' : 'No upcoming bookings.'
525
+ }) : /*#__PURE__*/ _jsx(Stack, {
526
+ spacing: 1,
527
+ children: shown.map((booking)=>/*#__PURE__*/ _jsxs(Stack, {
528
+ direction: "row",
529
+ spacing: 1,
530
+ sx: {
531
+ alignItems: 'center'
532
+ },
533
+ children: [
534
+ /*#__PURE__*/ _jsxs(Stack, {
535
+ sx: {
536
+ flex: 1,
537
+ minWidth: 0
538
+ },
539
+ children: [
540
+ /*#__PURE__*/ _jsxs(Typography, {
541
+ variant: "body2",
542
+ noWrap: true,
543
+ children: [
544
+ `${booking.serviceName} — ${booking.name}`,
545
+ booking.status === 'canceled' ? ' (canceled)' : ''
546
+ ]
547
+ }),
548
+ /*#__PURE__*/ _jsx(Typography, {
549
+ variant: "caption",
550
+ color: "text.secondary",
551
+ noWrap: true,
552
+ children: `${new Date(booking.startsAtMs).toLocaleString()} · ${booking.email}`
553
+ })
554
+ ]
555
+ }),
556
+ siteContext && booking.email ? /*#__PURE__*/ _jsx(Button, _extends({
557
+ component: AppLink
558
+ }, {
559
+ componentVariant: 'naked',
560
+ nativeButton: false
561
+ }, {
562
+ href: crmContactByEmailHref(siteContext, String(booking.email)),
563
+ size: "small",
564
+ children: 'View in CRM'
565
+ })) : null,
566
+ booking.status !== 'canceled' ? /*#__PURE__*/ _jsx(Button, {
567
+ size: "small",
568
+ color: "error",
569
+ onClick: handleCancelBooking(booking),
570
+ children: 'Cancel'
571
+ }) : null
572
+ ]
573
+ }, booking.$id))
574
+ })
575
+ ]
576
+ }),
577
+ /*#__PURE__*/ _jsxs(Dialog, {
578
+ open: Boolean(draft),
579
+ onClose: ()=>setDraft(null),
580
+ maxWidth: "sm",
581
+ fullWidth: true,
582
+ children: [
583
+ /*#__PURE__*/ _jsx(DialogTitle, {
584
+ children: (draft == null ? void 0 : draft.id) ? 'Edit service' : 'Add service'
585
+ }),
586
+ /*#__PURE__*/ _jsxs(DialogContent, {
587
+ sx: {
588
+ display: 'flex',
589
+ flexDirection: 'column',
590
+ gap: 1.5,
591
+ pt: 1
592
+ },
593
+ children: [
594
+ /*#__PURE__*/ _jsx(TextField, {
595
+ label: "Name",
596
+ value: (_ref = draft == null ? void 0 : draft.name) != null ? _ref : '',
597
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
598
+ name: event.target.value
599
+ }) : prev),
600
+ size: "small",
601
+ autoFocus: true,
602
+ sx: {
603
+ mt: 1
604
+ }
605
+ }),
606
+ /*#__PURE__*/ _jsxs(Stack, {
607
+ direction: "row",
608
+ spacing: 1,
609
+ children: [
610
+ /*#__PURE__*/ _jsx(TextField, {
611
+ label: "Duration (minutes)",
612
+ value: (_ref1 = draft == null ? void 0 : draft.durationMinutes) != null ? _ref1 : '',
613
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
614
+ durationMinutes: event.target.value.replace(/[^0-9]/g, '')
615
+ }) : prev),
616
+ size: "small"
617
+ }),
618
+ /*#__PURE__*/ _jsx(TextField, {
619
+ label: "Price (USD, 0 = free)",
620
+ // WHAT THIS PRICE DOES NOT INCLUDE (AGL-2028).
621
+ //
622
+ // A paid booking is charged exactly this figure: the handler
623
+ // sends no tax parameter at all, by a stated decision (AGL-2000)
624
+ // — whether a service is taxable is jurisdiction-specific and
625
+ // frequently the opposite answer from goods, so applying the
626
+ // commerce plugin's GOODS sales rate would be confidently wrong
627
+ // rather than merely absent.
628
+ //
629
+ // That decision was recorded in the source and in the
630
+ // `storefrontTaxCollected` ledger, and in neither place can the
631
+ // merchant see it. A service business is one of the three named
632
+ // ICPs, and one who configured a rate in Commerce → Settings →
633
+ // Taxes has every reason to assume it covers the appointments
634
+ // they sell on the same site. Saying so here is the half of
635
+ // AGL-2028 that holds however the rate question is answered.
636
+ //
637
+ // States the MECHANISM and makes no remittance determination —
638
+ // the same line `describeOrderTaxMode` holds. Who owes which
639
+ // authority what attaches by operation of law and is counsel's
640
+ // call (AGL-1904/AGL-1956).
641
+ helperText: `${PLATFORM_BRAND_NAME} charges this price as typed — no tax is added. Your Commerce tax settings are a goods sales rate and do not apply to bookings.`,
642
+ value: (_ref2 = draft == null ? void 0 : draft.priceUsd) != null ? _ref2 : '',
643
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
644
+ priceUsd: event.target.value.replace(/[^0-9]/g, '')
645
+ }) : prev),
646
+ size: "small"
647
+ }),
648
+ /*#__PURE__*/ _jsx(TextField, {
649
+ label: "Timezone",
650
+ value: (_ref3 = draft == null ? void 0 : draft.timezone) != null ? _ref3 : '',
651
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
652
+ timezone: event.target.value
653
+ }) : prev),
654
+ size: "small",
655
+ sx: {
656
+ minWidth: 180
657
+ }
658
+ })
659
+ ]
660
+ }),
661
+ /*#__PURE__*/ _jsx(TextField, {
662
+ label: "Description (optional)",
663
+ value: (_ref4 = draft == null ? void 0 : draft.description) != null ? _ref4 : '',
664
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
665
+ description: event.target.value
666
+ }) : prev),
667
+ size: "small",
668
+ multiline: true,
669
+ minRows: 2
670
+ }),
671
+ /*#__PURE__*/ _jsx(Typography, {
672
+ variant: "overline",
673
+ color: "text.secondary",
674
+ children: 'Weekly availability'
675
+ }),
676
+ WEEKDAYS.map((day, index)=>{
677
+ var _ref;
678
+ return /*#__PURE__*/ _jsx(TextField, {
679
+ label: day,
680
+ placeholder: "09:00-12:00, 13:00-17:00 (empty = closed)",
681
+ value: (_ref = draft == null ? void 0 : draft.windowText[index]) != null ? _ref : '',
682
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
683
+ windowText: prev.windowText.map((text, i)=>i === index ? event.target.value : text)
684
+ }) : prev),
685
+ size: "small"
686
+ }, day);
687
+ }),
688
+ /*#__PURE__*/ _jsx(Typography, {
689
+ variant: "overline",
690
+ color: "text.secondary",
691
+ children: 'CRM'
692
+ }),
693
+ /*#__PURE__*/ _jsx(FormControlLabel, {
694
+ control: /*#__PURE__*/ _jsx(Switch, {
695
+ checked: (_ref5 = draft == null ? void 0 : draft.crmMeetingActivity) != null ? _ref5 : true,
696
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
697
+ crmMeetingActivity: event.target.checked
698
+ }) : prev),
699
+ size: "small"
700
+ }),
701
+ label: "Log a meeting on the CRM record when this service is booked"
702
+ }),
703
+ /*#__PURE__*/ _jsx(FormControlLabel, {
704
+ control: /*#__PURE__*/ _jsx(Switch, {
705
+ checked: (_ref6 = draft == null ? void 0 : draft.crmFollowUpTask) != null ? _ref6 : false,
706
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
707
+ crmFollowUpTask: event.target.checked
708
+ }) : prev),
709
+ size: "small"
710
+ }),
711
+ label: "Create a follow-up task on the CRM record when this service is booked"
712
+ }),
713
+ /*#__PURE__*/ _jsx(Typography, {
714
+ variant: "caption",
715
+ color: "text.secondary",
716
+ children: 'The follow-up is due one business day after the slot, on whoever ' + 'owns the record. Nothing is filed on a site whose CRM is off.'
717
+ })
718
+ ]
719
+ }),
720
+ /*#__PURE__*/ _jsxs(DialogActions, {
721
+ children: [
722
+ /*#__PURE__*/ _jsx(Button, {
723
+ onClick: ()=>setDraft(null),
724
+ children: 'Cancel'
725
+ }),
726
+ /*#__PURE__*/ _jsx(Button, {
727
+ variant: "contained",
728
+ color: "primary",
729
+ disabled: !(draft == null ? void 0 : draft.name.trim()),
730
+ onClick: handleSave,
731
+ children: 'Save service'
732
+ })
733
+ ]
734
+ })
735
+ ]
736
+ })
737
+ ]
738
+ });
739
+ }
740
+ BookingsConsolePage.displayName = 'BookingsConsolePage';
741
+ export default BookingsConsolePage;
742
+
743
+ //# sourceMappingURL=bookings-console-page.js.map