@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.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +53 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/book-meeting-action.d.ts +91 -0
- package/src/lib/components/book-meeting-action.js +296 -0
- package/src/lib/components/book-meeting-action.js.map +1 -0
- package/src/lib/components/booking.d.ts +33 -0
- package/src/lib/components/booking.js +463 -0
- package/src/lib/components/booking.js.map +1 -0
- package/src/lib/components/bookings-console-page.d.ts +12 -0
- package/src/lib/components/bookings-console-page.js +743 -0
- package/src/lib/components/bookings-console-page.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +26 -0
- package/src/lib/constants/bundle-common.js +26 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/booking-purchase-analytics.d.ts +129 -0
- package/src/lib/model/booking-purchase-analytics.js +197 -0
- package/src/lib/model/booking-purchase-analytics.js.map +1 -0
- package/src/lib/model/bookings.d.ts +141 -0
- package/src/lib/model/bookings.js +124 -0
- package/src/lib/model/bookings.js.map +1 -0
- package/src/lib/model/index.d.ts +22 -0
- package/src/lib/model/index.js +22 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/plugin-config.d.ts +24 -0
- package/src/lib/plugin-config.js +51 -0
- package/src/lib/plugin-config.js.map +1 -0
- package/src/lib/plugin.d.ts +22 -0
- package/src/lib/plugin.js +70 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +23 -0
- package/src/lib/server/billing-webhook.js +345 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/booking-analytics.d.ts +68 -0
- package/src/lib/server/booking-analytics.js +115 -0
- package/src/lib/server/booking-analytics.js.map +1 -0
- package/src/lib/server/booking-crm.d.ts +39 -0
- package/src/lib/server/booking-crm.js +67 -0
- package/src/lib/server/booking-crm.js.map +1 -0
- package/src/lib/server/booking-figures.d.ts +32 -0
- package/src/lib/server/booking-figures.js +155 -0
- package/src/lib/server/booking-figures.js.map +1 -0
- package/src/lib/server/refund.d.ts +65 -0
- package/src/lib/server/refund.js +292 -0
- package/src/lib/server/refund.js.map +1 -0
- package/src/lib/server.d.ts +63 -0
- package/src/lib/server.js +968 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site.d.ts +27 -0
- package/src/lib/site.js +50 -0
- package/src/lib/site.js.map +1 -0
- package/src/lib/testing/stand-in-tax-profile.d.ts +29 -0
- package/src/lib/testing/stand-in-tax-profile.js +64 -0
- package/src/lib/testing/stand-in-tax-profile.js.map +1 -0
- package/src/lib/utils/generate-preset-id.d.ts +19 -0
- package/src/lib/utils/generate-preset-id.js +25 -0
- package/src/lib/utils/generate-preset-id.js.map +1 -0
- package/src/lib/utils/use-booking-purchase-event.d.ts +65 -0
- package/src/lib/utils/use-booking-purchase-event.js +147 -0
- package/src/lib/utils/use-booking-purchase-event.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/components/bookings-console-page.tsx"],"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'use client'\n\nimport { checkQuota, crmContactByEmailHref, pluginDocsHelp } from '@aglyn/aglyn'\nimport { BOOKINGS_BOOKER_PARAM, normalizeContactEmail } from '@aglyn/aglyn'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { type HostBookingService, isBookingReminderDue } from '../model'\nimport { AppLink, CardDisplay, HelpTip, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport QuotaReadoutComponent from '@aglyn/shared-ui-jsx/components/quota-readout.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControlLabel,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n limit,\n orderBy,\n query,\n setDoc,\n updateDoc,\n} from 'firebase/firestore'\nimport { useParams, useSearchParams } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport {\n useFirestore,\n useFirestoreCollection,\n useHostResourceApi,\n useUser,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nconst WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']\n\n/** \"09:00-12:00, 13:00-17:00\" → open intervals in minutes. */\nfunction parseWindows(input: string): Array<{ start: number; end: number }> {\n const windows: Array<{ start: number; end: number }> = []\n for (const chunk of input.split(',')) {\n const match = chunk.trim().match(/^(\\d{1,2}):(\\d{2})\\s*-\\s*(\\d{1,2}):(\\d{2})$/)\n if (!match) continue\n const start = Number(match[1]) * 60 + Number(match[2])\n const end = Number(match[3]) * 60 + Number(match[4])\n if (end > start && end <= 24 * 60) windows.push({ start, end })\n }\n return windows\n}\n\nfunction formatWindows(\n windows: Array<{ start: number; end: number }> | undefined,\n): string {\n const pad = (minutes: number) =>\n `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(\n minutes % 60,\n ).padStart(2, '0')}`\n return (windows ?? [])\n .map((window) => `${pad(window.start)}-${pad(window.end)}`)\n .join(', ')\n}\n\ninterface ServiceDraft {\n id: string | null\n name: string\n durationMinutes: string\n priceUsd: string\n timezone: string\n description: string\n /** Per-weekday window text, e.g. \"09:00-17:00\". */\n windowText: string[]\n /** The service's two CRM switches (AGL-2660) — see `HostBookingService`. */\n crmMeetingActivity: boolean\n crmFollowUpTask: boolean\n}\n\n/**\n * Bookings manager (AGL-159): bookable services with weekly availability\n * windows, and the upcoming-bookings list with cancel. Visitors book via\n * the org /api/bookings endpoints; confirmations email through the\n * env-gated Resend path. Plan-gated (`bookings` flag + `servicesPerHost`).\n */\nexport function BookingsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, entitled, org } = props\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The id token the refund route authenticates with (AGL-2315).\n const { data: user } = useUser()\n /*\n * Where a booker's CONTACT is (AGL-2622). A booking updated a person in\n * the CRM by the address it carried, and the row links there by that\n * address — the Contacts list holds the id nothing here does and opens\n * the record on one match. The address comes from the SHARED builder\n * rather than the CRM plugin, the way the commerce dialog's does: a\n * plugin importing a sibling is a cycle waiting for its third edge, and\n * the shared builder is pinned against the CRM's own routes by a spec.\n * `null` until the route params settle, so no row renders a link to\n * nowhere.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const siteContext =\n params?.orgSlug && params?.host\n ? { orgSlug: String(params.orgSlug), host: String(params.host) }\n : null\n /*\n * The list narrowed to one booker (AGL-2660): a contact's record links\n * here with `?email=`, and the page answers with every booking that\n * address holds — past ones too, because that link asks about a\n * person's history rather than the week ahead. The same match rule as\n * the row's own \"View in CRM\": the address, normalized, equal. `null`\n * outside a router, where there is no query to read.\n */\n const searchParams = useSearchParams()\n const bookerFilter = normalizeContactEmail(searchParams?.get(BOOKINGS_BOOKER_PARAM))\n const { basePath } = props\n\n const {\n data: serviceDocs,\n status: servicesStatus,\n /**\n * The rows this editor is seeded from are unconfirmed by the server\n * (AGL-1358). Editing copies a whole stored service into `draft` and\n * writes all of it back, so `merge: true` protects nothing. `windows` is\n * the weekly availability map, rebuilt in full on every save: a cached\n * seed re-opens slots that were closed and closes ones customers can\n * already book, and `priceUsd` goes back to whatever that snapshot\n * charged.\n */\n fromCache: servicesFromCache,\n } = useFirestoreCollection<any>(\n () =>\n query(collection(firestore, 'hosts', hostId, 'services'), limit(100)),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { data: bookingDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'bookings'),\n orderBy('startsAtMs', 'desc'),\n limit(100),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const services = [...(serviceDocs ?? [])]\n .filter((service: any) => !service.deletedAt)\n .sort((a: any, b: any) =>\n String(a.name ?? '').localeCompare(String(b.name ?? '')),\n )\n const upcoming = [...(bookingDocs ?? [])]\n .filter((booking: any) => booking.endsAtMs >= Date.now())\n .sort((a: any, b: any) => a.startsAtMs - b.startsAtMs)\n // Newest first when narrowed to a booker — the listener already orders\n // by start, descending — so the history reads down from the latest.\n const shown = bookerFilter\n ? (bookingDocs ?? []).filter(\n (booking: any) => normalizeContactEmail(booking.email) === bookerFilter,\n )\n : upcoming\n\n /**\n * The 24-hour reminder queue (AGL-2431).\n *\n * Reminders are sent by the `booking-reminders` beat, not by anything on\n * this page, and until that beat existed they were never sent at all — the\n * feature had a designed email template, white-label branding and an\n * `x-cron-secret` door, and no scheduler pointed at it. So the merchant had\n * no way to tell working from broken, and still would not if this card\n * merely asserted that reminders happen.\n *\n * READ-ONLY, and deliberately no \"send now\" button: the beat runs hourly\n * and stamps `reminderSentAt`, so a manual sender would race it for a\n * reminder nobody can un-send. Same call AGL-2227 made for the recovery\n * queue.\n *\n * `isBookingReminderDue` is the sender's own predicate, against one instant\n * shared by both counts, so this is the queue the next pass will drain\n * rather than an approximation of it.\n */\n const reminderNowMs = Date.now()\n const reminderQueue = (bookingDocs ?? []).filter((booking: any) =>\n isBookingReminderDue(booking, reminderNowMs),\n ).length\n const remindersSent = (bookingDocs ?? []).filter(\n (booking: any) => booking.reminderSentAt,\n ).length\n\n const [draft, setDraft] = useState<ServiceDraft | null>(null)\n\n const handleAdd = useCallback(() => {\n if (!entitled) {\n return void enqueueSnackbar(\n 'Bookings require a Starter plan — see Billing to upgrade',\n { variant: 'warning', persist: false },\n )\n }\n const quota = checkQuota(org, 'servicesPerHost', services.length)\n if (!quota.allowed) {\n return void enqueueSnackbar(\n `Service limit reached (${quota.limit}) — upgrade in Billing`,\n { variant: 'warning', persist: false },\n )\n }\n setDraft({\n id: null,\n name: '',\n durationMinutes: '30',\n priceUsd: '0',\n timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',\n description: '',\n windowText: WEEKDAYS.map((_, index) =>\n index >= 1 && index <= 5 ? '09:00-17:00' : '',\n ),\n // A booking is a meeting the record has; the follow-up is opt-in.\n crmMeetingActivity: true,\n crmFollowUpTask: false,\n })\n }, [entitled, org, services.length, enqueueSnackbar])\n\n const handleSave = useCallback(async () => {\n if (!draft || !draft.name.trim()) return\n const windows: HostBookingService['windows'] = {}\n draft.windowText.forEach((text, weekday) => {\n const parsed = parseWindows(text)\n if (parsed.length) windows[weekday] = parsed\n })\n const fields = {\n name: draft.name.trim().slice(0, 80),\n durationMinutes: Math.max(\n 5,\n Math.min(480, Math.round(Number(draft.durationMinutes) || 30)),\n ),\n priceUsd: Math.max(0, Math.round(Number(draft.priceUsd) || 0)),\n timezone: draft.timezone.trim() || 'UTC',\n ...(draft.description.trim() && {\n description: draft.description.trim().slice(0, 500),\n }),\n windows,\n // Written explicitly rather than only when on: an edit that switches\n // the meeting off has to land a `false`, and `merge: true` would keep\n // an absent key exactly as it was.\n crmMeetingActivity: draft.crmMeetingActivity,\n crmFollowUpTask: draft.crmFollowUpTask,\n }\n try {\n if (draft.id) {\n /**\n * Edit stays client-direct (no quota consumed) — and is refused when\n * its seed was never confirmed by the server (AGL-1358). `fields` is\n * every editable key of the stored service, `windows` included, so\n * `merge: true` has nothing left to protect.\n *\n * The guard WRAPS the write. An early return is a shape you can keep\n * while losing the protection; here the write is only reachable\n * through the verdict.\n */\n const verdict = await writeGuardedBySeed(\n {\n subject: 'service',\n unreadable: servicesStatus === 'error',\n fromCache: servicesFromCache,\n },\n async () => {\n await setDoc(\n doc(firestore, 'hosts', hostId, 'services', draft.id),\n { ...fields, updatedAt: Timestamp.now() },\n { merge: true },\n )\n },\n )\n // Before `setDraft(null)`, so a refusal keeps the dialog open with\n // every window that was typed. A save that silently does nothing\n // sends the user back to retype a form that will be refused again\n // just as quietly.\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n } else {\n // New service rides the quota-enforcing resources API (AGL-473) —\n // it also re-checks the `bookings` entitlement server-side.\n await createHostResource({ hostId, resource: 'service', data: fields })\n }\n setDraft(null)\n enqueueSnackbar('Service saved', { variant: 'success', persist: false })\n } catch (error: any) {\n console.error(error)\n enqueueSnackbar(error?.message ?? 'An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n draft,\n firestore,\n hostId,\n createHostResource,\n enqueueSnackbar,\n servicesStatus,\n servicesFromCache,\n ])\n\n const handleDeleteService = useCallback(\n (service: any) => async () => {\n const confirmed = await confirm({\n title: 'Delete this service?',\n description: `\"${service.name}\" stops accepting bookings.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await updateDoc(doc(firestore, 'hosts', hostId, 'services', service.$id), {\n deletedAt: Timestamp.now(),\n })\n },\n [confirm, firestore, hostId],\n )\n\n /**\n * Cancelling a PAID booking refunds the guest (AGL-2315).\n *\n * This used to be one `updateDoc` writing `status: 'canceled'`, for a paid\n * booking exactly as for a free one. The slot reopened, the appointment was\n * gone, and the guest had paid for it — the money moved nowhere and nothing\n * in the console said so. That was survivable only while the charge settled\n * in Aglyn's own balance and could be given back by hand; a paid booking is\n * a destination charge now, so the funds are at the MERCHANT and there is no\n * later opportunity to notice.\n *\n * So a paid booking cancels through the refund route, which reverses the\n * merchant's share and Aglyn's fee, and which sets `canceled` itself once\n * the money is actually back. A FAILED refund deliberately leaves the\n * booking standing: a cancelled-and-unrefunded appointment is the worst of\n * the three outcomes, and an admin who sees the row still there knows the\n * cancel did not happen.\n */\n const handleCancelBooking = useCallback(\n (booking: any) => async () => {\n const paidCents = Math.max(0, Number(booking.paidAmountCents ?? 0))\n const refundedCents = Math.max(0, Number(booking.refundedCents ?? 0))\n const outstandingCents = Math.max(0, paidCents - refundedCents)\n const refundable = outstandingCents > 0\n const confirmed = await confirm({\n title: 'Cancel this booking?',\n description:\n `${booking.name} (${booking.email}) — the slot reopens.` +\n (refundable\n ? ` $${(outstandingCents / 100).toFixed(2)} is refunded to them` +\n ' through Stripe.'\n : ''),\n confirmationText: refundable ? 'Cancel and refund' : 'Cancel booking',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n\n if (refundable) {\n // `randomUUID` needs a secure context, which the console always is,\n // but fall back rather than throw on a refund.\n const attemptKey =\n globalThis.crypto?.randomUUID?.() ??\n `${Date.now()}-${Math.random().toString(36).slice(2)}`\n try {\n const response = await authorizedFetch(user, '/api/bookings/refund', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Idempotency-Key': attemptKey,\n },\n body: JSON.stringify({ hostId, bookingId: booking.$id }),\n })\n const payload = await response.json().catch(() => null)\n if (!response.ok) {\n // A 409 is a guard REFUSING with a body that says what to do\n // instead — a booking paid before the PaymentIntent was recorded\n // names the Stripe dashboard — not the refund machinery failing.\n // It surfaces verbatim, the AGL-1818 rule.\n enqueueSnackbar(payload?.error ?? 'Refund failed', {\n variant: response.status === 409 ? 'warning' : 'error',\n allowDuplicate: true,\n })\n // The booking stays. See the note above: cancelled-and-unrefunded\n // is worse than not cancelled.\n return\n }\n // The route wrote `canceled` itself once the money was back.\n enqueueSnackbar('Booking canceled and refunded', {\n variant: 'success',\n persist: false,\n })\n return\n } catch {\n enqueueSnackbar('Refund failed — the booking was not canceled', {\n variant: 'error',\n allowDuplicate: true,\n })\n return\n }\n }\n\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'bookings', booking.$id),\n { status: 'canceled' },\n )\n enqueueSnackbar('Booking canceled', {\n variant: 'success',\n persist: false,\n })\n },\n [confirm, firestore, hostId, enqueueSnackbar, user],\n )\n\n return (\n <Stack spacing={3}>\n <CardDisplay\n header={'Services'}\n help={pluginDocsHelp('bookings', {\n anchor: '#set-up-bookings',\n excerpt:\n 'The bookable services this site offers — how long each takes, ' +\n 'what it costs, and the hours it can be booked in.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n {services.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Define a bookable service — duration, price, and weekly ' +\n 'availability — then visitors can book from your site.'}\n </Typography>\n ) : (\n services.map((service: any) => (\n <Stack\n key={service.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {service.name}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {`${service.durationMinutes} min` +\n (Number(service.priceUsd) > 0\n ? ` · $${service.priceUsd}`\n : ' · free') +\n ` · ${service.timezone ?? 'UTC'}`}\n </Typography>\n </Stack>\n <Button\n size=\"small\"\n onClick={() =>\n setDraft({\n id: service.$id,\n name: service.name ?? '',\n durationMinutes: String(service.durationMinutes ?? 30),\n priceUsd: String(service.priceUsd ?? 0),\n timezone: service.timezone ?? 'UTC',\n description: service.description ?? '',\n windowText: WEEKDAYS.map((_, index) =>\n formatWindows(service.windows?.[index]),\n ),\n // Absent reads as ON for the meeting and OFF for the\n // follow-up — the model's defaults, seeded so the\n // switches show what the service actually does.\n crmMeetingActivity: service.crmMeetingActivity !== false,\n crmFollowUpTask: service.crmFollowUpTask === true,\n })\n }\n >\n {'Edit'}\n </Button>\n <Button\n size=\"small\"\n color=\"error\"\n onClick={handleDeleteService(service)}\n >\n {'Delete'}\n </Button>\n </Stack>\n ))\n )}\n <Button\n size=\"small\"\n color=\"primary\"\n sx={{ alignSelf: 'flex-start' }}\n onClick={handleAdd}\n >\n {'Add service'}\n </Button>\n {/* The cap, standing rather than only on refusal (AGL-2113).\n `services.length` is the same count `handleAdd` hands to\n `checkQuota`, so the readout and the gate cannot disagree. */}\n <QuotaReadoutComponent\n ready={org != null}\n used={services.length}\n limit={checkQuota(org, 'servicesPerHost', services.length).limit}\n noun=\"service\"\n />\n </Stack>\n </CardDisplay>\n\n <CardDisplay\n header={bookerFilter ? `Bookings for ${bookerFilter}` : 'Upcoming bookings'}\n help={pluginDocsHelp('bookings', {\n anchor: '#manage',\n excerpt:\n 'Bookings taken on this site, with the customer and the slot each ' +\n 'one holds.',\n })}\n contentGutterX\n contentGutterY\n >\n {bookerFilter ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n component=\"div\"\n sx={{ mb: 1 }}\n >\n {'Every booking this address holds, newest first. '}\n {basePath ? <AppLink href={basePath}>{'Show upcoming bookings'}</AppLink> : null}\n </Typography>\n ) : null}\n {/* What the reminder beat will do next, and what it has done. Named\n \"24-hour reminders\" rather than \"emails\" because that is the only\n mail this queue governs — confirmations go out on booking. */}\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n component=\"div\"\n sx={{ mb: 1 }}\n >\n {`24-hour reminders · ${reminderQueue} due in the next pass · ` +\n `${remindersSent} already sent`}\n {/* Its OWN heading, not the card's `#manage`: a merchant reading\n this line wants the timing and the once-only rule, and landing\n them on \"manage your bookings\" is the presence-not-correctness\n failure a help-coverage check cannot see. */}\n <HelpTip\n {...pluginDocsHelp('bookings', { anchor: '#reminders' })}\n sx={{ ml: 0.5, fontSize: '0.9em' }}\n />\n </Typography>\n {shown.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {bookerFilter ? 'No bookings for this address.' : 'No upcoming bookings.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n {shown.map((booking: any) => (\n <Stack\n key={booking.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {`${booking.serviceName} — ${booking.name}`}\n {booking.status === 'canceled' ? ' (canceled)' : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {`${new Date(booking.startsAtMs).toLocaleString()} · ${\n booking.email\n }`}\n </Typography>\n </Stack>\n {siteContext && booking.email ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={crmContactByEmailHref(siteContext, String(booking.email))}\n size=\"small\"\n >\n {'View in CRM'}\n </Button>\n ) : null}\n {booking.status !== 'canceled' ? (\n <Button\n size=\"small\"\n color=\"error\"\n onClick={handleCancelBooking(booking)}\n >\n {'Cancel'}\n </Button>\n ) : null}\n </Stack>\n ))}\n </Stack>\n )}\n </CardDisplay>\n\n <Dialog\n open={Boolean(draft)}\n onClose={() => setDraft(null)}\n maxWidth=\"sm\"\n fullWidth\n >\n <DialogTitle>{draft?.id ? 'Edit service' : 'Add service'}</DialogTitle>\n <DialogContent\n sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}\n >\n <TextField\n label=\"Name\"\n value={draft?.name ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, name: event.target.value } : prev,\n )\n }\n size=\"small\"\n autoFocus\n sx={{ mt: 1 }}\n />\n <Stack direction=\"row\" spacing={1}>\n <TextField\n label=\"Duration (minutes)\"\n value={draft?.durationMinutes ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? {\n ...prev,\n durationMinutes: event.target.value.replace(\n /[^0-9]/g,\n '',\n ),\n }\n : prev,\n )\n }\n size=\"small\"\n />\n <TextField\n label=\"Price (USD, 0 = free)\"\n // WHAT THIS PRICE DOES NOT INCLUDE (AGL-2028).\n //\n // A paid booking is charged exactly this figure: the handler\n // sends no tax parameter at all, by a stated decision (AGL-2000)\n // — whether a service is taxable is jurisdiction-specific and\n // frequently the opposite answer from goods, so applying the\n // commerce plugin's GOODS sales rate would be confidently wrong\n // rather than merely absent.\n //\n // That decision was recorded in the source and in the\n // `storefrontTaxCollected` ledger, and in neither place can the\n // merchant see it. A service business is one of the three named\n // ICPs, and one who configured a rate in Commerce → Settings →\n // Taxes has every reason to assume it covers the appointments\n // they sell on the same site. Saying so here is the half of\n // AGL-2028 that holds however the rate question is answered.\n //\n // States the MECHANISM and makes no remittance determination —\n // the same line `describeOrderTaxMode` holds. Who owes which\n // authority what attaches by operation of law and is counsel's\n // call (AGL-1904/AGL-1956).\n 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.`}\n value={draft?.priceUsd ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? {\n ...prev,\n priceUsd: event.target.value.replace(/[^0-9]/g, ''),\n }\n : prev,\n )\n }\n size=\"small\"\n />\n <TextField\n label=\"Timezone\"\n value={draft?.timezone ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, timezone: event.target.value } : prev,\n )\n }\n size=\"small\"\n sx={{ minWidth: 180 }}\n />\n </Stack>\n <TextField\n label=\"Description (optional)\"\n value={draft?.description ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, description: event.target.value } : prev,\n )\n }\n size=\"small\"\n multiline\n minRows={2}\n />\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Weekly availability'}\n </Typography>\n {WEEKDAYS.map((day, index) => (\n <TextField\n key={day}\n label={day}\n placeholder=\"09:00-12:00, 13:00-17:00 (empty = closed)\"\n value={draft?.windowText[index] ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? {\n ...prev,\n windowText: prev.windowText.map((text, i) =>\n i === index ? event.target.value : text,\n ),\n }\n : prev,\n )\n }\n size=\"small\"\n />\n ))}\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'CRM'}\n </Typography>\n {/*\n What a booking of this service does to the booker's CRM record\n (AGL-2660). The meeting is on by default — a booking IS a\n meeting the record has — and the follow-up is the service's\n choice, because not every service earns a call afterwards.\n */}\n <FormControlLabel\n control={\n <Switch\n checked={draft?.crmMeetingActivity ?? true}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? { ...prev, crmMeetingActivity: event.target.checked }\n : prev,\n )\n }\n size=\"small\"\n />\n }\n label=\"Log a meeting on the CRM record when this service is booked\"\n />\n <FormControlLabel\n control={\n <Switch\n checked={draft?.crmFollowUpTask ?? false}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? { ...prev, crmFollowUpTask: event.target.checked }\n : prev,\n )\n }\n size=\"small\"\n />\n }\n label=\"Create a follow-up task on the CRM record when this service is booked\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The follow-up is due one business day after the slot, on whoever ' +\n 'owns the record. Nothing is filed on a site whose CRM is off.'}\n </Typography>\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setDraft(null)}>{'Cancel'}</Button>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!draft?.name.trim()}\n onClick={handleSave}\n >\n {'Save service'}\n </Button>\n </DialogActions>\n </Dialog>\n </Stack>\n )\n}\nBookingsConsolePage.displayName = 'BookingsConsolePage'\n\nexport default BookingsConsolePage\n"],"names":["checkQuota","crmContactByEmailHref","pluginDocsHelp","BOOKINGS_BOOKER_PARAM","normalizeContactEmail","PLATFORM_BRAND_NAME","isBookingReminderDue","AppLink","CardDisplay","HelpTip","useConfirmationContext","QuotaReadoutComponent","useSnackbar","Timestamp","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControlLabel","Stack","Switch","TextField","Typography","collection","doc","limit","orderBy","query","setDoc","updateDoc","useParams","useSearchParams","useCallback","useState","useFirestore","useFirestoreCollection","useHostResourceApi","useUser","writeGuardedBySeed","authorizedFetch","WEEKDAYS","parseWindows","input","windows","chunk","split","match","trim","start","Number","end","push","formatWindows","pad","minutes","String","Math","floor","padStart","map","window","join","BookingsConsolePage","props","hostId","entitled","org","firestore","createHostResource","enqueueSnackbar","confirm","data","user","params","siteContext","orgSlug","host","searchParams","bookerFilter","get","basePath","serviceDocs","status","servicesStatus","fromCache","servicesFromCache","idField","bookingDocs","services","filter","service","deletedAt","sort","a","b","name","localeCompare","upcoming","booking","endsAtMs","Date","now","startsAtMs","shown","email","reminderNowMs","reminderQueue","length","remindersSent","reminderSentAt","draft","setDraft","handleAdd","variant","persist","quota","allowed","id","durationMinutes","priceUsd","timezone","Intl","DateTimeFormat","resolvedOptions","timeZone","description","windowText","_","index","crmMeetingActivity","crmFollowUpTask","handleSave","forEach","text","weekday","parsed","fields","slice","max","min","round","verdict","subject","unreadable","updatedAt","merge","ok","message","resource","error","console","allowDuplicate","handleDeleteService","confirmed","title","confirmationText","confirmationButtonProps","color","then","catch","$id","handleCancelBooking","paidCents","paidAmountCents","refundedCents","outstandingCents","refundable","toFixed","globalThis","attemptKey","crypto","randomUUID","random","toString","response","method","headers","body","JSON","stringify","bookingId","payload","json","spacing","header","help","anchor","excerpt","contentGutterX","contentGutterY","direction","sx","alignItems","flex","minWidth","noWrap","size","onClick","alignSelf","ready","used","noun","component","mb","href","ml","fontSize","serviceName","toLocaleString","componentVariant","nativeButton","open","Boolean","onClose","maxWidth","fullWidth","display","flexDirection","gap","pt","label","value","onChange","event","prev","target","autoFocus","mt","replace","helperText","multiline","minRows","day","placeholder","i","control","checked","disabled","displayName"],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 bundle id, persisted as `pluginId` on every node this bundle places.
|
|
19
|
+
*
|
|
20
|
+
* Rendering resolves by `componentId` alone, so a node naming another bundle
|
|
21
|
+
* still draws — but `requiredSitePlugins` reads `pluginId` to decide which
|
|
22
|
+
* chunks must register before first paint, so one that names the wrong bundle
|
|
23
|
+
* draws LATE. `tools/scripts/backfill-node-plugin-ids.mjs` is what keeps saved
|
|
24
|
+
* nodes agreeing with this string.
|
|
25
|
+
*/
|
|
26
|
+
export declare const BUNDLE_ID = "bookings";
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
* The bundle id, persisted as `pluginId` on every node this bundle places.
|
|
18
|
+
*
|
|
19
|
+
* Rendering resolves by `componentId` alone, so a node naming another bundle
|
|
20
|
+
* still draws — but `requiredSitePlugins` reads `pluginId` to decide which
|
|
21
|
+
* chunks must register before first paint, so one that names the wrong bundle
|
|
22
|
+
* draws LATE. `tools/scripts/backfill-node-plugin-ids.mjs` is what keeps saved
|
|
23
|
+
* nodes agreeing with this string.
|
|
24
|
+
*/ export const BUNDLE_ID = 'bookings';
|
|
25
|
+
|
|
26
|
+
//# sourceMappingURL=bundle-common.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/constants/bundle-common.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 * The bundle id, persisted as `pluginId` on every node this bundle places.\n *\n * Rendering resolves by `componentId` alone, so a node naming another bundle\n * still draws — but `requiredSitePlugins` reads `pluginId` to decide which\n * chunks must register before first paint, so one that names the wrong bundle\n * draws LATE. `tools/scripts/backfill-node-plugin-ids.mjs` is what keeps saved\n * nodes agreeing with this string.\n */\nexport const BUNDLE_ID = 'bookings'\n"],"names":["BUNDLE_ID"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GACD,OAAO,MAAMA,YAAY,WAAU"}
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { AnalyticsEventParams } from '@aglyn/aglyn/app-utils/analytics-events';
|
|
18
|
+
/**
|
|
19
|
+
* AGLYN's revenue on a paid booking, in cents — the fee we actually charged.
|
|
20
|
+
*
|
|
21
|
+
* ## The measured value, not a rate re-applied
|
|
22
|
+
*
|
|
23
|
+
* `server.ts` resolves the take rate from the org's plan and stamps the
|
|
24
|
+
* resulting `application_fee_amount` onto the session as `metadata.feeCents`.
|
|
25
|
+
* That stamp is what this reads. It is deliberately NOT re-derived here from
|
|
26
|
+
* the plan and the price, for the same reason AGL-2315 refused to re-derive it
|
|
27
|
+
* at refund time: the rate follows the plan and the plan moves, so a later
|
|
28
|
+
* derivation reports a share that was never taken.
|
|
29
|
+
*
|
|
30
|
+
* This is the "records a constant instead of the measured value" trap in its
|
|
31
|
+
* exact local form — the one that would look right in every test written
|
|
32
|
+
* against a single-tier fixture, and silently report the same revenue for
|
|
33
|
+
* every merchant on the platform. The figure comes off the Stripe object or it
|
|
34
|
+
* does not exist.
|
|
35
|
+
*
|
|
36
|
+
* Returns 0 on the 0%-rate tiers, where `'0'` is a real recorded answer rather
|
|
37
|
+
* than a missing one. The caller drops the event rather than sending
|
|
38
|
+
* `value: 0` — see {@link shouldSendBookingPlatformPurchase}.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bookingPlatformNetCents(object: any): number;
|
|
41
|
+
/**
|
|
42
|
+
* Whether Aglyn earned anything reportable on this booking.
|
|
43
|
+
*
|
|
44
|
+
* A `purchase` carrying `value: 0` is not a truthful record of a free
|
|
45
|
+
* transaction — it is a row that drags ARPA down and inflates the purchase
|
|
46
|
+
* COUNT, which is the denominator of conversion rate. On a 0%-fee tier the
|
|
47
|
+
* merchant genuinely sold something and Aglyn genuinely earned nothing, and
|
|
48
|
+
* "nothing" is better said by the absence of a revenue event than by a
|
|
49
|
+
* zero-valued one. Mirrors `buildStorefrontPurchaseParams`, which returns null
|
|
50
|
+
* on a non-positive value for the same reason.
|
|
51
|
+
*/
|
|
52
|
+
export declare function shouldSendBookingPlatformPurchase(object: any): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The GA-safe projection of a paid booking (AGL-2481). Deliberately NOT the
|
|
55
|
+
* booking document: this crosses the wire to a guest's browser, so it can only
|
|
56
|
+
* hold what a `purchase` needs. `email`, `name`, `timezone` and the appointment
|
|
57
|
+
* times are absent by construction rather than by a `delete` somebody has to
|
|
58
|
+
* remember — the same discipline as `StorefrontPurchaseSource`.
|
|
59
|
+
*
|
|
60
|
+
* `feeCents` is absent too, and that is not an oversight: a guest has no
|
|
61
|
+
* business learning Aglyn's take rate on their hairdresser.
|
|
62
|
+
*/
|
|
63
|
+
export interface BookingPurchaseSource {
|
|
64
|
+
/** Stripe Checkout Session id. GA4 de-duplicates on it. */
|
|
65
|
+
transactionId: string;
|
|
66
|
+
/** Stripe's `amount_total`, verbatim — service plus the merchant's tax. */
|
|
67
|
+
paidAmountCents: number;
|
|
68
|
+
/** The merchant's own service tax, from the session metadata. */
|
|
69
|
+
taxCents: number;
|
|
70
|
+
serviceId: string;
|
|
71
|
+
serviceName: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build the `purchase` a TENANT SITE reports to the MERCHANT's GA4 property.
|
|
75
|
+
*
|
|
76
|
+
* ## Whose revenue this is
|
|
77
|
+
*
|
|
78
|
+
* The merchant's, gross of our fee. On their own site the merchant is the
|
|
79
|
+
* seller; Aglyn's cut is not a share of this sale from their side, it is a cost
|
|
80
|
+
* of making it. So `bookingPlatformNetCents` is NOT subtracted here — this is
|
|
81
|
+
* the exact inverse of the figure the server sends to our property, and the
|
|
82
|
+
* inversion is deliberate (AGL-1641).
|
|
83
|
+
*
|
|
84
|
+
* ## Tax is excluded, and no `tax` param is sent
|
|
85
|
+
*
|
|
86
|
+
* A booking's tax is the merchant's own configured flat rate, which Terms
|
|
87
|
+
* §10.7 does not reach (AGL-1956) — so it stays theirs to remit, and what was
|
|
88
|
+
* collected is money held for an authority rather than revenue. `value` is
|
|
89
|
+
* therefore ex-tax, and no GA4 `tax` param rides beside it — beside an ex-tax
|
|
90
|
+
* `value` that param asserts a relationship that does not hold and invites the
|
|
91
|
+
* subtraction that removes tax a second time (AGL-1639/AGL-1641 both declined
|
|
92
|
+
* it, and the asymmetry with `shipping` there is not an inconsistency to tidy).
|
|
93
|
+
*
|
|
94
|
+
* ## No `shipping` param at all
|
|
95
|
+
*
|
|
96
|
+
* Commerce always sends `shipping`, including as 0, because on a storefront 0
|
|
97
|
+
* is a true statement about an order that carried none. A booking is an
|
|
98
|
+
* appointment: there is no shipping concept to be zero. `shipping` is optional
|
|
99
|
+
* on the taxonomy's `purchase` for exactly this case — "a plan or a marketplace
|
|
100
|
+
* purchase has no shipping to report and omits it" — and a booking omits it for
|
|
101
|
+
* the same reason. Sending 0 would put every service business in a shipping
|
|
102
|
+
* report they are not in.
|
|
103
|
+
*
|
|
104
|
+
* ## Why `value` derives from `amount_total`
|
|
105
|
+
*
|
|
106
|
+
* `paidAmountCents` is Stripe's `amount_total` verbatim, so it reconciles with
|
|
107
|
+
* the charge by construction. The booking document stores no price to re-derive
|
|
108
|
+
* from, and a service price edited between session creation and webhook
|
|
109
|
+
* delivery would make any re-derivation disagree with what the guest actually
|
|
110
|
+
* paid.
|
|
111
|
+
*
|
|
112
|
+
* `taxCents` is the merchant's own rate as charged. It has to come from the
|
|
113
|
+
* session metadata rather than from Stripe's tax fields: the rate rides as an
|
|
114
|
+
* ordinary `line_items[1]` Stripe is never told is tax (the AGL-1711
|
|
115
|
+
* construction, which is what keeps the figure the MERCHANT's), so
|
|
116
|
+
* `total_details.amount_tax` reads 0 on a booking that really did charge tax.
|
|
117
|
+
* Reading Stripe's field would report tax-inclusive gross — the AGL-1639
|
|
118
|
+
* overstatement, live.
|
|
119
|
+
*
|
|
120
|
+
* Returns `null` when there is nothing truthful to send. A dropped event is a
|
|
121
|
+
* gap in the merchant's report; a fabricated one is a wrong number in it, and
|
|
122
|
+
* the second is worse.
|
|
123
|
+
*/
|
|
124
|
+
export declare function buildBookingPurchaseParams(source: BookingPurchaseSource): AnalyticsEventParams['purchase'] | null;
|
|
125
|
+
/**
|
|
126
|
+
* Reduce a stored booking to the wire shape above. Server-side only — it reads
|
|
127
|
+
* fields the projection exists to withhold.
|
|
128
|
+
*/
|
|
129
|
+
export declare function toBookingPurchaseSource(transactionId: string, booking: Record<string, any>): BookingPurchaseSource;
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
* The two `purchase` derivations for a PAID BOOKING (AGL-2481), kept in one
|
|
18
|
+
* file precisely because they are different numbers and must stay that way.
|
|
19
|
+
*
|
|
20
|
+
* The bookings plugin sent no analytics event of any kind. Its billing webhook
|
|
21
|
+
* computed real money and spent it on a contact record and a confirmation
|
|
22
|
+
* email, so booking revenue was invisible in Aglyn's GA4 property AND in the
|
|
23
|
+
* merchant's — the second of which does not read as "not measured", it reads
|
|
24
|
+
* as a 100% abandonment rate on every service a merchant sells.
|
|
25
|
+
*
|
|
26
|
+
* Commerce and marketplace already settled every question this raises, and
|
|
27
|
+
* nothing here is invented:
|
|
28
|
+
*
|
|
29
|
+
* | | Aglyn's property | the merchant's property |
|
|
30
|
+
* |---|---|---|
|
|
31
|
+
* | sender | server, Measurement Protocol | browser, the merchant's `gtag` |
|
|
32
|
+
* | `value` | platform NET — our fee | GROSS ex-tax — what they sold |
|
|
33
|
+
* | our fee | IS the value | NOT subtracted; their cost of sale |
|
|
34
|
+
* | tax | excluded, no `tax` param | excluded, no `tax` param |
|
|
35
|
+
*
|
|
36
|
+
* ## Why the split is the whole point
|
|
37
|
+
*
|
|
38
|
+
* Reporting the same figure into both properties is the expensive mistake, in
|
|
39
|
+
* either direction. Gross into OUR property would make one $95 massage read as
|
|
40
|
+
* $95 of Aglyn revenue beside a $95 subscription, and every combined total,
|
|
41
|
+
* ARPA and revenue audience becomes nonsense (AGL-1639). Platform net into the
|
|
42
|
+
* MERCHANT's property would show them a few percent of their real revenue
|
|
43
|
+
* (AGL-1641). Each number is right in exactly one place.
|
|
44
|
+
*
|
|
45
|
+
* Nothing is double-counted across the two because they are two different
|
|
46
|
+
* GA4 properties, measuring two different businesses, and the figures are
|
|
47
|
+
* disjoint by construction: ours is the fee, theirs is the sale less the fee's
|
|
48
|
+
* base. Within each property GA4 de-duplicates on `transaction_id`, and both
|
|
49
|
+
* senders use the Stripe Checkout Session id — the same key the webhook's own
|
|
50
|
+
* idempotency turns on.
|
|
51
|
+
*/ /** Money, to two decimals. A float sum of cents produces `59.99999999999999`. */ function toAmount(cents) {
|
|
52
|
+
return Math.round(cents) / 100;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* AGLYN's revenue on a paid booking, in cents — the fee we actually charged.
|
|
56
|
+
*
|
|
57
|
+
* ## The measured value, not a rate re-applied
|
|
58
|
+
*
|
|
59
|
+
* `server.ts` resolves the take rate from the org's plan and stamps the
|
|
60
|
+
* resulting `application_fee_amount` onto the session as `metadata.feeCents`.
|
|
61
|
+
* That stamp is what this reads. It is deliberately NOT re-derived here from
|
|
62
|
+
* the plan and the price, for the same reason AGL-2315 refused to re-derive it
|
|
63
|
+
* at refund time: the rate follows the plan and the plan moves, so a later
|
|
64
|
+
* derivation reports a share that was never taken.
|
|
65
|
+
*
|
|
66
|
+
* This is the "records a constant instead of the measured value" trap in its
|
|
67
|
+
* exact local form — the one that would look right in every test written
|
|
68
|
+
* against a single-tier fixture, and silently report the same revenue for
|
|
69
|
+
* every merchant on the platform. The figure comes off the Stripe object or it
|
|
70
|
+
* does not exist.
|
|
71
|
+
*
|
|
72
|
+
* Returns 0 on the 0%-rate tiers, where `'0'` is a real recorded answer rather
|
|
73
|
+
* than a missing one. The caller drops the event rather than sending
|
|
74
|
+
* `value: 0` — see {@link shouldSendBookingPlatformPurchase}.
|
|
75
|
+
*/ export function bookingPlatformNetCents(object) {
|
|
76
|
+
var _ref;
|
|
77
|
+
var _object_metadata;
|
|
78
|
+
return Math.max(0, Math.round(Number((_ref = object == null ? void 0 : (_object_metadata = object.metadata) == null ? void 0 : _object_metadata.feeCents) != null ? _ref : 0)));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether Aglyn earned anything reportable on this booking.
|
|
82
|
+
*
|
|
83
|
+
* A `purchase` carrying `value: 0` is not a truthful record of a free
|
|
84
|
+
* transaction — it is a row that drags ARPA down and inflates the purchase
|
|
85
|
+
* COUNT, which is the denominator of conversion rate. On a 0%-fee tier the
|
|
86
|
+
* merchant genuinely sold something and Aglyn genuinely earned nothing, and
|
|
87
|
+
* "nothing" is better said by the absence of a revenue event than by a
|
|
88
|
+
* zero-valued one. Mirrors `buildStorefrontPurchaseParams`, which returns null
|
|
89
|
+
* on a non-positive value for the same reason.
|
|
90
|
+
*/ export function shouldSendBookingPlatformPurchase(object) {
|
|
91
|
+
return bookingPlatformNetCents(object) > 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build the `purchase` a TENANT SITE reports to the MERCHANT's GA4 property.
|
|
95
|
+
*
|
|
96
|
+
* ## Whose revenue this is
|
|
97
|
+
*
|
|
98
|
+
* The merchant's, gross of our fee. On their own site the merchant is the
|
|
99
|
+
* seller; Aglyn's cut is not a share of this sale from their side, it is a cost
|
|
100
|
+
* of making it. So `bookingPlatformNetCents` is NOT subtracted here — this is
|
|
101
|
+
* the exact inverse of the figure the server sends to our property, and the
|
|
102
|
+
* inversion is deliberate (AGL-1641).
|
|
103
|
+
*
|
|
104
|
+
* ## Tax is excluded, and no `tax` param is sent
|
|
105
|
+
*
|
|
106
|
+
* A booking's tax is the merchant's own configured flat rate, which Terms
|
|
107
|
+
* §10.7 does not reach (AGL-1956) — so it stays theirs to remit, and what was
|
|
108
|
+
* collected is money held for an authority rather than revenue. `value` is
|
|
109
|
+
* therefore ex-tax, and no GA4 `tax` param rides beside it — beside an ex-tax
|
|
110
|
+
* `value` that param asserts a relationship that does not hold and invites the
|
|
111
|
+
* subtraction that removes tax a second time (AGL-1639/AGL-1641 both declined
|
|
112
|
+
* it, and the asymmetry with `shipping` there is not an inconsistency to tidy).
|
|
113
|
+
*
|
|
114
|
+
* ## No `shipping` param at all
|
|
115
|
+
*
|
|
116
|
+
* Commerce always sends `shipping`, including as 0, because on a storefront 0
|
|
117
|
+
* is a true statement about an order that carried none. A booking is an
|
|
118
|
+
* appointment: there is no shipping concept to be zero. `shipping` is optional
|
|
119
|
+
* on the taxonomy's `purchase` for exactly this case — "a plan or a marketplace
|
|
120
|
+
* purchase has no shipping to report and omits it" — and a booking omits it for
|
|
121
|
+
* the same reason. Sending 0 would put every service business in a shipping
|
|
122
|
+
* report they are not in.
|
|
123
|
+
*
|
|
124
|
+
* ## Why `value` derives from `amount_total`
|
|
125
|
+
*
|
|
126
|
+
* `paidAmountCents` is Stripe's `amount_total` verbatim, so it reconciles with
|
|
127
|
+
* the charge by construction. The booking document stores no price to re-derive
|
|
128
|
+
* from, and a service price edited between session creation and webhook
|
|
129
|
+
* delivery would make any re-derivation disagree with what the guest actually
|
|
130
|
+
* paid.
|
|
131
|
+
*
|
|
132
|
+
* `taxCents` is the merchant's own rate as charged. It has to come from the
|
|
133
|
+
* session metadata rather than from Stripe's tax fields: the rate rides as an
|
|
134
|
+
* ordinary `line_items[1]` Stripe is never told is tax (the AGL-1711
|
|
135
|
+
* construction, which is what keeps the figure the MERCHANT's), so
|
|
136
|
+
* `total_details.amount_tax` reads 0 on a booking that really did charge tax.
|
|
137
|
+
* Reading Stripe's field would report tax-inclusive gross — the AGL-1639
|
|
138
|
+
* overstatement, live.
|
|
139
|
+
*
|
|
140
|
+
* Returns `null` when there is nothing truthful to send. A dropped event is a
|
|
141
|
+
* gap in the merchant's report; a fabricated one is a wrong number in it, and
|
|
142
|
+
* the second is worse.
|
|
143
|
+
*/ export function buildBookingPurchaseParams(source) {
|
|
144
|
+
var _ref, _ref1, _ref2, _ref3, _ref4;
|
|
145
|
+
const transactionId = String((_ref = source == null ? void 0 : source.transactionId) != null ? _ref : '').trim();
|
|
146
|
+
if (!transactionId) return null;
|
|
147
|
+
const paidAmountCents = Math.round(Number((_ref1 = source == null ? void 0 : source.paidAmountCents) != null ? _ref1 : 0));
|
|
148
|
+
// Clamped, so a negative cannot be ADDED back into the value by subtraction.
|
|
149
|
+
const taxCents = Math.max(0, Math.round(Number((_ref2 = source == null ? void 0 : source.taxCents) != null ? _ref2 : 0)));
|
|
150
|
+
const netOfTaxCents = paidAmountCents - taxCents;
|
|
151
|
+
if (!Number.isFinite(netOfTaxCents) || netOfTaxCents <= 0) return null;
|
|
152
|
+
return {
|
|
153
|
+
transaction_id: transactionId,
|
|
154
|
+
// No currency is stored on a booking and the session is opened in USD;
|
|
155
|
+
// `buildStorefrontPurchaseParams` takes the same default so a merchant
|
|
156
|
+
// running both plugins sees one currency across both revenue lines.
|
|
157
|
+
currency: 'USD',
|
|
158
|
+
value: toAmount(netOfTaxCents),
|
|
159
|
+
items: [
|
|
160
|
+
{
|
|
161
|
+
// The SERVICE id, so a merchant can read revenue per service. The
|
|
162
|
+
// name is the merchant's own content on the merchant's own property,
|
|
163
|
+
// which is why it is sent here and withheld from ours.
|
|
164
|
+
item_id: String((_ref3 = source == null ? void 0 : source.serviceId) != null ? _ref3 : ''),
|
|
165
|
+
item_name: String((_ref4 = source == null ? void 0 : source.serviceName) != null ? _ref4 : ''),
|
|
166
|
+
// NO `item_category`, matching the storefront items (docs/ANALYTICS.md,
|
|
167
|
+
// "The event map"). In a MERCHANT's property a constant category is a
|
|
168
|
+
// column with one value in it, and their real service categories are
|
|
169
|
+
// not on this payload. It would be actively worse than useless for a
|
|
170
|
+
// merchant running both plugins: products would carry no category and
|
|
171
|
+
// bookings would carry one, so the dimension would be half-populated
|
|
172
|
+
// for a reason GA cannot distinguish from missing data.
|
|
173
|
+
//
|
|
174
|
+
// OUR property is the opposite case and does set it — there `booking`
|
|
175
|
+
// sits beside `subscription` and `marketplace` and separates three
|
|
176
|
+
// real revenue lines.
|
|
177
|
+
price: toAmount(netOfTaxCents),
|
|
178
|
+
quantity: 1
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Reduce a stored booking to the wire shape above. Server-side only — it reads
|
|
185
|
+
* fields the projection exists to withhold.
|
|
186
|
+
*/ export function toBookingPurchaseSource(transactionId, booking) {
|
|
187
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
188
|
+
return {
|
|
189
|
+
transactionId,
|
|
190
|
+
paidAmountCents: Number((_ref = booking == null ? void 0 : booking['paidAmountCents']) != null ? _ref : 0),
|
|
191
|
+
taxCents: Number((_ref1 = booking == null ? void 0 : booking['taxCents']) != null ? _ref1 : 0),
|
|
192
|
+
serviceId: String((_ref2 = booking == null ? void 0 : booking['serviceId']) != null ? _ref2 : ''),
|
|
193
|
+
serviceName: String((_ref3 = booking == null ? void 0 : booking['serviceName']) != null ? _ref3 : '')
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
//# sourceMappingURL=booking-purchase-analytics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/model/booking-purchase-analytics.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 type { AnalyticsEventParams } from '@aglyn/aglyn/app-utils/analytics-events'\n\n/**\n * The two `purchase` derivations for a PAID BOOKING (AGL-2481), kept in one\n * file precisely because they are different numbers and must stay that way.\n *\n * The bookings plugin sent no analytics event of any kind. Its billing webhook\n * computed real money and spent it on a contact record and a confirmation\n * email, so booking revenue was invisible in Aglyn's GA4 property AND in the\n * merchant's — the second of which does not read as \"not measured\", it reads\n * as a 100% abandonment rate on every service a merchant sells.\n *\n * Commerce and marketplace already settled every question this raises, and\n * nothing here is invented:\n *\n * | | Aglyn's property | the merchant's property |\n * |---|---|---|\n * | sender | server, Measurement Protocol | browser, the merchant's `gtag` |\n * | `value` | platform NET — our fee | GROSS ex-tax — what they sold |\n * | our fee | IS the value | NOT subtracted; their cost of sale |\n * | tax | excluded, no `tax` param | excluded, no `tax` param |\n *\n * ## Why the split is the whole point\n *\n * Reporting the same figure into both properties is the expensive mistake, in\n * either direction. Gross into OUR property would make one $95 massage read as\n * $95 of Aglyn revenue beside a $95 subscription, and every combined total,\n * ARPA and revenue audience becomes nonsense (AGL-1639). Platform net into the\n * MERCHANT's property would show them a few percent of their real revenue\n * (AGL-1641). Each number is right in exactly one place.\n *\n * Nothing is double-counted across the two because they are two different\n * GA4 properties, measuring two different businesses, and the figures are\n * disjoint by construction: ours is the fee, theirs is the sale less the fee's\n * base. Within each property GA4 de-duplicates on `transaction_id`, and both\n * senders use the Stripe Checkout Session id — the same key the webhook's own\n * idempotency turns on.\n */\n\n/** Money, to two decimals. A float sum of cents produces `59.99999999999999`. */\nfunction toAmount(cents: number): number {\n return Math.round(cents) / 100\n}\n\n/**\n * AGLYN's revenue on a paid booking, in cents — the fee we actually charged.\n *\n * ## The measured value, not a rate re-applied\n *\n * `server.ts` resolves the take rate from the org's plan and stamps the\n * resulting `application_fee_amount` onto the session as `metadata.feeCents`.\n * That stamp is what this reads. It is deliberately NOT re-derived here from\n * the plan and the price, for the same reason AGL-2315 refused to re-derive it\n * at refund time: the rate follows the plan and the plan moves, so a later\n * derivation reports a share that was never taken.\n *\n * This is the \"records a constant instead of the measured value\" trap in its\n * exact local form — the one that would look right in every test written\n * against a single-tier fixture, and silently report the same revenue for\n * every merchant on the platform. The figure comes off the Stripe object or it\n * does not exist.\n *\n * Returns 0 on the 0%-rate tiers, where `'0'` is a real recorded answer rather\n * than a missing one. The caller drops the event rather than sending\n * `value: 0` — see {@link shouldSendBookingPlatformPurchase}.\n */\nexport function bookingPlatformNetCents(object: any): number {\n return Math.max(0, Math.round(Number(object?.metadata?.feeCents ?? 0)))\n}\n\n/**\n * Whether Aglyn earned anything reportable on this booking.\n *\n * A `purchase` carrying `value: 0` is not a truthful record of a free\n * transaction — it is a row that drags ARPA down and inflates the purchase\n * COUNT, which is the denominator of conversion rate. On a 0%-fee tier the\n * merchant genuinely sold something and Aglyn genuinely earned nothing, and\n * \"nothing\" is better said by the absence of a revenue event than by a\n * zero-valued one. Mirrors `buildStorefrontPurchaseParams`, which returns null\n * on a non-positive value for the same reason.\n */\nexport function shouldSendBookingPlatformPurchase(object: any): boolean {\n return bookingPlatformNetCents(object) > 0\n}\n\n/**\n * The GA-safe projection of a paid booking (AGL-2481). Deliberately NOT the\n * booking document: this crosses the wire to a guest's browser, so it can only\n * hold what a `purchase` needs. `email`, `name`, `timezone` and the appointment\n * times are absent by construction rather than by a `delete` somebody has to\n * remember — the same discipline as `StorefrontPurchaseSource`.\n *\n * `feeCents` is absent too, and that is not an oversight: a guest has no\n * business learning Aglyn's take rate on their hairdresser.\n */\nexport interface BookingPurchaseSource {\n /** Stripe Checkout Session id. GA4 de-duplicates on it. */\n transactionId: string\n /** Stripe's `amount_total`, verbatim — service plus the merchant's tax. */\n paidAmountCents: number\n /** The merchant's own service tax, from the session metadata. */\n taxCents: number\n serviceId: string\n serviceName: string\n}\n\n/**\n * Build the `purchase` a TENANT SITE reports to the MERCHANT's GA4 property.\n *\n * ## Whose revenue this is\n *\n * The merchant's, gross of our fee. On their own site the merchant is the\n * seller; Aglyn's cut is not a share of this sale from their side, it is a cost\n * of making it. So `bookingPlatformNetCents` is NOT subtracted here — this is\n * the exact inverse of the figure the server sends to our property, and the\n * inversion is deliberate (AGL-1641).\n *\n * ## Tax is excluded, and no `tax` param is sent\n *\n * A booking's tax is the merchant's own configured flat rate, which Terms\n * §10.7 does not reach (AGL-1956) — so it stays theirs to remit, and what was\n * collected is money held for an authority rather than revenue. `value` is\n * therefore ex-tax, and no GA4 `tax` param rides beside it — beside an ex-tax\n * `value` that param asserts a relationship that does not hold and invites the\n * subtraction that removes tax a second time (AGL-1639/AGL-1641 both declined\n * it, and the asymmetry with `shipping` there is not an inconsistency to tidy).\n *\n * ## No `shipping` param at all\n *\n * Commerce always sends `shipping`, including as 0, because on a storefront 0\n * is a true statement about an order that carried none. A booking is an\n * appointment: there is no shipping concept to be zero. `shipping` is optional\n * on the taxonomy's `purchase` for exactly this case — \"a plan or a marketplace\n * purchase has no shipping to report and omits it\" — and a booking omits it for\n * the same reason. Sending 0 would put every service business in a shipping\n * report they are not in.\n *\n * ## Why `value` derives from `amount_total`\n *\n * `paidAmountCents` is Stripe's `amount_total` verbatim, so it reconciles with\n * the charge by construction. The booking document stores no price to re-derive\n * from, and a service price edited between session creation and webhook\n * delivery would make any re-derivation disagree with what the guest actually\n * paid.\n *\n * `taxCents` is the merchant's own rate as charged. It has to come from the\n * session metadata rather than from Stripe's tax fields: the rate rides as an\n * ordinary `line_items[1]` Stripe is never told is tax (the AGL-1711\n * construction, which is what keeps the figure the MERCHANT's), so\n * `total_details.amount_tax` reads 0 on a booking that really did charge tax.\n * Reading Stripe's field would report tax-inclusive gross — the AGL-1639\n * overstatement, live.\n *\n * Returns `null` when there is nothing truthful to send. A dropped event is a\n * gap in the merchant's report; a fabricated one is a wrong number in it, and\n * the second is worse.\n */\nexport function buildBookingPurchaseParams(\n source: BookingPurchaseSource,\n): AnalyticsEventParams['purchase'] | null {\n const transactionId = String(source?.transactionId ?? '').trim()\n if (!transactionId) return null\n\n const paidAmountCents = Math.round(Number(source?.paidAmountCents ?? 0))\n // Clamped, so a negative cannot be ADDED back into the value by subtraction.\n const taxCents = Math.max(0, Math.round(Number(source?.taxCents ?? 0)))\n const netOfTaxCents = paidAmountCents - taxCents\n if (!Number.isFinite(netOfTaxCents) || netOfTaxCents <= 0) return null\n\n return {\n transaction_id: transactionId,\n // No currency is stored on a booking and the session is opened in USD;\n // `buildStorefrontPurchaseParams` takes the same default so a merchant\n // running both plugins sees one currency across both revenue lines.\n currency: 'USD',\n value: toAmount(netOfTaxCents),\n items: [\n {\n // The SERVICE id, so a merchant can read revenue per service. The\n // name is the merchant's own content on the merchant's own property,\n // which is why it is sent here and withheld from ours.\n item_id: String(source?.serviceId ?? ''),\n item_name: String(source?.serviceName ?? ''),\n // NO `item_category`, matching the storefront items (docs/ANALYTICS.md,\n // \"The event map\"). In a MERCHANT's property a constant category is a\n // column with one value in it, and their real service categories are\n // not on this payload. It would be actively worse than useless for a\n // merchant running both plugins: products would carry no category and\n // bookings would carry one, so the dimension would be half-populated\n // for a reason GA cannot distinguish from missing data.\n //\n // OUR property is the opposite case and does set it — there `booking`\n // sits beside `subscription` and `marketplace` and separates three\n // real revenue lines.\n price: toAmount(netOfTaxCents),\n quantity: 1,\n },\n ],\n // No `billing_interval`: a booking is not a subscription.\n // No `shipping`: see above.\n }\n}\n\n/**\n * Reduce a stored booking to the wire shape above. Server-side only — it reads\n * fields the projection exists to withhold.\n */\nexport function toBookingPurchaseSource(\n transactionId: string,\n booking: Record<string, any>,\n): BookingPurchaseSource {\n return {\n transactionId,\n paidAmountCents: Number(booking?.['paidAmountCents'] ?? 0),\n taxCents: Number(booking?.['taxCents'] ?? 0),\n serviceId: String(booking?.['serviceId'] ?? ''),\n serviceName: String(booking?.['serviceName'] ?? ''),\n }\n}\n"],"names":["toAmount","cents","Math","round","bookingPlatformNetCents","object","max","Number","metadata","feeCents","shouldSendBookingPlatformPurchase","buildBookingPurchaseParams","source","transactionId","String","trim","paidAmountCents","taxCents","netOfTaxCents","isFinite","transaction_id","currency","value","items","item_id","serviceId","item_name","serviceName","price","quantity","toBookingPurchaseSource","booking"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GAED,+EAA+E,GAC/E,SAASA,SAASC,KAAa;IAC7B,OAAOC,KAAKC,KAAK,CAACF,SAAS;AAC7B;AAEA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASG,wBAAwBC,MAAW;;QACZA;IAArC,OAAOH,KAAKI,GAAG,CAAC,GAAGJ,KAAKC,KAAK,CAACI,eAAOF,2BAAAA,mBAAAA,OAAQG,QAAQ,qBAAhBH,iBAAkBI,QAAQ,mBAAI;AACrE;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,kCAAkCL,MAAW;IAC3D,OAAOD,wBAAwBC,UAAU;AAC3C;AAuBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GACD,OAAO,SAASM,2BACdC,MAA6B;;IAE7B,MAAMC,gBAAgBC,eAAOF,0BAAAA,OAAQC,aAAa,mBAAI,IAAIE,IAAI;IAC9D,IAAI,CAACF,eAAe,OAAO;IAE3B,MAAMG,kBAAkBd,KAAKC,KAAK,CAACI,gBAAOK,0BAAAA,OAAQI,eAAe,oBAAI;IACrE,6EAA6E;IAC7E,MAAMC,WAAWf,KAAKI,GAAG,CAAC,GAAGJ,KAAKC,KAAK,CAACI,gBAAOK,0BAAAA,OAAQK,QAAQ,oBAAI;IACnE,MAAMC,gBAAgBF,kBAAkBC;IACxC,IAAI,CAACV,OAAOY,QAAQ,CAACD,kBAAkBA,iBAAiB,GAAG,OAAO;IAElE,OAAO;QACLE,gBAAgBP;QAChB,uEAAuE;QACvE,uEAAuE;QACvE,oEAAoE;QACpEQ,UAAU;QACVC,OAAOtB,SAASkB;QAChBK,OAAO;YACL;gBACE,kEAAkE;gBAClE,qEAAqE;gBACrE,uDAAuD;gBACvDC,SAASV,gBAAOF,0BAAAA,OAAQa,SAAS,oBAAI;gBACrCC,WAAWZ,gBAAOF,0BAAAA,OAAQe,WAAW,oBAAI;gBACzC,wEAAwE;gBACxE,sEAAsE;gBACtE,qEAAqE;gBACrE,qEAAqE;gBACrE,sEAAsE;gBACtE,qEAAqE;gBACrE,wDAAwD;gBACxD,EAAE;gBACF,sEAAsE;gBACtE,mEAAmE;gBACnE,sBAAsB;gBACtBC,OAAO5B,SAASkB;gBAChBW,UAAU;YACZ;SACD;IAGH;AACF;AAEA;;;CAGC,GACD,OAAO,SAASC,wBACdjB,aAAqB,EACrBkB,OAA4B;;IAE5B,OAAO;QACLlB;QACAG,iBAAiBT,eAAOwB,2BAAAA,OAAS,CAAC,kBAAkB,mBAAI;QACxDd,UAAUV,gBAAOwB,2BAAAA,OAAS,CAAC,WAAW,oBAAI;QAC1CN,WAAWX,gBAAOiB,2BAAAA,OAAS,CAAC,YAAY,oBAAI;QAC5CJ,aAAab,gBAAOiB,2BAAAA,OAAS,CAAC,cAAc,oBAAI;IAClD;AACF"}
|