@cartbase/storefront 0.1.0

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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @cartbase/storefront/tracking — Meta Pixel + GA4 + Rybbit + Consent Mode v2.
3
+ *
4
+ * Ported from `@1click/ui/src/tracking` (v2.3.1) with the barter seams:
5
+ * - config comes from `GET /api/store/integrations` → `tracking` block
6
+ * via `getTrackingConfig(client)` (never env vars)
7
+ * - server-side CAPI / GA4-MP sending is barter-backend-owned (the
8
+ * `order.placed` forwarder) — this package fires CLIENT events only
9
+ * and writes attribution for the server to inherit
10
+ * - Purchase dedupe: Pixel `eventID = "purchase_" + order.display_id`
11
+ * (trackPurchase builds it); GA4 `transaction_id =
12
+ * String(order.display_id)`
13
+ *
14
+ * Public surface:
15
+ * - <ConsentInit> / <ConsentBanner> / <ConsentSettingsLink> — Consent
16
+ * Mode v2 (config from GET /api/store/consent; `_1c_consent` seam)
17
+ * - <MetaPixel pixelId> / <GA4 measurementId> / <Rybbit siteId> — layout tags
18
+ * - Meta Pixel client helpers: trackViewContent / trackAddToCart /
19
+ * trackInitiateCheckout / trackPurchase / trackLead
20
+ * - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
21
+ * trackGABeginCheckout / trackGAPurchase / setEnhancedConversions
22
+ * - Rybbit client helpers: trackRybbit* (queue-buffered)
23
+ * - getTrackingConfig(client) — the public tag config
24
+ * - getTrackingAttribution(clientHints?, opts?) — server-side signal
25
+ * gathering for the cart.metadata writeback (consent-gated)
26
+ * - getEngagementTimeMsec() / initEngagementTime() — time-on-session
27
+ *
28
+ * Server vs client split (per Next.js App Router rules):
29
+ * - get-tracking-attribution.ts imports `next/headers` (server-only by
30
+ * construction); get-tracking-config.ts is isomorphic
31
+ * - meta-pixel.tsx, ga4.tsx, rybbit.tsx, fbq.ts, gtag.ts,
32
+ * rybbit-events.ts, attribution.ts, use-engagement-time.ts,
33
+ * consent-banner.tsx are `"use client"`
34
+ * - types.ts, consent.ts, consent-init.tsx are universal
35
+ *
36
+ * Importers should usually pull from the subpath that matches their
37
+ * environment. The barrel re-exports everything, but tree-shaking and
38
+ * Next.js's RSC boundary detection both work better with subpaths.
39
+ */
40
+
41
+ export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
42
+ export { GA4 } from "./ga4"
43
+ export { Rybbit } from "./rybbit"
44
+ export { ConsentInit } from "./consent-init"
45
+ export { ConsentBanner, ConsentBannerCard, ConsentSettingsLink } from "./consent-banner"
46
+ export {
47
+ applyConsent,
48
+ setConsent,
49
+ openConsentSettings,
50
+ readConsentCookie,
51
+ writeConsentCookie,
52
+ shouldRenderBanner,
53
+ pickConsentCopy,
54
+ CONSENT_COOKIE,
55
+ CONSENT_MAX_AGE_SECONDS,
56
+ CONSENT_OPEN_EVENT,
57
+ CONSENT_INIT_SNIPPET,
58
+ type ConsentChoices,
59
+ type ConsentCopy,
60
+ type ConsentLayout,
61
+ type ConsentMode,
62
+ type ConsentSettings,
63
+ } from "./consent"
64
+ export {
65
+ trackViewContent,
66
+ trackAddToCart,
67
+ trackInitiateCheckout,
68
+ trackPurchase,
69
+ trackLead,
70
+ generateEventId,
71
+ type LeadContext,
72
+ } from "./fbq"
73
+ export {
74
+ setTrackingDefaults,
75
+ getOrCreateAnonId,
76
+ getOrCreateFbp,
77
+ getOrCreateFbc,
78
+ getKnownVisitor,
79
+ rememberKnownVisitor,
80
+ captureUtmsFromUrl,
81
+ getCapturedFirstTouchUtms,
82
+ getCapturedLastTouchUtms,
83
+ sha256Hex,
84
+ normaliseEmailForHash,
85
+ normalisePhoneForHash,
86
+ type KnownVisitor,
87
+ type CapturedUtms,
88
+ } from "./attribution"
89
+ export {
90
+ trackGAViewItem,
91
+ trackGAAddToCart,
92
+ trackGABeginCheckout,
93
+ trackGAPurchase,
94
+ setEnhancedConversions,
95
+ type EnhancedConversionsInput,
96
+ } from "./gtag"
97
+ export {
98
+ trackRybbitViewItem,
99
+ trackRybbitAddToCart,
100
+ trackRybbitBeginCheckout,
101
+ trackRybbitPurchase,
102
+ type RybbitViewItemData,
103
+ type RybbitAddToCartData,
104
+ type RybbitBeginCheckoutData,
105
+ type RybbitPurchaseData,
106
+ } from "./rybbit-events"
107
+ export { getTrackingConfig } from "./get-tracking-config"
108
+ export { getTrackingAttribution } from "./get-tracking-attribution"
109
+ export {
110
+ getEngagementTimeMsec,
111
+ initEngagementTime,
112
+ } from "./use-engagement-time"
113
+
114
+ export type {
115
+ TrackingConfig,
116
+ TrackingConfigResponse,
117
+ TrackingAttribution,
118
+ TrackingClientHints,
119
+ MetaContentItem,
120
+ ViewContentData,
121
+ AddToCartData,
122
+ InitiateCheckoutData,
123
+ PurchaseData,
124
+ LeadData,
125
+ } from "./types"
126
+
127
+ export type {
128
+ GA4Item,
129
+ GA4ViewItemData,
130
+ GA4AddToCartData,
131
+ GA4BeginCheckoutData,
132
+ GA4PurchaseData,
133
+ } from "./gtag"
@@ -0,0 +1,166 @@
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ import {
6
+ normaliseEmailForHash,
7
+ normalisePhoneForHash,
8
+ rememberKnownVisitor,
9
+ sha256Hex,
10
+ type KnownVisitor,
11
+ } from "./attribution"
12
+ import { CONSENT_COOKIE } from "./consent"
13
+
14
+ /**
15
+ * MetaPixel — client-side base pixel injector.
16
+ *
17
+ * Loads `https://connect.facebook.net/en_US/fbevents.js` via Next.js
18
+ * `<Script strategy="afterInteractive">` (the canonical strategy for
19
+ * third-party tags per Next.js docs). Initializes `fbq` with the given
20
+ * pixel ID and fires an initial `PageView`.
21
+ *
22
+ * The snippet stashes `pixelId` on `window.__1click_fb_pixel_id` so
23
+ * `updatePixelAdvancedMatching()` (called later from checkout / popup)
24
+ * can re-init the Pixel with hashed em / ph for Advanced Matching
25
+ * without the caller having to plumb the pixel id through.
26
+ *
27
+ * Renders nothing when `pixelId` is falsy — every consuming layout
28
+ * can call this unconditionally; the script is only injected when the
29
+ * admin has configured Facebook Pixel + CAPI.
30
+ *
31
+ * Subsequent route navigations DON'T need a manual PageView — the
32
+ * single base init covers the initial page; further events are fired
33
+ * explicitly via the helpers in `./fbq.ts`.
34
+ *
35
+ * Consent gate: fbq('consent', …) is pushed from the `_1c_consent`
36
+ * cookie BEFORE fbq('init') — Meta only honors a pre-init revoke, which
37
+ * makes the Pixel queue every event (incl. this PageView) client-side
38
+ * until the <ConsentBanner> grants; a later fbq('consent','grant')
39
+ * flushes the queue. No cookie (first visit) = revoke.
40
+ */
41
+ export function MetaPixel({ pixelId }: { pixelId?: string }) {
42
+ if (!pixelId) return null
43
+
44
+ const initSnippet = `
45
+ window.__1click_fb_pixel_id='${pixelId}';
46
+ !function(f,b,e,v,n,t,s)
47
+ {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
48
+ n.callMethod.apply(n,arguments):n.queue.push(arguments)};
49
+ if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
50
+ n.queue=[];t=b.createElement(e);t.async=!0;
51
+ t.src=v;s=b.getElementsByTagName(e)[0];
52
+ s.parentNode.insertBefore(t,s)}(window, document,'script',
53
+ 'https://connect.facebook.net/en_US/fbevents.js');
54
+ (function(){
55
+ var ads=false;
56
+ try{
57
+ var m=document.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
58
+ if(m){ads=!!JSON.parse(decodeURIComponent(m[1])).ads;}
59
+ }catch(e){}
60
+ fbq('consent', ads?'grant':'revoke');
61
+ })();
62
+ fbq('init', '${pixelId}');
63
+ fbq('track', 'PageView');
64
+ `.trim()
65
+
66
+ return (
67
+ <>
68
+ <Script
69
+ id="meta-pixel-init"
70
+ strategy="afterInteractive"
71
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
72
+ />
73
+ <noscript>
74
+ <img
75
+ height="1"
76
+ width="1"
77
+ style={{ display: "none" }}
78
+ src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`}
79
+ alt=""
80
+ />
81
+ </noscript>
82
+ </>
83
+ )
84
+ }
85
+
86
+ declare global {
87
+ interface Window {
88
+ __1click_fb_pixel_id?: string
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Re-initialize the Pixel with Advanced Matching params so EVERY
94
+ * subsequent browser-side event is enriched with hashed em / ph /
95
+ * fn / ln / ct / zp automatically. Per Meta docs, fbq('init') with
96
+ * a 3rd arg merges advanced-matching values rather than replacing
97
+ * them — safe to call repeatedly as new fields become known.
98
+ *
99
+ * Caller passes RAW PII (we hash here using Meta's exact normalization
100
+ * so the resulting hash matches what backend CAPI sends — both sides
101
+ * of dedup compare the same digest).
102
+ *
103
+ * Also persists raw values via rememberKnownVisitor() so any future
104
+ * consumer of getKnownVisitor() sees them. In barter the server-side
105
+ * CAPI enrichment is backend-owned (the order.placed forwarder reads
106
+ * hashed PII from the order itself), so this call's job is the
107
+ * browser-side Pixel Advanced Matching.
108
+ *
109
+ * No-ops when:
110
+ * - SSR (no window)
111
+ * - MetaPixel hasn't mounted yet (no `window.__1click_fb_pixel_id`)
112
+ * - fbevents.js hasn't loaded yet (no `window.fbq`)
113
+ *
114
+ * The localStorage write still happens in cases 2-3 so the data is
115
+ * available to any later getKnownVisitor() consumer.
116
+ */
117
+ export async function updatePixelAdvancedMatching(
118
+ visitor: KnownVisitor
119
+ ): Promise<void> {
120
+ if (typeof window === "undefined") return
121
+
122
+ // Persist raw values regardless of Pixel state — later consumers
123
+ // (getKnownVisitor) use these even when Pixel isn't reachable.
124
+ rememberKnownVisitor(visitor)
125
+
126
+ const pixelId = window.__1click_fb_pixel_id
127
+ const fbq = window.fbq
128
+ if (!pixelId || !fbq) return
129
+
130
+ const matching: Record<string, string> = {}
131
+
132
+ if (visitor.email) {
133
+ matching.em = await sha256Hex(normaliseEmailForHash(visitor.email))
134
+ }
135
+ if (visitor.phone) {
136
+ matching.ph = await sha256Hex(normalisePhoneForHash(visitor.phone))
137
+ }
138
+ if (visitor.firstName) {
139
+ matching.fn = await sha256Hex(visitor.firstName.trim().toLowerCase())
140
+ }
141
+ if (visitor.lastName) {
142
+ matching.ln = await sha256Hex(visitor.lastName.trim().toLowerCase())
143
+ }
144
+ if (visitor.city) {
145
+ matching.ct = await sha256Hex(
146
+ visitor.city.trim().toLowerCase().replace(/\s+/g, "")
147
+ )
148
+ }
149
+ if (visitor.state) {
150
+ matching.st = await sha256Hex(
151
+ visitor.state.trim().toLowerCase().replace(/\s+/g, "")
152
+ )
153
+ }
154
+ if (visitor.postalCode) {
155
+ matching.zp = await sha256Hex(
156
+ visitor.postalCode.trim().toLowerCase().replace(/\s+/g, "")
157
+ )
158
+ }
159
+ if (visitor.country) {
160
+ matching.country = await sha256Hex(visitor.country.trim().toLowerCase())
161
+ }
162
+
163
+ if (Object.keys(matching).length === 0) return
164
+
165
+ fbq("init", pixelId, matching)
166
+ }
@@ -0,0 +1,242 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Typed wrappers around the global `window.rybbit.event()` from the
5
+ * Rybbit tracker script (`<Rybbit siteId={...} />`).
6
+ *
7
+ * Rybbit only supports STRING and NUMBER property values (no arrays,
8
+ * no objects, no booleans) and caps the property payload at 2KB
9
+ * (https://rybbit.com/docs/track-events). The helpers below stick to
10
+ * that contract — multi-item events flatten line ids into a
11
+ * comma-separated string, which is enough for aggregation in Rybbit's
12
+ * Custom Events view. Pixel/GA4 retain the rich item-level breakdown.
13
+ *
14
+ * Script-load race resilience (added v2.2.6):
15
+ *
16
+ * The Rybbit tracker is loaded by `<Rybbit>` via Next.js Script with
17
+ * strategy="afterInteractive" — `window.rybbit` is undefined for the
18
+ * first 50-800ms after page load while the script downloads. During
19
+ * that window, useEffect-mounted tracking calls would previously
20
+ * silently no-op (`safeRybbit()` returned null and the caller had no
21
+ * way to retry).
22
+ *
23
+ * This file now maintains a small per-page queue: when fireEvent is
24
+ * called before `window.rybbit` exists, the call is pushed onto a
25
+ * module-level queue and a single drainer is scheduled that polls
26
+ * for `window.rybbit` and replays everything once the script loads.
27
+ *
28
+ * The drainer caps total wait at 10 seconds — beyond that the script
29
+ * is presumed permanently blocked (ad-blocker, CSP rejection, network
30
+ * failure) and the queue is discarded so we don't leak memory.
31
+ *
32
+ * This is the canonical pattern for async tag loaders — same as
33
+ * Google's `window.dataLayer` (queue → consumed by gtag.js on load)
34
+ * and Segment's `analytics.js` snippet (method-stub queue → flushed
35
+ * on script ready).
36
+ *
37
+ * IMPORTANT: server-side firing (e.g. Rybbit Purchase from the
38
+ * `order.placed` subscriber in medusa-mindpages) remains the truth
39
+ * source for high-value conversions. This queue improves browser-side
40
+ * funnel events (view_item / add_to_cart / begin_checkout) where the
41
+ * server doesn't fire. Purchase still has the server flag guard.
42
+ *
43
+ * All helpers stay no-op-safe when called outside a browser (SSR) and
44
+ * when Rybbit is admin-disabled (the `<Rybbit>` component renders
45
+ * nothing for missing siteId, so `window.rybbit` simply never appears
46
+ * and the 10s drain timeout discards the queue silently).
47
+ *
48
+ * Event names match GA4 / industry vocabulary so the same dashboard
49
+ * names work across analytics tools:
50
+ * view_item · add_to_cart · begin_checkout · purchase
51
+ */
52
+
53
+ export type RybbitEventProps = Record<string, string | number>
54
+
55
+ type RybbitFn = {
56
+ event: (eventName: string, properties?: RybbitEventProps) => void
57
+ pageview?: () => void
58
+ identify?: (userId: string, traits?: RybbitEventProps) => void
59
+ }
60
+
61
+ declare global {
62
+ interface Window {
63
+ rybbit?: RybbitFn
64
+ }
65
+ }
66
+
67
+ function safeRybbit(): RybbitFn | null {
68
+ if (typeof window === "undefined") return null
69
+ return window.rybbit ?? null
70
+ }
71
+
72
+ /**
73
+ * Truncate a comma-joined list of ids so the resulting properties
74
+ * payload stays under Rybbit's 2KB limit. Other props are short, so
75
+ * a 1500-char cap on the joined string leaves comfortable headroom.
76
+ */
77
+ function joinIds(ids: string[]): string {
78
+ const joined = ids.join(",")
79
+ return joined.length <= 1500 ? joined : joined.slice(0, 1500)
80
+ }
81
+
82
+ // ── Script-load race queue ────────────────────────────────────────────
83
+
84
+ type PendingEvent = { name: string; props?: RybbitEventProps }
85
+
86
+ /** Calls made before `window.rybbit` exists wait here. Module-level so
87
+ * every fireEvent() across the page shares the same queue. */
88
+ const pendingQueue: PendingEvent[] = []
89
+
90
+ /** Single in-flight drainer guard — prevents N parallel pollers if N
91
+ * events queue during the same script-load window. */
92
+ let drainScheduled = false
93
+
94
+ const DRAIN_FIRST_DELAY_MS = 50
95
+ const DRAIN_POLL_INTERVAL_MS = 100
96
+ const DRAIN_MAX_WAIT_MS = 10_000
97
+
98
+ /**
99
+ * Fire an event immediately if Rybbit is ready, or queue + schedule a
100
+ * drain otherwise. All public helpers below go through this so the
101
+ * queue covers every event type uniformly — no caller has to know
102
+ * whether the script has loaded yet.
103
+ */
104
+ function fireEvent(name: string, props?: RybbitEventProps): void {
105
+ const r = safeRybbit()
106
+ if (r) {
107
+ try {
108
+ r.event(name, props)
109
+ } catch {
110
+ // Defensive — Rybbit's event() throwing would be unusual but
111
+ // tracking failures must never break the user flow.
112
+ }
113
+ return
114
+ }
115
+ if (typeof window === "undefined") return // SSR — drop silently
116
+ pendingQueue.push({ name, props })
117
+ scheduleDrain()
118
+ }
119
+
120
+ /**
121
+ * Poll for `window.rybbit` and drain the queue when it appears.
122
+ * Bounded — gives up after DRAIN_MAX_WAIT_MS so a permanently-blocked
123
+ * Rybbit (ad-blocker, CSP, offline) doesn't grow the queue forever.
124
+ */
125
+ function scheduleDrain(): void {
126
+ if (drainScheduled) return
127
+ if (typeof window === "undefined") return
128
+ drainScheduled = true
129
+
130
+ const startedAt = Date.now()
131
+ const tick = (): void => {
132
+ const r = safeRybbit()
133
+ if (r) {
134
+ const drained = pendingQueue.splice(0, pendingQueue.length)
135
+ for (const ev of drained) {
136
+ try {
137
+ r.event(ev.name, ev.props)
138
+ } catch {
139
+ // Don't let one bad event stop the rest from draining
140
+ }
141
+ }
142
+ drainScheduled = false
143
+ return
144
+ }
145
+ if (Date.now() - startedAt > DRAIN_MAX_WAIT_MS) {
146
+ // Rybbit will not load (ad-blocker, CSP, network failure, admin
147
+ // disabled). Discard the queue so we don't leak memory if the
148
+ // user navigates around the SPA with the script permanently dead.
149
+ pendingQueue.length = 0
150
+ drainScheduled = false
151
+ return
152
+ }
153
+ setTimeout(tick, DRAIN_POLL_INTERVAL_MS)
154
+ }
155
+ setTimeout(tick, DRAIN_FIRST_DELAY_MS)
156
+ }
157
+
158
+ // ── Public event helpers ──────────────────────────────────────────────
159
+
160
+ export type RybbitViewItemData = {
161
+ item_id: string
162
+ item_name: string
163
+ currency: string
164
+ value: number
165
+ }
166
+
167
+ export type RybbitAddToCartData = {
168
+ item_id: string
169
+ item_name: string
170
+ quantity: number
171
+ currency: string
172
+ value: number
173
+ }
174
+
175
+ export type RybbitBeginCheckoutData = {
176
+ item_ids: string[]
177
+ num_items: number
178
+ currency: string
179
+ value: number
180
+ }
181
+
182
+ export type RybbitPurchaseData = {
183
+ transaction_id: string
184
+ item_ids: string[]
185
+ num_items: number
186
+ currency: string
187
+ value: number
188
+ }
189
+
190
+ export function trackRybbitViewItem(data: RybbitViewItemData): void {
191
+ fireEvent("view_item", {
192
+ item_id: data.item_id,
193
+ item_name: data.item_name,
194
+ currency: data.currency,
195
+ value: data.value,
196
+ })
197
+ }
198
+
199
+ export function trackRybbitAddToCart(data: RybbitAddToCartData): void {
200
+ fireEvent("add_to_cart", {
201
+ item_id: data.item_id,
202
+ item_name: data.item_name,
203
+ quantity: data.quantity,
204
+ currency: data.currency,
205
+ value: data.value,
206
+ })
207
+ }
208
+
209
+ export function trackRybbitBeginCheckout(
210
+ data: RybbitBeginCheckoutData
211
+ ): void {
212
+ fireEvent("begin_checkout", {
213
+ item_ids: joinIds(data.item_ids),
214
+ num_items: data.num_items,
215
+ currency: data.currency,
216
+ value: data.value,
217
+ })
218
+ }
219
+
220
+ /**
221
+ * Purchase event — fired client-side on the order-confirmed page.
222
+ *
223
+ * Browser-side Purchase is now redundant when the server-side fire
224
+ * has succeeded (medusa-mindpages tracking-events.ts subscriber sets
225
+ * `order.metadata.rybbit_purchase_fired`). The storefront's
226
+ * `<TrackPurchase>` reads that flag and skips this call when true.
227
+ * Server-side is the truth source; this browser-side call is the
228
+ * realtime safety net for the very first /confirmed visit when the
229
+ * subscriber hasn't finished yet.
230
+ *
231
+ * Even on first-visit, the queue above means a script-load race no
232
+ * longer drops the event — it'll fire as soon as Rybbit loads.
233
+ */
234
+ export function trackRybbitPurchase(data: RybbitPurchaseData): void {
235
+ fireEvent("purchase", {
236
+ transaction_id: data.transaction_id,
237
+ item_ids: joinIds(data.item_ids),
238
+ num_items: data.num_items,
239
+ currency: data.currency,
240
+ value: data.value,
241
+ })
242
+ }
@@ -0,0 +1,40 @@
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ /**
6
+ * Rybbit — privacy-friendly self-hosted analytics tracker injector.
7
+ *
8
+ * Loads `<baseUrl>/api/script.js` via Next.js `<Script
9
+ * strategy="afterInteractive">`. The script auto-fires pageviews on
10
+ * initial load and on SPA route changes, so storefronts don't need
11
+ * to wire route-change effects.
12
+ *
13
+ * `siteId` and `baseUrl` are platform-managed — Rybbit is the platform's
14
+ * self-hosted, cookieless analytics, provisioned per store from platform
15
+ * env, and is deliberately ABSENT from the barter store-config `tracking`
16
+ * block (`src/lib/tracking/store-config-block.ts`). The host app passes
17
+ * the values as props directly (e.g. from its own env). The merchant
18
+ * never sees or configures them. Rybbit needs no consent gate.
19
+ *
20
+ * Renders nothing when `siteId` is falsy — every consuming layout can
21
+ * call this unconditionally.
22
+ */
23
+ export function Rybbit({
24
+ siteId,
25
+ baseUrl = "https://analytics.fam.social",
26
+ }: {
27
+ siteId?: string
28
+ baseUrl?: string
29
+ }) {
30
+ if (!siteId) return null
31
+
32
+ return (
33
+ <Script
34
+ id="rybbit-tracker"
35
+ src={`${baseUrl}/api/script.js`}
36
+ data-site-id={siteId}
37
+ strategy="afterInteractive"
38
+ />
39
+ )
40
+ }