@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,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
|
+
}
|