import type { Cart, Order, Product, ProductVariant, TrackingEventItem } from 'brainerce'; import { getCartTotals } from 'brainerce'; import { getClient } from './brainerce'; /** * E-commerce event reporting for the merchant's marketing tags. * * `` loads whichever tags the merchant connected; these * helpers describe what the shopper did. Both halves are needed: a Google Tag * Manager container with no events is an empty container, and Meta cannot * optimize a campaign it never sees a `Purchase` for. * * Every function is a no-op when no tag is loaded, so calling them is always * safe and never needs a feature check at the call site. * * ## Item ids * * `itemId` is the SKU-preferring id Meta/TikTok pixels match against (Meta * deliberately uses the bare internal id instead, since SKU is nullable * there — see `apps/meta-connector/src/lib/mappers.ts`). For GA4/GTM, the SDK * builds a SEPARATE id from `productId`/`variantId` (`prod_`/`var_`) * matching the Google Merchant Center feed's `offerId` — neither SKU nor a * bare unprefixed id matches that feed, which silently breaks Shopping ads / * dynamic remarketing item matching. Always pass `productId`/`variantId` * alongside `itemId` so both platforms attribute correctly. */ /** Parse a Brainerce decimal string (`"29.99"`) into a number. */ function toNumber(value: string | number | null | undefined): number | undefined { if (value === null || value === undefined) return undefined; const parsed = typeof value === 'number' ? value : parseFloat(value); return Number.isFinite(parsed) ? parsed : undefined; } /** * Builds a GA4-spec-correct `{value, tax, shipping}` triple for the * `purchase` event: `value` must be net item revenue only, with `tax`/ * `shipping` reported alongside it, never folded in. * * Inlined rather than imported from `buildGa4CommercePayload` in `brainerce` * — that export only exists from SDK 3.1.0 (published 2026-09-18), and * `packages/cli-shared/src/versions.ts`'s exact pin cannot move to a * same-day release (its own ⛔⛔ RAISING THIS FLOOR rule: a pinned version * must already be 7+ days old, so cooldown-enforcing installers like pnpm's * `minimumReleaseAge` or ChatGPT Sites don't refuse the scaffold outright). * Once 3.1.0 clears that week, swap this back to the SDK import and raise * the pin in the same change — keep this in sync with * `packages/sdk/src/types.ts`'s `buildGa4CommercePayload` until then. */ function buildGa4CommercePayload( order: Pick< Order, | 'subtotal' | 'totalAmount' | 'discountAmount' | 'shippingAmount' | 'taxAmount' | 'taxBreakdown' | 'currency' > ): { value: number; tax: number; shipping: number; currency?: string } { const pricesIncludeTax = order.taxBreakdown?.pricesIncludeTax ?? false; const subtotal = parseFloat(order.subtotal ?? order.totalAmount) || 0; const discountAmount = parseFloat(order.discountAmount ?? '0') || 0; const shippingAmount = parseFloat(order.shippingAmount ?? '0') || 0; const taxAmount = parseFloat(order.taxAmount ?? '0') || 0; const tax = pricesIncludeTax ? (order.taxBreakdown?.totalTax ?? 0) : taxAmount; const netSubtotal = pricesIncludeTax ? subtotal - tax : subtotal; const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; return { value: round2(Math.max(0, netSubtotal - discountAmount)), tax: round2(tax), shipping: round2(shippingAmount), currency: order.currency, }; } /** * Resolve the SKU for a product/variant pair, falling back to the variant then * product id only when the catalog has no SKU at all — a feed-less product * can't be attributed either way, and dropping the event entirely would hide * real shopper behaviour from GA4 too. */ function resolveItemId(product: Product, variant?: ProductVariant | null): string { return variant?.sku || product.sku || variant?.id || product.id; } /** The shopper opened a product detail page. */ export function trackProductView( product: Product, variant: ProductVariant | null | undefined, price: number | undefined, currency: string | undefined ): void { getClient().trackMarketingEvent('view_item', { currency, value: price, items: [ { itemId: resolveItemId(product, variant), productId: product.id, variantId: variant?.id, itemName: product.name, price, quantity: 1, ...(variant?.name ? { itemVariant: variant.name } : {}), }, ], }); } /** The shopper added a line to the cart. */ export function trackAddToCart( product: Product, variant: ProductVariant | null | undefined, quantity: number, price: number | undefined, currency: string | undefined ): void { getClient().trackMarketingEvent('add_to_cart', { currency, value: price !== undefined ? price * quantity : undefined, items: [ { itemId: resolveItemId(product, variant), productId: product.id, variantId: variant?.id, itemName: product.name, price, quantity, ...(variant?.name ? { itemVariant: variant.name } : {}), }, ], }); } /** Map cart lines onto tracking items. */ function cartItems(cart: Cart): TrackingEventItem[] { return (cart.items ?? []).map((item) => ({ // The cart line carries product + variant snapshots; prefer the variant's // SKU for the same catalog-parity reason as `resolveItemId`. itemId: item.variant?.sku || item.product?.sku || item.variantId || item.productId, productId: item.productId, variantId: item.variantId ?? undefined, itemName: item.product?.name, price: toNumber(item.currentUnitPrice ?? item.unitPrice), quantity: item.quantity, ...(item.variant?.name ? { itemVariant: item.variant.name } : {}), })); } /** * The shopper landed on the checkout page. * * Shipping is not known at this point (no address yet), so the value is the * cart total without it — which is what GA4's `begin_checkout` expects. */ export function trackBeginCheckout(cart: Cart, currency: string | undefined): void { getClient().trackMarketingEvent('begin_checkout', { currency, value: getCartTotals(cart).total, items: cartItems(cart), }); } /** * Key under which a reported order id is remembered for this tab. * * Prefixed per order rather than a single "last order" slot so a shopper who * places two orders in one session gets both reported exactly once. */ const PURCHASE_KEY_PREFIX = 'brainerce_purchase_tracked:'; /** * The order is paid — report the conversion, at most once per order. * * Two independent guards, because double-counted revenue is the failure that * quietly ruins a merchant's ROAS reporting: * * 1. `sessionStorage` stops a page refresh or a back-button return from * re-firing the event at all. * 2. The order id travels as GA4's `transaction_id`, Meta's `eventID` and * TikTok's `event_id`, so even a send that slips past guard 1 (new tab, * cleared storage) de-duplicates on the vendor's side. * * Note the event fires on the confirmation page, which a shopper can always * fail to reach — closing the tab after paying, or an iframe provider that * lands them on `/payment-complete`. Browser-side conversion tracking is * lossy by nature; Brainerce's server-side GA4 purchase event (sent from the * backend when the order is paid) is what covers that gap for Google. */ export function trackPurchase(order: Order): void { if (typeof window === 'undefined' || !order?.id) return; const key = `${PURCHASE_KEY_PREFIX}${order.id}`; try { if (window.sessionStorage.getItem(key)) return; window.sessionStorage.setItem(key, '1'); } catch { // Private mode / storage disabled — fall through and rely on the // vendor-side transaction id de-dupe rather than skipping the conversion. } // GA4 spec: `value` is net item revenue only — `tax`/`shipping` are // reported alongside it, never folded in. See `buildGa4CommercePayload`. const commerce = buildGa4CommercePayload(order); getClient().trackMarketingEvent('purchase', { transactionId: order.id, currency: commerce.currency, value: commerce.value, shipping: commerce.shipping, tax: commerce.tax, coupon: order.couponCode ?? undefined, items: (order.items ?? []).map((item) => ({ itemId: item.sku || item.variantId || item.productId, productId: item.productId, variantId: item.variantId, itemName: item.name, price: toNumber(item.price), quantity: item.quantity, })), }); }