@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,418 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Self-managed Facebook attribution helpers.
5
+ *
6
+ * Why this exists: Meta Pixel sets _fbp / _fbc cookies on init, which
7
+ * happens via `<Script strategy="afterInteractive">`. Our React
8
+ * useEffect tracking calls run during hydration — frequently BEFORE
9
+ * the Pixel script has finished downloading. The result is that
10
+ * `fireCapiEvent` reads document.cookie, finds nothing, and ships an
11
+ * empty fbp/fbc to Meta CAPI. Event Match Quality tanks, and the
12
+ * "Other dedup keys: 0%" panel shows up in Events Manager.
13
+ *
14
+ * The fix: defensively write our own _fbp / _fbc cookies on first
15
+ * tracking call. Per Meta's spec, `fbq('init')` checks for an
16
+ * existing _fbp cookie and uses it if present (it never overwrites
17
+ * a cookie that's already there). So whichever side fires first wins,
18
+ * and both sides agree on the same value. Browser Pixel and CAPI
19
+ * naturally share the cookie.
20
+ *
21
+ * Same cookie names as Meta (_fbp, _fbc) are used intentionally —
22
+ * Meta's docs guarantee Pixel-side compatibility. anon_id is our own
23
+ * (_1c_anon) and never collides with anything.
24
+ *
25
+ * Spec sources verified before writing:
26
+ * - https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
27
+ * - https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
28
+ */
29
+
30
+ const FBP_COOKIE = "_fbp"
31
+ const FBC_COOKIE = "_fbc"
32
+ const ANON_ID_COOKIE = "_1c_anon"
33
+ const KNOWN_VISITOR_LS = "_1c_visitor_v1"
34
+ const UTM_FIRST_COOKIE = "_1c_utm_first"
35
+ const UTM_LAST_COOKIE = "_1c_utm_last"
36
+
37
+ /** _fbp / _fbc TTL per Meta spec — 90 days first-party. */
38
+ const FB_COOKIE_TTL_DAYS = 90
39
+ /** anon_id persists much longer so cross-session attribution survives. */
40
+ const ANON_ID_TTL_DAYS = 365
41
+ /** First-touch UTM survives longer than any single ad campaign — the
42
+ * campaign that originally acquired this visitor should still be
43
+ * attributable a year later when they convert. Klaviyo acquisition
44
+ * flows + cohort analysis want this. */
45
+ const UTM_FIRST_TTL_DAYS = 365
46
+ /** Last-touch UTM matches Meta's _fbc 90-day dedup window — the
47
+ * campaign that closed the deal is what Meta / Google Ads dashboards
48
+ * attribute conversions to. */
49
+ const UTM_LAST_TTL_DAYS = 90
50
+
51
+ /** Module-level config set by setTrackingDefaults — used by getKnownVisitor
52
+ * to default country when the storefront knows it (e.g. alenika is BG-only). */
53
+ let defaultCountry: string | undefined
54
+
55
+ /**
56
+ * Configure tracking defaults once on app init. Storefront should call
57
+ * this in a top-level client component (e.g. TrackInit) so the value
58
+ * is set before any fireCapiEvent.
59
+ */
60
+ export function setTrackingDefaults(opts: { country?: string }): void {
61
+ if (opts.country) defaultCountry = opts.country.toLowerCase()
62
+ }
63
+
64
+ function isBrowser(): boolean {
65
+ return typeof window !== "undefined" && typeof document !== "undefined"
66
+ }
67
+
68
+ function getCookie(name: string): string | undefined {
69
+ if (!isBrowser()) return undefined
70
+ const match = document.cookie
71
+ .split("; ")
72
+ .find((c) => c.startsWith(`${name}=`))
73
+ if (!match) return undefined
74
+ const value = match.split("=").slice(1).join("=")
75
+ return value || undefined
76
+ }
77
+
78
+ function setCookie(name: string, value: string, days: number): void {
79
+ if (!isBrowser()) return
80
+ const maxAge = days * 24 * 60 * 60
81
+ // SameSite=Lax + Secure — first-party cookie scoped to root path.
82
+ // Lax allows top-level navigation from external referrers (Meta ads
83
+ // landing) which is exactly when fbclid arrives. Secure required by
84
+ // modern browsers for any cookie with SameSite specified.
85
+ document.cookie = `${name}=${value}; max-age=${maxAge}; path=/; SameSite=Lax; Secure`
86
+ }
87
+
88
+ function randomHex(bytes: number): string {
89
+ if (isBrowser() && typeof crypto !== "undefined" && crypto.getRandomValues) {
90
+ const buf = new Uint8Array(bytes)
91
+ crypto.getRandomValues(buf)
92
+ return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("")
93
+ }
94
+ // SSR / very old browser fallback. Never reached in practice but
95
+ // keeps the function total.
96
+ return Math.random().toString(16).slice(2, 2 + bytes * 2)
97
+ }
98
+
99
+ /**
100
+ * Get or create the _fbp cookie.
101
+ *
102
+ * Meta spec format: `fb.1.<creation_time_ms>.<random_64bit>`
103
+ * - "fb" = literal prefix
104
+ * - "1" = subdomain index (1 = root domain like example.com)
105
+ * - <ms> = creation time in Unix milliseconds
106
+ * - <random> = 64-bit unsigned integer (16 hex chars covers it)
107
+ */
108
+ export function getOrCreateFbp(): string | undefined {
109
+ const existing = getCookie(FBP_COOKIE)
110
+ if (existing) return existing
111
+ if (!isBrowser()) return undefined
112
+
113
+ const created = `fb.1.${Date.now()}.${randomHex(8)}`
114
+ setCookie(FBP_COOKIE, created, FB_COOKIE_TTL_DAYS)
115
+ return created
116
+ }
117
+
118
+ /**
119
+ * Get or create the _fbc cookie. Reads `?fbclid=…` from the URL on
120
+ * first visit and constructs the cookie ourselves — Meta's Pixel
121
+ * does this too, but only when fbevents.js loads BEFORE the visitor
122
+ * navigates away from the landing URL. Doing it ourselves on the
123
+ * first tracking call captures fbclid even when the Pixel races us.
124
+ *
125
+ * Format: `fb.1.<creation_time_ms>.<fbclid>` — same subdomain index
126
+ * as _fbp.
127
+ *
128
+ * Returns undefined if no cookie exists AND no fbclid is in the URL —
129
+ * not every visitor came from a Meta ad, and we don't fabricate fbc.
130
+ */
131
+ export function getOrCreateFbc(): string | undefined {
132
+ const existing = getCookie(FBC_COOKIE)
133
+ if (existing) return existing
134
+ if (!isBrowser()) return undefined
135
+
136
+ const params = new URLSearchParams(window.location.search)
137
+ const fbclid = params.get("fbclid")
138
+ if (!fbclid) return undefined
139
+
140
+ const created = `fb.1.${Date.now()}.${fbclid}`
141
+ setCookie(FBC_COOKIE, created, FB_COOKIE_TTL_DAYS)
142
+ return created
143
+ }
144
+
145
+ /**
146
+ * Get or create the anonymous-visitor identifier.
147
+ *
148
+ * Sent as `external_id` on every CAPI event for guests, and continues
149
+ * to be sent alongside `customer_id` once the visitor signs up. Meta
150
+ * accepts external_id as an array, so both can coexist — improves
151
+ * dedup AND links pre-signup browsing to the customer once they
152
+ * identify themselves. Solves the "Other dedup keys: 0%" gap from
153
+ * the Events Manager dedup panel.
154
+ *
155
+ * Stored in a cookie (NOT just localStorage) so the Next.js server
156
+ * can read it via cookies() in get-tracking-attribution and write
157
+ * it to cart.metadata for the order.placed subscriber.
158
+ */
159
+ export function getOrCreateAnonId(): string | undefined {
160
+ const existing = getCookie(ANON_ID_COOKIE)
161
+ if (existing) return existing
162
+ if (!isBrowser()) return undefined
163
+
164
+ const id =
165
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
166
+ ? crypto.randomUUID()
167
+ : `${randomHex(4)}-${randomHex(2)}-${randomHex(2)}-${randomHex(2)}-${randomHex(6)}`
168
+ setCookie(ANON_ID_COOKIE, id, ANON_ID_TTL_DAYS)
169
+ return id
170
+ }
171
+
172
+ /**
173
+ * Visitor PII we know about and want sent on every subsequent CAPI
174
+ * event. Populated by the storefront via rememberKnownVisitor() when
175
+ * the visitor types their email in checkout, signs up via the popup,
176
+ * or logs in. Backend hashes these at the boundary — we store RAW so
177
+ * Pixel Advanced Matching can also use them (Pixel hashes them with
178
+ * its own internal logic).
179
+ */
180
+ export type KnownVisitor = {
181
+ email?: string
182
+ phone?: string
183
+ firstName?: string
184
+ lastName?: string
185
+ city?: string
186
+ state?: string
187
+ postalCode?: string
188
+ country?: string
189
+ }
190
+
191
+ export function getKnownVisitor(): KnownVisitor {
192
+ let stored: KnownVisitor = {}
193
+ if (isBrowser()) {
194
+ try {
195
+ const raw = window.localStorage.getItem(KNOWN_VISITOR_LS)
196
+ if (raw) stored = JSON.parse(raw) as KnownVisitor
197
+ } catch {
198
+ // localStorage unavailable / parse error — return empty
199
+ }
200
+ }
201
+ // Default country when storefront set one via setTrackingDefaults
202
+ // (e.g. alenika is BG-only). Lifts country coverage to ~100%
203
+ // without per-callsite plumbing.
204
+ if (!stored.country && defaultCountry) {
205
+ stored = { ...stored, country: defaultCountry }
206
+ }
207
+ return stored
208
+ }
209
+
210
+ /**
211
+ * Persist visitor PII for use on subsequent events. Merges with what's
212
+ * already stored — caller passes only the fields they newly know.
213
+ */
214
+ export function rememberKnownVisitor(update: KnownVisitor): void {
215
+ if (!isBrowser()) return
216
+ try {
217
+ const current = getKnownVisitor()
218
+ const next: KnownVisitor = { ...current }
219
+ for (const [k, v] of Object.entries(update)) {
220
+ if (typeof v === "string" && v.trim().length > 0) {
221
+ ;(next as Record<string, string>)[k] = v.trim()
222
+ }
223
+ }
224
+ window.localStorage.setItem(KNOWN_VISITOR_LS, JSON.stringify(next))
225
+ } catch {
226
+ // localStorage write failure — silently ignore
227
+ }
228
+ }
229
+
230
+ /**
231
+ * SHA-256 → lowercase hex via Web Crypto. Used by Pixel Advanced
232
+ * Matching helper to pre-hash em / ph before passing to fbq('init').
233
+ * Mirrors backend hashSHA256 (in src/lib/facebook-capi.ts) so client
234
+ * and server hashes of the same input produce the same digest.
235
+ */
236
+ export async function sha256Hex(value: string): Promise<string> {
237
+ const encoder = new TextEncoder()
238
+ const data = encoder.encode(value)
239
+ const buffer = await crypto.subtle.digest("SHA-256", data)
240
+ return Array.from(new Uint8Array(buffer))
241
+ .map((b) => b.toString(16).padStart(2, "0"))
242
+ .join("")
243
+ }
244
+
245
+ /**
246
+ * Normalise an email for hashing per Meta CAPI spec: trim, lowercase.
247
+ * Mirrors `trim()` in backend buildCapiUserData → `out.em`.
248
+ */
249
+ export function normaliseEmailForHash(raw: string): string {
250
+ return raw.trim().toLowerCase()
251
+ }
252
+
253
+ /**
254
+ * Normalise a phone for hashing per Meta CAPI spec: digits only,
255
+ * country-code prefixed. Mirrors backend normalizePhoneForCapi —
256
+ * BG default (+359) when a 10-digit national number with leading 0
257
+ * is detected.
258
+ */
259
+ export function normalisePhoneForHash(raw: string): string {
260
+ let digits = raw.replace(/\D/g, "")
261
+ if (digits.startsWith("00")) digits = digits.slice(2)
262
+ if (digits.length === 10 && digits.startsWith("0")) {
263
+ digits = "359" + digits.slice(1)
264
+ }
265
+ return digits
266
+ }
267
+
268
+ // ── UTM attribution capture ──────────────────────────────────────────
269
+ //
270
+ // Captures `utm_source / utm_medium / utm_campaign / utm_term /
271
+ // utm_content` from the current URL into TWO cookies:
272
+ //
273
+ // - `_1c_utm_first` — first-touch (write once, 365d TTL). The
274
+ // campaign that originally acquired this visitor. Klaviyo flows +
275
+ // acquisition cohort analysis want this.
276
+ // - `_1c_utm_last` — last-touch (write every visit with UTMs,
277
+ // 90d TTL). The campaign that closed the deal. Meta / Google Ads
278
+ // dashboards attribute conversions to this.
279
+ //
280
+ // Both stored as a JSON blob so a single cookie carries all five UTM
281
+ // params + a captured-at timestamp. The server-side
282
+ // `get-tracking-attribution.ts` reads both cookies and flattens them
283
+ // into `utm_first_*` / `utm_last_*` keys for `cart.metadata`.
284
+ //
285
+ // Cookie size: ~150-300 bytes per cookie at typical campaign-name
286
+ // lengths. Well under any browser limit; the storefront's total
287
+ // cookie footprint stays under 1KB even with both UTM cookies set.
288
+
289
+ /** Captured UTM tuple stored as JSON in `_1c_utm_first` / `_1c_utm_last`. */
290
+ export type CapturedUtms = {
291
+ utm_source: string | null
292
+ utm_medium: string | null
293
+ utm_campaign: string | null
294
+ utm_term: string | null
295
+ utm_content: string | null
296
+ /** Unix seconds at the moment of capture. Lets the server compute
297
+ * "days since first touch" and similar attribution slicing. */
298
+ captured_at: number
299
+ }
300
+
301
+ const UTM_FIELDS = [
302
+ "utm_source",
303
+ "utm_medium",
304
+ "utm_campaign",
305
+ "utm_term",
306
+ "utm_content",
307
+ ] as const
308
+
309
+ /** Parse UTM params from a URL search string. Returns null when the
310
+ * URL carries no UTM params (so the caller can leave existing cookies
311
+ * untouched on UTM-less visits). */
312
+ function readUtmsFromSearch(search: string): CapturedUtms | null {
313
+ let params: URLSearchParams
314
+ try {
315
+ params = new URLSearchParams(search)
316
+ } catch {
317
+ return null
318
+ }
319
+ let hasAny = false
320
+ const collected: Record<string, string | null> = {}
321
+ for (const field of UTM_FIELDS) {
322
+ const raw = params.get(field)
323
+ if (raw && raw.trim().length > 0) {
324
+ collected[field] = raw.trim()
325
+ hasAny = true
326
+ } else {
327
+ collected[field] = null
328
+ }
329
+ }
330
+ if (!hasAny) return null
331
+ return {
332
+ utm_source: collected.utm_source ?? null,
333
+ utm_medium: collected.utm_medium ?? null,
334
+ utm_campaign: collected.utm_campaign ?? null,
335
+ utm_term: collected.utm_term ?? null,
336
+ utm_content: collected.utm_content ?? null,
337
+ captured_at: Math.floor(Date.now() / 1000),
338
+ }
339
+ }
340
+
341
+ /** Read + parse one of the two UTM cookies. Defensive — malformed JSON
342
+ * or shape drift returns null so the caller treats it as missing
343
+ * instead of crashing. */
344
+ function readUtmsCookie(name: string): CapturedUtms | null {
345
+ const raw = getCookie(name)
346
+ if (!raw) return null
347
+ try {
348
+ const parsed = JSON.parse(decodeURIComponent(raw)) as Partial<CapturedUtms>
349
+ if (typeof parsed.captured_at !== "number") return null
350
+ return {
351
+ utm_source: parsed.utm_source ?? null,
352
+ utm_medium: parsed.utm_medium ?? null,
353
+ utm_campaign: parsed.utm_campaign ?? null,
354
+ utm_term: parsed.utm_term ?? null,
355
+ utm_content: parsed.utm_content ?? null,
356
+ captured_at: parsed.captured_at,
357
+ }
358
+ } catch {
359
+ return null
360
+ }
361
+ }
362
+
363
+ function writeUtmsCookie(name: string, utms: CapturedUtms, days: number): void {
364
+ if (!isBrowser()) return
365
+ setCookie(name, encodeURIComponent(JSON.stringify(utms)), days)
366
+ }
367
+
368
+ /**
369
+ * Capture UTM parameters from the current URL into first-touch +
370
+ * last-touch cookies. Idempotent and safe to call on every page mount —
371
+ * does nothing when the URL has no `utm_*` params.
372
+ *
373
+ * - First-touch: written ONLY if no existing `_1c_utm_first` cookie.
374
+ * Preserves the original acquisition campaign for 365 days.
375
+ * - Last-touch: overwritten on every visit that carries UTMs.
376
+ * Refreshed to 90 days each time so it tracks the most recent ad
377
+ * click that brought the visitor back.
378
+ *
379
+ * Storefronts should call this from a top-level client component
380
+ * (e.g. `TrackInit`) so the cookies are set before any subsequent
381
+ * checkout step calls `getTrackingAttribution()` to read them.
382
+ */
383
+ export function captureUtmsFromUrl(): void {
384
+ if (!isBrowser()) return
385
+ const utms = readUtmsFromSearch(window.location.search)
386
+ if (!utms) return
387
+
388
+ // First-touch: write only when the cookie is currently absent.
389
+ // Re-arriving with a fresh UTM does NOT overwrite the original
390
+ // acquisition record — that's the whole point of "first-touch".
391
+ if (!readUtmsCookie(UTM_FIRST_COOKIE)) {
392
+ writeUtmsCookie(UTM_FIRST_COOKIE, utms, UTM_FIRST_TTL_DAYS)
393
+ }
394
+ // Last-touch: always overwrite when the URL carries UTMs. Refreshes
395
+ // the 90-day TTL on every campaign click so it always points at the
396
+ // most recent attributable touch.
397
+ writeUtmsCookie(UTM_LAST_COOKIE, utms, UTM_LAST_TTL_DAYS)
398
+ }
399
+
400
+ /**
401
+ * Browser-side accessor for the captured first-touch UTM cookie.
402
+ * Returns null when no first-touch has been recorded yet. Server-side
403
+ * code MUST use `getTrackingAttribution()` (which reads via Next's
404
+ * cookies() API) instead — this function is browser-only.
405
+ */
406
+ export function getCapturedFirstTouchUtms(): CapturedUtms | null {
407
+ return readUtmsCookie(UTM_FIRST_COOKIE)
408
+ }
409
+
410
+ /**
411
+ * Browser-side accessor for the captured last-touch UTM cookie.
412
+ * Returns null when no UTM-bearing visit has happened in the last
413
+ * 90 days. Server-side code MUST use `getTrackingAttribution()` —
414
+ * this is browser-only.
415
+ */
416
+ export function getCapturedLastTouchUtms(): CapturedUtms | null {
417
+ return readUtmsCookie(UTM_LAST_COOKIE)
418
+ }