@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|