@aglyn/plugins-bookings 1.0.0-beta.229 → 1.0.0-beta.230

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/utils/use-booking-purchase-event.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 { trackEvent } from '@aglyn/aglyn/app-utils/analytics-events'\nimport { useEffect } from 'react'\nimport { buildBookingPurchaseParams } from '../model/booking-purchase-analytics'\n\n/** How long to wait out the `checkout.session.completed` webhook. */\nconst MAX_ATTEMPTS = 8\nconst RETRY_MS = 1_500\n/** How long to wait for the consent-gated gtag script to exist. */\nconst GTAG_ATTEMPTS = 20\nconst GTAG_MS = 250\n\n/**\n * Once per browser session per booking. `sessionStorage` survives the reload;\n * the in-memory set covers a page carrying more than one Booking widget, which\n * would otherwise race before either had written.\n */\nconst reported = new Set<string>()\nconst STORAGE_PREFIX = 'aglyn:booking-purchase-reported:'\n\nfunction alreadyReported(sessionId: string): boolean {\n if (reported.has(sessionId)) return true\n try {\n return window.sessionStorage.getItem(STORAGE_PREFIX + sessionId) === '1'\n } catch {\n // Private mode / storage disabled. GA4's own de-duplication on\n // `transaction_id` is the real guarantee; this is only the cheap half.\n return false\n }\n}\n\nfunction markReported(sessionId: string): void {\n reported.add(sessionId)\n try {\n window.sessionStorage.setItem(STORAGE_PREFIX + sessionId, '1')\n } catch {\n /* see above */\n }\n}\n\nconst sleep = (ms: number) =>\n new Promise<void>((resolve) => setTimeout(resolve, ms))\n\n/**\n * Report a paid booking to the MERCHANT's GA4 property (AGL-2481).\n *\n * ## Why this exists\n *\n * The bookings plugin sent no analytics event of any kind. A merchant selling\n * appointments through Aglyn saw traffic on the page, and then nothing — which\n * does not read as \"bookings are not measured\", it reads as a 100% abandonment\n * rate on every service they sell, and GA4's ecommerce reports and shopping\n * funnel are all terminated by `purchase`. Commerce closed exactly this gap for\n * products (AGL-1641); services were left behind.\n *\n * ## Client-side, deliberately — the inverse of the call made for OUR revenue\n *\n * Aglyn's own cut of the booking is sent server-side from the Stripe webhook,\n * because a lost hit there is lost revenue reporting on our books. This one is\n * client-side, and the reasoning inverts with the consequence: server-side\n * would require collecting, encrypting and supporting a per-host Measurement\n * Protocol API secret we have nowhere to store, to buy accuracy against closed\n * tabs and ad blockers that every storefront on the internet already accepts.\n * `ga4-measurement-protocol.ts` is not that channel — it holds one global\n * credential pair, which is AGLYN's property.\n *\n * The two hits therefore land in two different properties carrying two\n * different figures — our fee there, the merchant's gross ex-tax here — and\n * neither double-counts the other.\n *\n * ## Where it fires\n *\n * On the page Stripe returns the guest to, which is the page they left: a\n * tenant site has no booking-confirmation route, so `success_url` comes back to\n * the site root carrying `?booking=paid&session_id=…`.\n *\n * ## The two waits\n *\n * The booking is confirmed by the webhook, which can land AFTER the guest is\n * back, so the lookup retries. And `window.gtag` only exists once the visitor\n * has granted analytics consent and the consent-gated script has loaded\n * post-hydration, so the send waits for it — otherwise the event would be built\n * correctly and dropped on the floor.\n *\n * Both waits are bounded, and a visitor who never granted consent simply times\n * out. That is the consent gate working, not a bug to route around:\n * `trackEvent` drops rather than queues, deliberately, so an event fired before\n * consent is gone for good instead of replaying a pre-consent hit later.\n */\nexport function useBookingPurchaseEvent(\n hostId: string,\n /** `Aglyn.useSiteFetch()`. A GET, so preview mode passes it straight through. */\n siteFetch: typeof fetch = fetch,\n): void {\n useEffect(() => {\n if (typeof window === 'undefined' || !hostId) return\n\n const params = new URLSearchParams(window.location.search)\n if (params.get('booking') !== 'paid') return\n const sessionId = params.get('session_id') ?? ''\n if (!sessionId || alreadyReported(sessionId)) return\n\n let active = true\n // Claimed up front so a sibling widget in the same document cannot start a\n // second poll for the same booking. Persisted only once the hit is sent.\n reported.add(sessionId)\n\n void (async () => {\n let source: Parameters<typeof buildBookingPurchaseParams>[0] | null = null\n for (let attempt = 0; attempt < MAX_ATTEMPTS && active; attempt += 1) {\n try {\n const response = await siteFetch(\n `/api/bookings/booking-analytics?hostId=${encodeURIComponent(\n hostId,\n )}&sessionId=${encodeURIComponent(sessionId)}`,\n )\n if (response.ok) {\n source = await response.json()\n break\n }\n // 404 means the webhook has not confirmed the booking yet; anything\n // else is terminal and retrying it would only add load.\n if (response.status !== 404) return\n } catch {\n // Network blip — treated like the race and retried.\n }\n await sleep(RETRY_MS)\n }\n if (!active || !source) return\n\n const purchase = buildBookingPurchaseParams(source)\n if (!purchase) return\n\n // `trackEvent` no-ops when `window.gtag` is absent and never queues, so\n // waiting for the tag IS the delivery mechanism here.\n for (let attempt = 0; attempt < GTAG_ATTEMPTS && active; attempt += 1) {\n if (typeof (window as any).gtag === 'function') {\n trackEvent('purchase', purchase)\n markReported(sessionId)\n return\n }\n await sleep(GTAG_MS)\n }\n })()\n\n return () => {\n active = false\n }\n // `siteFetch` is deliberately out of the dep list: it is a `useCallback`\n // that changes identity on a preview toggle, and re-running this effect\n // would restart a poll that has already claimed its session id.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [hostId])\n}\n"],"names":["trackEvent","useEffect","buildBookingPurchaseParams","MAX_ATTEMPTS","RETRY_MS","GTAG_ATTEMPTS","GTAG_MS","reported","Set","STORAGE_PREFIX","alreadyReported","sessionId","has","window","sessionStorage","getItem","markReported","add","setItem","sleep","ms","Promise","resolve","setTimeout","useBookingPurchaseEvent","hostId","siteFetch","fetch","params","URLSearchParams","location","search","get","active","source","attempt","response","encodeURIComponent","ok","json","status","purchase","gtag"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,0CAAyC;AACpE,SAASC,SAAS,QAAQ,QAAO;AACjC,SAASC,0BAA0B,QAAQ,yCAAqC;AAEhF,mEAAmE,GACnE,MAAMC,eAAe;AACrB,MAAMC,WAAW;AACjB,iEAAiE,GACjE,MAAMC,gBAAgB;AACtB,MAAMC,UAAU;AAEhB;;;;CAIC,GACD,MAAMC,WAAW,IAAIC;AACrB,MAAMC,iBAAiB;AAEvB,SAASC,gBAAgBC,SAAiB;IACxC,IAAIJ,SAASK,GAAG,CAACD,YAAY,OAAO;IACpC,IAAI;QACF,OAAOE,OAAOC,cAAc,CAACC,OAAO,CAACN,iBAAiBE,eAAe;IACvE,EAAE,eAAM;QACN,+DAA+D;QAC/D,uEAAuE;QACvE,OAAO;IACT;AACF;AAEA,SAASK,aAAaL,SAAiB;IACrCJ,SAASU,GAAG,CAACN;IACb,IAAI;QACFE,OAAOC,cAAc,CAACI,OAAO,CAACT,iBAAiBE,WAAW;IAC5D,EAAE,eAAM;IACN,aAAa,GACf;AACF;AAEA,MAAMQ,QAAQ,CAACC,KACb,IAAIC,QAAc,CAACC,UAAYC,WAAWD,SAASF;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CC,GACD,OAAO,SAASI,wBACdC,MAAc,EACd,+EAA+E,GAC/EC,YAA0BC,KAAK;IAE/B1B,UAAU;YAKU2B;QAJlB,IAAI,OAAOf,WAAW,eAAe,CAACY,QAAQ;QAE9C,MAAMG,SAAS,IAAIC,gBAAgBhB,OAAOiB,QAAQ,CAACC,MAAM;QACzD,IAAIH,OAAOI,GAAG,CAAC,eAAe,QAAQ;QACtC,MAAMrB,aAAYiB,cAAAA,OAAOI,GAAG,CAAC,yBAAXJ,cAA4B;QAC9C,IAAI,CAACjB,aAAaD,gBAAgBC,YAAY;QAE9C,IAAIsB,SAAS;QACb,2EAA2E;QAC3E,yEAAyE;QACzE1B,SAASU,GAAG,CAACN;QAEb,KAAK,AAAC,CAAA;YACJ,IAAIuB,SAAkE;YACtE,IAAK,IAAIC,UAAU,GAAGA,UAAUhC,gBAAgB8B,QAAQE,WAAW,EAAG;gBACpE,IAAI;oBACF,MAAMC,WAAW,MAAMV,UACrB,CAAC,uCAAuC,EAAEW,mBACxCZ,QACA,WAAW,EAAEY,mBAAmB1B,YAAY;oBAEhD,IAAIyB,SAASE,EAAE,EAAE;wBACfJ,SAAS,MAAME,SAASG,IAAI;wBAC5B;oBACF;oBACA,oEAAoE;oBACpE,wDAAwD;oBACxD,IAAIH,SAASI,MAAM,KAAK,KAAK;gBAC/B,EAAE,eAAM;gBACN,oDAAoD;gBACtD;gBACA,MAAMrB,MAAMf;YACd;YACA,IAAI,CAAC6B,UAAU,CAACC,QAAQ;YAExB,MAAMO,WAAWvC,2BAA2BgC;YAC5C,IAAI,CAACO,UAAU;YAEf,wEAAwE;YACxE,sDAAsD;YACtD,IAAK,IAAIN,UAAU,GAAGA,UAAU9B,iBAAiB4B,QAAQE,WAAW,EAAG;gBACrE,IAAI,OAAO,AAACtB,OAAe6B,IAAI,KAAK,YAAY;oBAC9C1C,WAAW,YAAYyC;oBACvBzB,aAAaL;oBACb;gBACF;gBACA,MAAMQ,MAAMb;YACd;QACF,CAAA;QAEA,OAAO;YACL2B,SAAS;QACX;IACA,yEAAyE;IACzE,wEAAwE;IACxE,gEAAgE;IAChE,uDAAuD;IACzD,GAAG;QAACR;KAAO;AACb"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/bookings/src/lib/utils/use-booking-purchase-event.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 { trackEvent } from '@aglyn/aglyn/app-utils/analytics-events'\nimport { recordSiteJourneyStep } from '@aglyn/aglyn/app-utils/site-journey'\nimport { useEffect } from 'react'\nimport { buildBookingPurchaseParams } from '../model/booking-purchase-analytics'\n\n/** How long to wait out the `checkout.session.completed` webhook. */\nconst MAX_ATTEMPTS = 8\nconst RETRY_MS = 1_500\n/** How long to wait for the consent-gated gtag script to exist. */\nconst GTAG_ATTEMPTS = 20\nconst GTAG_MS = 250\n\n/**\n * Once per browser session per booking. `sessionStorage` survives the reload;\n * the in-memory set covers a page carrying more than one Booking widget, which\n * would otherwise race before either had written.\n */\nconst reported = new Set<string>()\nconst STORAGE_PREFIX = 'aglyn:booking-purchase-reported:'\n\nfunction alreadyReported(sessionId: string): boolean {\n if (reported.has(sessionId)) return true\n try {\n return window.sessionStorage.getItem(STORAGE_PREFIX + sessionId) === '1'\n } catch {\n // Private mode / storage disabled. GA4's own de-duplication on\n // `transaction_id` is the real guarantee; this is only the cheap half.\n return false\n }\n}\n\nfunction markReported(sessionId: string): void {\n reported.add(sessionId)\n try {\n window.sessionStorage.setItem(STORAGE_PREFIX + sessionId, '1')\n } catch {\n /* see above */\n }\n}\n\nconst sleep = (ms: number) =>\n new Promise<void>((resolve) => setTimeout(resolve, ms))\n\n/**\n * Report a paid booking to the MERCHANT's GA4 property (AGL-2481).\n *\n * ## Why this exists\n *\n * The bookings plugin sent no analytics event of any kind. A merchant selling\n * appointments through Aglyn saw traffic on the page, and then nothing — which\n * does not read as \"bookings are not measured\", it reads as a 100% abandonment\n * rate on every service they sell, and GA4's ecommerce reports and shopping\n * funnel are all terminated by `purchase`. Commerce closed exactly this gap for\n * products (AGL-1641); services were left behind.\n *\n * ## Client-side, deliberately — the inverse of the call made for OUR revenue\n *\n * Aglyn's own cut of the booking is sent server-side from the Stripe webhook,\n * because a lost hit there is lost revenue reporting on our books. This one is\n * client-side, and the reasoning inverts with the consequence: server-side\n * would require collecting, encrypting and supporting a per-host Measurement\n * Protocol API secret we have nowhere to store, to buy accuracy against closed\n * tabs and ad blockers that every storefront on the internet already accepts.\n * `ga4-measurement-protocol.ts` is not that channel — it holds one global\n * credential pair, which is AGLYN's property.\n *\n * The two hits therefore land in two different properties carrying two\n * different figures — our fee there, the merchant's gross ex-tax here — and\n * neither double-counts the other.\n *\n * ## Where it fires\n *\n * On the page Stripe returns the guest to, which is the page they left: a\n * tenant site has no booking-confirmation route, so `success_url` comes back to\n * the site root carrying `?booking=paid&session_id=…`.\n *\n * ## The two waits\n *\n * The booking is confirmed by the webhook, which can land AFTER the guest is\n * back, so the lookup retries. And `window.gtag` only exists once the visitor\n * has granted analytics consent and the consent-gated script has loaded\n * post-hydration, so the send waits for it — otherwise the event would be built\n * correctly and dropped on the floor.\n *\n * Both waits are bounded, and a visitor who never granted consent simply times\n * out. That is the consent gate working, not a bug to route around:\n * `trackEvent` drops rather than queues, deliberately, so an event fired before\n * consent is gone for good instead of replaying a pre-consent hit later.\n */\nexport function useBookingPurchaseEvent(\n hostId: string,\n /** `Aglyn.useSiteFetch()`. A GET, so preview mode passes it straight through. */\n siteFetch: typeof fetch = fetch,\n): void {\n useEffect(() => {\n if (typeof window === 'undefined' || !hostId) return\n\n const params = new URLSearchParams(window.location.search)\n if (params.get('booking') !== 'paid') return\n const sessionId = params.get('session_id') ?? ''\n if (!sessionId || alreadyReported(sessionId)) return\n\n let active = true\n // Claimed up front so a sibling widget in the same document cannot start a\n // second poll for the same booking. Persisted only once the hit is sent.\n reported.add(sessionId)\n\n void (async () => {\n let source: Parameters<typeof buildBookingPurchaseParams>[0] | null = null\n for (let attempt = 0; attempt < MAX_ATTEMPTS && active; attempt += 1) {\n try {\n const response = await siteFetch(\n `/api/bookings/booking-analytics?hostId=${encodeURIComponent(\n hostId,\n )}&sessionId=${encodeURIComponent(sessionId)}`,\n )\n if (response.ok) {\n source = await response.json()\n break\n }\n // 404 means the webhook has not confirmed the booking yet; anything\n // else is terminal and retrying it would only add load.\n if (response.status !== 404) return\n } catch {\n // Network blip — treated like the race and retried.\n }\n await sleep(RETRY_MS)\n }\n if (!active || !source) return\n\n const purchase = buildBookingPurchaseParams(source)\n if (!purchase) return\n // A funnel step (AGL-3605), recorded before the wait for the tag: the\n // journey has its own consent gate and does not need GA on the page.\n recordSiteJourneyStep('booking', purchase.items?.[0]?.item_id)\n\n // `trackEvent` no-ops when `window.gtag` is absent and never queues, so\n // waiting for the tag IS the delivery mechanism here.\n for (let attempt = 0; attempt < GTAG_ATTEMPTS && active; attempt += 1) {\n if (typeof (window as any).gtag === 'function') {\n trackEvent('purchase', purchase)\n markReported(sessionId)\n return\n }\n await sleep(GTAG_MS)\n }\n })()\n\n return () => {\n active = false\n }\n // `siteFetch` is deliberately out of the dep list: it is a `useCallback`\n // that changes identity on a preview toggle, and re-running this effect\n // would restart a poll that has already claimed its session id.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [hostId])\n}\n"],"names":["trackEvent","recordSiteJourneyStep","useEffect","buildBookingPurchaseParams","MAX_ATTEMPTS","RETRY_MS","GTAG_ATTEMPTS","GTAG_MS","reported","Set","STORAGE_PREFIX","alreadyReported","sessionId","has","window","sessionStorage","getItem","markReported","add","setItem","sleep","ms","Promise","resolve","setTimeout","useBookingPurchaseEvent","hostId","siteFetch","fetch","params","URLSearchParams","location","search","get","active","purchase","source","attempt","response","encodeURIComponent","ok","json","status","items","item_id","gtag"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,0CAAyC;AACpE,SAASC,qBAAqB,QAAQ,sCAAqC;AAC3E,SAASC,SAAS,QAAQ,QAAO;AACjC,SAASC,0BAA0B,QAAQ,yCAAqC;AAEhF,mEAAmE,GACnE,MAAMC,eAAe;AACrB,MAAMC,WAAW;AACjB,iEAAiE,GACjE,MAAMC,gBAAgB;AACtB,MAAMC,UAAU;AAEhB;;;;CAIC,GACD,MAAMC,WAAW,IAAIC;AACrB,MAAMC,iBAAiB;AAEvB,SAASC,gBAAgBC,SAAiB;IACxC,IAAIJ,SAASK,GAAG,CAACD,YAAY,OAAO;IACpC,IAAI;QACF,OAAOE,OAAOC,cAAc,CAACC,OAAO,CAACN,iBAAiBE,eAAe;IACvE,EAAE,eAAM;QACN,+DAA+D;QAC/D,uEAAuE;QACvE,OAAO;IACT;AACF;AAEA,SAASK,aAAaL,SAAiB;IACrCJ,SAASU,GAAG,CAACN;IACb,IAAI;QACFE,OAAOC,cAAc,CAACI,OAAO,CAACT,iBAAiBE,WAAW;IAC5D,EAAE,eAAM;IACN,aAAa,GACf;AACF;AAEA,MAAMQ,QAAQ,CAACC,KACb,IAAIC,QAAc,CAACC,UAAYC,WAAWD,SAASF;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CC,GACD,OAAO,SAASI,wBACdC,MAAc,EACd,+EAA+E,GAC/EC,YAA0BC,KAAK;IAE/B1B,UAAU;YAKU2B;QAJlB,IAAI,OAAOf,WAAW,eAAe,CAACY,QAAQ;QAE9C,MAAMG,SAAS,IAAIC,gBAAgBhB,OAAOiB,QAAQ,CAACC,MAAM;QACzD,IAAIH,OAAOI,GAAG,CAAC,eAAe,QAAQ;QACtC,MAAMrB,aAAYiB,cAAAA,OAAOI,GAAG,CAAC,yBAAXJ,cAA4B;QAC9C,IAAI,CAACjB,aAAaD,gBAAgBC,YAAY;QAE9C,IAAIsB,SAAS;QACb,2EAA2E;QAC3E,yEAAyE;QACzE1B,SAASU,GAAG,CAACN;QAEb,KAAK,AAAC,CAAA;gBA2B6BuB,kBAAAA;YA1BjC,IAAIC,SAAkE;YACtE,IAAK,IAAIC,UAAU,GAAGA,UAAUjC,gBAAgB8B,QAAQG,WAAW,EAAG;gBACpE,IAAI;oBACF,MAAMC,WAAW,MAAMX,UACrB,CAAC,uCAAuC,EAAEY,mBACxCb,QACA,WAAW,EAAEa,mBAAmB3B,YAAY;oBAEhD,IAAI0B,SAASE,EAAE,EAAE;wBACfJ,SAAS,MAAME,SAASG,IAAI;wBAC5B;oBACF;oBACA,oEAAoE;oBACpE,wDAAwD;oBACxD,IAAIH,SAASI,MAAM,KAAK,KAAK;gBAC/B,EAAE,eAAM;gBACN,oDAAoD;gBACtD;gBACA,MAAMtB,MAAMf;YACd;YACA,IAAI,CAAC6B,UAAU,CAACE,QAAQ;YAExB,MAAMD,WAAWhC,2BAA2BiC;YAC5C,IAAI,CAACD,UAAU;YACf,sEAAsE;YACtE,qEAAqE;YACrElC,sBAAsB,YAAWkC,kBAAAA,SAASQ,KAAK,sBAAdR,mBAAAA,eAAgB,CAAC,EAAE,qBAAnBA,iBAAqBS,OAAO;YAE7D,wEAAwE;YACxE,sDAAsD;YACtD,IAAK,IAAIP,UAAU,GAAGA,UAAU/B,iBAAiB4B,QAAQG,WAAW,EAAG;gBACrE,IAAI,OAAO,AAACvB,OAAe+B,IAAI,KAAK,YAAY;oBAC9C7C,WAAW,YAAYmC;oBACvBlB,aAAaL;oBACb;gBACF;gBACA,MAAMQ,MAAMb;YACd;QACF,CAAA;QAEA,OAAO;YACL2B,SAAS;QACX;IACA,yEAAyE;IACzE,wEAAwE;IACxE,gEAAgE;IAChE,uDAAuD;IACzD,GAAG;QAACR;KAAO;AACb"}