@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,168 @@
1
+ "use client"
2
+
3
+ import type {
4
+ AddToCartData,
5
+ InitiateCheckoutData,
6
+ LeadData,
7
+ PurchaseData,
8
+ ViewContentData,
9
+ } from "./types"
10
+
11
+ /**
12
+ * Typed wrappers around the global `fbq()` from Meta Pixel.
13
+ *
14
+ * Ported from `@1click/ui/src/tracking/fbq.ts` (v2.3.1) with ONE deliberate
15
+ * seam change: the browser-side `fireCapiEvent` dual-fire is REMOVED —
16
+ * in barter, server-side Conversions API sending is backend-owned (the
17
+ * `order.placed` forwarder, `src/lib/tracking/forward.ts` + admin-api.md
18
+ * §34). This package fires CLIENT Pixel events only; the ad-blocked /
19
+ * iOS-ATT recovery the dual-fire provided is covered by the backend CAPI
20
+ * Purchase, which inherits fbp/fbc/anon-id from the attribution keys the
21
+ * storefront writes into `cart.metadata` (see get-tracking-attribution.ts).
22
+ *
23
+ * Event-id contract (unchanged — the part that must never drift):
24
+ * - Purchase: `eventID = "purchase_" + order.display_id`, built by
25
+ * `trackPurchase()` from the caller's `order.display_id` so the format
26
+ * can never drift. The backend CAPI Purchase uses the SAME id
27
+ * (`purchaseEventId()` in `src/lib/tracking/constants.ts`); Meta
28
+ * dedupes browser Pixel + server CAPI by event_name + event_id
29
+ * within ~3 days.
30
+ * - Everything else: `<eventname>_<unixSeconds>_<6 hex>` via
31
+ * `generateEventId()` (the exact @1click capi.ts format, mirrored
32
+ * by `src/lib/tracking/constants.ts generateEventId`).
33
+ *
34
+ * All helpers no-op when `window.fbq` is undefined (pixel not loaded —
35
+ * SSR, ad-blocker, admin disabled). Storefronts can call them freely
36
+ * without guards.
37
+ */
38
+
39
+ type FbqFn = {
40
+ (command: "init", pixelId: string): void
41
+ /** Re-init with Advanced Matching params (em / ph / fn / ln / ct / zp,
42
+ * pre-hashed). Per Meta docs the Pixel merges these with any prior
43
+ * init values and applies them to ALL subsequent track() calls. */
44
+ (
45
+ command: "init",
46
+ pixelId: string,
47
+ advancedMatching: Record<string, string>
48
+ ): void
49
+ (
50
+ command: "track",
51
+ eventName: string,
52
+ data?: Record<string, unknown>,
53
+ options?: { eventID?: string }
54
+ ): void
55
+ (
56
+ command: "trackCustom",
57
+ eventName: string,
58
+ data?: Record<string, unknown>,
59
+ options?: { eventID?: string }
60
+ ): void
61
+ callMethod?: (...args: unknown[]) => void
62
+ queue: unknown[]
63
+ loaded: boolean
64
+ version: string
65
+ }
66
+
67
+ declare global {
68
+ interface Window {
69
+ fbq?: FbqFn
70
+ }
71
+ }
72
+
73
+ function safeFbq(): FbqFn | null {
74
+ if (typeof window === "undefined") return null
75
+ return window.fbq ?? null
76
+ }
77
+
78
+ /**
79
+ * Random event_id for events without a natural stable id —
80
+ * `<eventName.toLowerCase()>_<unixSeconds>_<6 hex chars>`, the exact
81
+ * @1click capi.ts:188-201 format (mirrored server-side by
82
+ * `src/lib/tracking/constants.ts generateEventId`). Exported so callers
83
+ * that hand the id to a backend route (e.g. Lead → subscribe endpoint)
84
+ * can share it across browser + server fires.
85
+ */
86
+ export function generateEventId(eventName: string): string {
87
+ const ts = Math.floor(Date.now() / 1000)
88
+ const rand = (() => {
89
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
90
+ const buf = new Uint8Array(3)
91
+ crypto.getRandomValues(buf)
92
+ return Array.from(buf)
93
+ .map((b) => b.toString(16).padStart(2, "0"))
94
+ .join("")
95
+ }
96
+ return Math.random().toString(16).slice(2, 8)
97
+ })()
98
+ return `${eventName.toLowerCase()}_${ts}_${rand}`
99
+ }
100
+
101
+ export function trackViewContent(data: ViewContentData): void {
102
+ const fbq = safeFbq()
103
+ if (!fbq) return
104
+ fbq("track", "ViewContent", data as unknown as Record<string, unknown>, {
105
+ eventID: generateEventId("ViewContent"),
106
+ })
107
+ }
108
+
109
+ export function trackAddToCart(data: AddToCartData): void {
110
+ const fbq = safeFbq()
111
+ if (!fbq) return
112
+ fbq("track", "AddToCart", data as unknown as Record<string, unknown>, {
113
+ eventID: generateEventId("AddToCart"),
114
+ })
115
+ }
116
+
117
+ export function trackInitiateCheckout(data: InitiateCheckoutData): void {
118
+ const fbq = safeFbq()
119
+ if (!fbq) return
120
+ fbq("track", "InitiateCheckout", data as unknown as Record<string, unknown>, {
121
+ eventID: generateEventId("InitiateCheckout"),
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Purchase event — eventID MUST equal `purchase_${order.display_id}`
127
+ * to dedupe with the barter backend's order.placed CAPI Purchase.
128
+ * Caller passes `order.display_id`; this helper builds the id so the
129
+ * format can never drift between the two sides.
130
+ */
131
+ export function trackPurchase(
132
+ data: PurchaseData,
133
+ orderDisplayId: string | number
134
+ ): void {
135
+ const fbq = safeFbq()
136
+ if (!fbq) return
137
+ fbq("track", "Purchase", data as unknown as Record<string, unknown>, {
138
+ eventID: `purchase_${orderDisplayId}`,
139
+ })
140
+ }
141
+
142
+ export type LeadContext = {
143
+ /** Override the auto-generated event id. Used by callers that need
144
+ * to send the same id to a backend route so server-side CAPI can
145
+ * dedupe with the browser-fired Pixel event. */
146
+ eventId?: string
147
+ }
148
+
149
+ /**
150
+ * Lead event — fired when a visitor submits the marketing popup or
151
+ * any newsletter signup form. Highest-intent pre-purchase signal.
152
+ * Without this Meta has no way to optimize ad delivery for "likely
153
+ * subscribers" or build a Lead lookalike audience from our funnel.
154
+ *
155
+ * Returns the eventId so the caller can pass it to a backend route —
156
+ * a server-side CAPI Lead with the same id dedupes against this
157
+ * browser fire.
158
+ */
159
+ export function trackLead(data: LeadData = {}, context: LeadContext = {}): string {
160
+ const eventId = context.eventId ?? generateEventId("Lead")
161
+ const fbq = safeFbq()
162
+ if (fbq) {
163
+ fbq("track", "Lead", data as unknown as Record<string, unknown>, {
164
+ eventID: eventId,
165
+ })
166
+ }
167
+ return eventId
168
+ }
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ /**
6
+ * GA4 — Google Analytics 4 base script loader.
7
+ *
8
+ * Loads `https://www.googletagmanager.com/gtag/js?id=<measurementId>` via
9
+ * Next.js `<Script strategy="afterInteractive">` and initialises gtag with
10
+ * the given measurement ID. Once loaded, gtag automatically sets the
11
+ * `_ga` and `_ga_<MEASUREMENT_ID>` first-party cookies, which the server
12
+ * action `getTrackingAttribution` then reads on cart completion to
13
+ * forward `ga_client_id` / `ga_session_id` into the order metadata for
14
+ * GA4 Measurement Protocol Purchase events.
15
+ *
16
+ * Renders nothing when `measurementId` is falsy — every consuming layout
17
+ * can call this unconditionally; the script is only injected when the
18
+ * admin has configured GA4.
19
+ *
20
+ * `send_page_view: true` (default) — initial page_view fires automatically
21
+ * on script load. Subsequent SPA route changes are NOT auto-tracked by
22
+ * gtag; storefronts that need per-route page_views should call
23
+ * `gtag('event', 'page_view', { page_path })` from a route-change effect.
24
+ */
25
+ export function GA4({ measurementId }: { measurementId?: string }) {
26
+ if (!measurementId) return null
27
+
28
+ const initSnippet = `
29
+ window.dataLayer = window.dataLayer || [];
30
+ function gtag(){dataLayer.push(arguments);}
31
+ gtag('js', new Date());
32
+ gtag('config', '${measurementId}');
33
+ `.trim()
34
+
35
+ return (
36
+ <>
37
+ <Script
38
+ id="ga4-loader"
39
+ strategy="afterInteractive"
40
+ src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
41
+ />
42
+ <Script
43
+ id="ga4-init"
44
+ strategy="afterInteractive"
45
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
46
+ />
47
+ </>
48
+ )
49
+ }
@@ -0,0 +1,224 @@
1
+ import { cookies, headers } from "next/headers"
2
+
3
+ import { getTrackingConfig } from "./get-tracking-config"
4
+ import type { StorefrontClient } from "../api/http"
5
+ import type { TrackingAttribution, TrackingClientHints } from "./types"
6
+
7
+ /**
8
+ * Reads Meta + GA4 attribution signals from the current Next.js server
9
+ * request and returns the payload to write into `cart.metadata` before
10
+ * completion (consent-gated — only capture when the visitor's `_1c_consent`
11
+ * choice allows it; see store-api.md "Tracking block" wiring contract).
12
+ * Cart completion copies the keys onto `order.metadata`
13
+ * (`src/lib/carts/complete/_order.ts`) and the barter `order.placed`
14
+ * forwarder reads exactly these keys to build the CAPI Purchase event's
15
+ * `user_data` and the GA4 Measurement Protocol Purchase's `client_id` /
16
+ * `session_id`.
17
+ *
18
+ * SERVER-ONLY: imports `next/headers` — call it from RSC / server actions
19
+ * only (the @1click original carried a `server-only` import; this package
20
+ * keeps the constraint by construction since `next/headers` throws outside
21
+ * a request scope).
22
+ *
23
+ * Adaptation vs `@1click/ui`: resolving the `_ga_<MEASUREMENT_ID>` session
24
+ * cookie needs the GA4 measurement id, which the original fetched from env
25
+ * config. Pass either `opts.ga4MeasurementId` (when the layout already
26
+ * fetched the tracking config) or `opts.client` (a `StorefrontClient`; the
27
+ * config fetch is cached 5 min). With neither, the fb_* / utm_* keys and
28
+ * `ga_client_id` still resolve — only `ga_session_id` is skipped.
29
+ *
30
+ * Server-readable (this function reads from cookies/headers):
31
+ * - fb_fbp, fb_fbc — _fbp / _fbc cookies (set by fbq init)
32
+ * - fb_anon_id — _1c_anon cookie (attribution.ts)
33
+ * - fb_user_agent — User-Agent request header
34
+ * - fb_client_ip — x-forwarded-for / x-real-ip header
35
+ * - fb_event_source_url — Referer header
36
+ * - ga_client_id — _ga cookie, schema prefix stripped
37
+ * - ga_session_id — _ga_<MEASUREMENT_ID> cookie, session segment
38
+ * - utm_first_* / utm_last_* — _1c_utm_first / _1c_utm_last cookies
39
+ *
40
+ * Client-only (passed via `clientHints` because the browser computes it):
41
+ * - ga_engagement_time_msec — accumulated time-on-session in ms
42
+ *
43
+ * Returns an object with only the fields that resolved (no undefined
44
+ * keys), so spreading into existing metadata won't clobber other values.
45
+ */
46
+ export async function getTrackingAttribution(
47
+ clientHints?: TrackingClientHints,
48
+ opts?: {
49
+ /** SDK client used to fetch the tracking config (for the GA4 id). */
50
+ client?: StorefrontClient
51
+ /** Skip the config fetch by passing the GA4 measurement id directly. */
52
+ ga4MeasurementId?: string
53
+ }
54
+ ): Promise<TrackingAttribution> {
55
+ const result: TrackingAttribution = {}
56
+
57
+ // Cookies (best-effort — throws outside a request context)
58
+ let fbp: string | undefined
59
+ let fbc: string | undefined
60
+ let anonId: string | undefined
61
+ let gaCookieRaw: string | undefined
62
+ let gaSessionCookieRaw: string | undefined
63
+
64
+ let utmFirstRaw: string | undefined
65
+ let utmLastRaw: string | undefined
66
+
67
+ try {
68
+ const cookieStore = await cookies()
69
+ fbp = cookieStore.get("_fbp")?.value
70
+ fbc = cookieStore.get("_fbc")?.value
71
+ // _1c_anon — our self-managed per-browser visitor id, written by
72
+ // attribution.ts on first tracking call. Surfaces server-side here
73
+ // so the order.placed CAPI Purchase can include it as external_id
74
+ // (alongside customer_id when both exist — Meta accepts an array).
75
+ anonId = cookieStore.get("_1c_anon")?.value
76
+ gaCookieRaw = cookieStore.get("_ga")?.value
77
+ // _1c_utm_first / _1c_utm_last — JSON-encoded UTM tuples written
78
+ // by browser-side captureUtmsFromUrl(). First-touch records the
79
+ // acquisition campaign (365-day TTL); last-touch records the
80
+ // closer (90-day TTL, refreshed on each UTM-bearing visit).
81
+ utmFirstRaw = cookieStore.get("_1c_utm_first")?.value
82
+ utmLastRaw = cookieStore.get("_1c_utm_last")?.value
83
+
84
+ // _ga_<MEASUREMENT_ID> uses the GA4 measurementId (e.g., G-ABCDEF1234)
85
+ // with the "G-" prefix stripped: cookie name = `_ga_ABCDEF1234`.
86
+ // Look it up only if we can learn the measurementId.
87
+ let measurementId = opts?.ga4MeasurementId
88
+ if (!measurementId && opts?.client) {
89
+ const config = await getTrackingConfig(opts.client)
90
+ measurementId = config.ga4?.measurementId
91
+ }
92
+ if (measurementId) {
93
+ const cookieName = `_ga_${measurementId.replace(/^G-/, "")}`
94
+ gaSessionCookieRaw = cookieStore.get(cookieName)?.value
95
+ }
96
+ } catch {
97
+ // best-effort — fall through with what we have
98
+ }
99
+
100
+ if (fbp) result.fb_fbp = fbp
101
+ if (fbc) result.fb_fbc = fbc
102
+ if (anonId) result.fb_anon_id = anonId
103
+
104
+ // Flatten UTM cookies into utm_first_* / utm_last_* keys so the
105
+ // backend (and the order.metadata writeback that follows) can read
106
+ // them with simple JSON path queries — no jsonb_path_query needed.
107
+ // Missing keys are dropped from `result` (not set to undefined) so a
108
+ // partial-touch (e.g. utm_source only) doesn't pollute metadata with
109
+ // null fields.
110
+ applyUtmCookie(utmFirstRaw, "utm_first", result)
111
+ applyUtmCookie(utmLastRaw, "utm_last", result)
112
+
113
+ // _ga cookie format: "GA1.1.<client_id>.<timestamp>" — backend wants
114
+ // the full <client_id>.<timestamp> portion (the canonical GA client_id).
115
+ if (gaCookieRaw) {
116
+ const parts = gaCookieRaw.split(".")
117
+ // GA1 / GS1 schemas: cookie is "<schema>.<sequence>.<id>.<ts>" — the
118
+ // canonical client_id is the last two segments joined with a dot.
119
+ if (parts.length >= 4) {
120
+ result.ga_client_id = `${parts[2]}.${parts[3]}`
121
+ } else {
122
+ result.ga_client_id = gaCookieRaw
123
+ }
124
+ }
125
+
126
+ // _ga_<MEASUREMENT_ID> format: "GS1.1.<session_id>.<count>.<engaged>.<session_start>.<scroll>.<engagement_time>.0"
127
+ // session_id is at index 2.
128
+ if (gaSessionCookieRaw) {
129
+ const parts = gaSessionCookieRaw.split(".")
130
+ if (parts.length >= 3 && parts[2]) {
131
+ result.ga_session_id = parts[2]
132
+ }
133
+ }
134
+
135
+ // Headers (best-effort)
136
+ try {
137
+ const headerStore = await headers()
138
+ const ua = headerStore.get("user-agent")
139
+ if (ua) result.fb_user_agent = ua
140
+
141
+ // x-forwarded-for can be a chain of proxies; leftmost is the original client.
142
+ const xff = headerStore.get("x-forwarded-for")
143
+ const xri = headerStore.get("x-real-ip")
144
+ const ip = xff?.split(",")[0]?.trim() || xri || undefined
145
+ if (ip) result.fb_client_ip = ip
146
+
147
+ const referer = headerStore.get("referer")
148
+ if (referer) result.fb_event_source_url = referer
149
+ } catch {
150
+ // best-effort
151
+ }
152
+
153
+ // Client hints (engagement time — browser only)
154
+ if (
155
+ clientHints?.engagementTimeMsec !== undefined &&
156
+ clientHints.engagementTimeMsec >= 0
157
+ ) {
158
+ result.ga_engagement_time_msec = clientHints.engagementTimeMsec
159
+ }
160
+
161
+ return result
162
+ }
163
+
164
+ // ── UTM cookie parsing ───────────────────────────────────────────────
165
+
166
+ type ParsedUtmCookie = {
167
+ utm_source?: string
168
+ utm_medium?: string
169
+ utm_campaign?: string
170
+ utm_term?: string
171
+ utm_content?: string
172
+ captured_at?: number
173
+ }
174
+
175
+ /**
176
+ * Decode a `_1c_utm_first` or `_1c_utm_last` cookie value (URI-encoded
177
+ * JSON) and flatten its fields onto the attribution result with the
178
+ * given prefix ("utm_first" or "utm_last"). Missing/blank UTM fields
179
+ * are dropped from `out` rather than written as undefined so a
180
+ * partial-touch never pollutes order.metadata with empty keys.
181
+ *
182
+ * Defensive against:
183
+ * - Missing cookie (raw=undefined) → no-op
184
+ * - Invalid URI encoding → caught + no-op
185
+ * - Invalid JSON → caught + no-op
186
+ * - Schema drift (captured_at missing or non-number) → no-op
187
+ *
188
+ * Treating malformed cookies as missing is the right call: a hostile
189
+ * or stale cookie shouldn't be able to inject keys into order.metadata
190
+ * via the type cast at the writeback boundary.
191
+ */
192
+ function applyUtmCookie(
193
+ raw: string | undefined,
194
+ prefix: "utm_first" | "utm_last",
195
+ out: TrackingAttribution
196
+ ): void {
197
+ if (!raw) return
198
+ let parsed: ParsedUtmCookie | null = null
199
+ try {
200
+ parsed = JSON.parse(decodeURIComponent(raw)) as ParsedUtmCookie
201
+ } catch {
202
+ return
203
+ }
204
+ if (!parsed || typeof parsed.captured_at !== "number") return
205
+
206
+ // Spelling-out each field keeps the type system honest (rather than
207
+ // a string-keyed loop that would require `as any`).
208
+ if (parsed.utm_source) {
209
+ out[`${prefix}_source` as const] = parsed.utm_source
210
+ }
211
+ if (parsed.utm_medium) {
212
+ out[`${prefix}_medium` as const] = parsed.utm_medium
213
+ }
214
+ if (parsed.utm_campaign) {
215
+ out[`${prefix}_campaign` as const] = parsed.utm_campaign
216
+ }
217
+ if (parsed.utm_term) {
218
+ out[`${prefix}_term` as const] = parsed.utm_term
219
+ }
220
+ if (parsed.utm_content) {
221
+ out[`${prefix}_content` as const] = parsed.utm_content
222
+ }
223
+ out[`${prefix}_captured_at` as const] = parsed.captured_at
224
+ }
@@ -0,0 +1,50 @@
1
+ import type { StorefrontClient } from "../api/http"
2
+ import type { TrackingConfig, TrackingConfigResponse } from "./types"
3
+
4
+ /**
5
+ * Fetch the store's public tracking config — the `tracking` block of
6
+ * `GET /api/store/integrations` (contract: store-api.md "Tracking block";
7
+ * code truth: `src/lib/tracking/store-config-block.ts` mounted by the
8
+ * block-builder registry in `src/lib/integrations/store-config.ts`).
9
+ *
10
+ * CRITICAL ADAPTATION vs `@1click/ui`: the original read
11
+ * `{MEDUSA_BACKEND_URL}/store/integrations` from env vars; the barter
12
+ * version goes through the `StorefrontClient` (x-client-id + optional
13
+ * publishable key + locale handled by the ONE http seam). Only ENABLED
14
+ * integrations contribute keys, and only non-sensitive IDs appear
15
+ * (pixelId, containerId, measurementId, publicKey, conversionId) —
16
+ * secrets (CAPI access_token, GA4 api_secret, Klaviyo private_key) can
17
+ * never traverse this surface (allowlist-mapped server-side).
18
+ *
19
+ * The @1click predecessor once called `/store/integrations/tracking`,
20
+ * which 404'd and silently returned `{}` — pixel never loaded. The path
21
+ * here is the registered barter route (`src/app/api/store/integrations/
22
+ * route.ts`); the contract tests execute this exact call.
23
+ *
24
+ * Response is cached for 5 minutes when running inside Next.js
25
+ * (`next.revalidate: 300` rides through the client's fetch passthrough)
26
+ * — these IDs change rarely (admin → Settings → Integrations).
27
+ *
28
+ * Returns an empty config on any error so storefront layouts can always
29
+ * call this and just render nothing if there's no pixel.
30
+ *
31
+ * Auth: anon `x-client-id` (client-required); publishable key optional.
32
+ * Errors: `400 invalid_publishable_key` when a bad key header is sent —
33
+ * swallowed here into `{}` by design (tags are best-effort chrome).
34
+ * Settings that change behavior: admin Integrations hub rows
35
+ * (facebook_capi / gtm / ga4 / klaviyo / google-ads `enabled` +
36
+ * credentials) and the consent card's `enabled` → `consent_required`.
37
+ */
38
+ export async function getTrackingConfig(
39
+ client: StorefrontClient
40
+ ): Promise<TrackingConfig> {
41
+ try {
42
+ const data = await client.get<TrackingConfigResponse>(
43
+ "/api/store/integrations",
44
+ { next: { revalidate: 300, tags: ["tracking-config"] } }
45
+ )
46
+ return data.tracking ?? {}
47
+ } catch {
48
+ return {}
49
+ }
50
+ }
@@ -0,0 +1,200 @@
1
+ "use client"
2
+
3
+ import { sha256Hex, normaliseEmailForHash, normalisePhoneForHash } from "./attribution"
4
+
5
+ /**
6
+ * Typed wrappers around the global `gtag()` from GA4 (gtag.js).
7
+ *
8
+ * Follows Google's GA4 Enhanced Ecommerce event spec:
9
+ * https://developers.google.com/analytics/devguides/collection/ga4/ecommerce
10
+ *
11
+ * All helpers no-op when `window.gtag` is undefined (GA4 not loaded —
12
+ * e.g., admin disabled it, or SSR). Storefronts can call these freely
13
+ * without guards.
14
+ *
15
+ * Purchase dedup: GA4 deduplicates Purchase events by `transaction_id`
16
+ * automatically — no need to coordinate event IDs across client + server.
17
+ * Pass the same `transaction_id = String(order.display_id)` on both
18
+ * sides and GA4 collapses them into a single conversion.
19
+ *
20
+ * Enhanced Conversions for Web (setEnhancedConversions below) sends
21
+ * hashed user-provided data through the same gtag.js so Google Ads —
22
+ * via the GA4 ↔ Ads link — can recover conversions that cookies miss
23
+ * (iOS / Safari ITP / ad-blockers). Spec:
24
+ * https://support.google.com/google-ads/answer/13258081
25
+ */
26
+
27
+ type GA4Item = {
28
+ item_id: string
29
+ item_name: string
30
+ quantity: number
31
+ price: number
32
+ currency?: string
33
+ index?: number
34
+ }
35
+
36
+ type GA4ViewItemData = {
37
+ currency: string
38
+ value: number
39
+ items: GA4Item[]
40
+ }
41
+
42
+ type GA4AddToCartData = {
43
+ currency: string
44
+ value: number
45
+ items: GA4Item[]
46
+ }
47
+
48
+ type GA4BeginCheckoutData = {
49
+ currency: string
50
+ value: number
51
+ items: GA4Item[]
52
+ coupon?: string
53
+ }
54
+
55
+ type GA4PurchaseData = {
56
+ transaction_id: string
57
+ currency: string
58
+ value: number
59
+ items: GA4Item[]
60
+ tax?: number
61
+ shipping?: number
62
+ coupon?: string
63
+ }
64
+
65
+ type GtagFn = (
66
+ command: "event" | "config" | "set" | "get" | "js",
67
+ ...args: unknown[]
68
+ ) => void
69
+
70
+ declare global {
71
+ interface Window {
72
+ gtag?: GtagFn
73
+ dataLayer?: unknown[]
74
+ }
75
+ }
76
+
77
+ function safeGtag(): GtagFn | null {
78
+ if (typeof window === "undefined") return null
79
+ return window.gtag ?? null
80
+ }
81
+
82
+ export function trackGAViewItem(data: GA4ViewItemData): void {
83
+ const gtag = safeGtag()
84
+ if (!gtag) return
85
+ gtag("event", "view_item", data)
86
+ }
87
+
88
+ export function trackGAAddToCart(data: GA4AddToCartData): void {
89
+ const gtag = safeGtag()
90
+ if (!gtag) return
91
+ gtag("event", "add_to_cart", data)
92
+ }
93
+
94
+ export function trackGABeginCheckout(data: GA4BeginCheckoutData): void {
95
+ const gtag = safeGtag()
96
+ if (!gtag) return
97
+ gtag("event", "begin_checkout", data)
98
+ }
99
+
100
+ /**
101
+ * Purchase event — GA4 deduplicates by `transaction_id`. Pass the SAME
102
+ * `String(order.display_id)` here AND in the backend Measurement Protocol
103
+ * payload so the two events collapse into one conversion.
104
+ */
105
+ export function trackGAPurchase(data: GA4PurchaseData): void {
106
+ const gtag = safeGtag()
107
+ if (!gtag) return
108
+ gtag("event", "purchase", data)
109
+ }
110
+
111
+ /**
112
+ * Enhanced Conversions for Web — sends hashed user-provided PII via
113
+ * the gtag user_data channel. Google Ads (linked via GA4) consumes
114
+ * these signals to recover conversions that browser cookies miss
115
+ * (Safari ITP, iOS, ad-blockers).
116
+ *
117
+ * Hashing strategy mirrors the Meta side (attribution.ts):
118
+ * - email / phone / first_name / last_name → SHA-256 hex, lowercase
119
+ * normalised, phone E.164-style digits-only with country code
120
+ * - city / region / postal_code / country / street → raw plain text
121
+ * (Google's spec only documents sha256 variants for name fields
122
+ * within the address block; geo fields are accepted as plain)
123
+ *
124
+ * Same input on both sides produces same digest → both vendors' match
125
+ * engines see identical hashes for the same user. Storefront callers
126
+ * pass RAW values; this function does the normalize+hash inline.
127
+ *
128
+ * Idempotent — safe to call repeatedly as new fields become known
129
+ * (gtag.set merges user_data per Google's spec).
130
+ *
131
+ * No-ops when window.gtag is undefined (GA4 not loaded, SSR, etc).
132
+ *
133
+ * Spec: https://support.google.com/google-ads/answer/13258081
134
+ */
135
+ export type EnhancedConversionsInput = {
136
+ email?: string
137
+ phone?: string
138
+ firstName?: string
139
+ lastName?: string
140
+ street?: string
141
+ city?: string
142
+ /** GA4 spec uses "region" for state / province / oblast. */
143
+ region?: string
144
+ postalCode?: string
145
+ /** 2-letter ISO country code (lowercase or upper — Google normalises). */
146
+ country?: string
147
+ }
148
+
149
+ export async function setEnhancedConversions(
150
+ input: EnhancedConversionsInput
151
+ ): Promise<void> {
152
+ const gtag = safeGtag()
153
+ if (!gtag) return
154
+
155
+ const userData: Record<string, unknown> = {}
156
+
157
+ if (input.email) {
158
+ userData.sha256_email_address = await sha256Hex(
159
+ normaliseEmailForHash(input.email)
160
+ )
161
+ }
162
+ if (input.phone) {
163
+ userData.sha256_phone_number = await sha256Hex(
164
+ normalisePhoneForHash(input.phone)
165
+ )
166
+ }
167
+
168
+ const address: Record<string, unknown> = {}
169
+ if (input.firstName) {
170
+ address.sha256_first_name = await sha256Hex(
171
+ input.firstName.trim().toLowerCase()
172
+ )
173
+ }
174
+ if (input.lastName) {
175
+ address.sha256_last_name = await sha256Hex(
176
+ input.lastName.trim().toLowerCase()
177
+ )
178
+ }
179
+ if (input.street) address.street = input.street.trim()
180
+ if (input.city) address.city = input.city.trim()
181
+ if (input.region) address.region = input.region.trim()
182
+ if (input.postalCode) address.postal_code = input.postalCode.trim()
183
+ if (input.country) address.country = input.country.trim().toUpperCase()
184
+
185
+ if (Object.keys(address).length > 0) {
186
+ userData.address = address
187
+ }
188
+
189
+ if (Object.keys(userData).length === 0) return
190
+
191
+ gtag("set", "user_data", userData)
192
+ }
193
+
194
+ export type {
195
+ GA4Item,
196
+ GA4ViewItemData,
197
+ GA4AddToCartData,
198
+ GA4BeginCheckoutData,
199
+ GA4PurchaseData,
200
+ }