diff --git a/src/lib/components/ShoppingCart.svelte b/src/lib/components/ShoppingCart.svelte index 3e39f14b..f95bd64e 100644 --- a/src/lib/components/ShoppingCart.svelte +++ b/src/lib/components/ShoppingCart.svelte @@ -13,6 +13,7 @@ import SteppableInput from "./SteppableInput.svelte"; import Space from "./Space.svelte"; import { formatCurrency } from "$lib/utils/currency"; + import { refreshCartAttributes } from "$lib/utils/shopify"; export let loading = false; @@ -31,6 +32,12 @@ async function checkout() { loading = true; + // never block checkout on analytics + try { + await refreshCartAttributes(); + } catch { + // ignore + } window.open(get(checkoutUrl), "_self"); loading = false; } diff --git a/src/lib/email-updates.js b/src/lib/email-updates.js index df0ed7e5..a3501bde 100644 --- a/src/lib/email-updates.js +++ b/src/lib/email-updates.js @@ -1,4 +1,5 @@ import { get, writable } from 'svelte/store'; +import { identifyByEmail } from '$lib/utils/attribution'; export const EMAIL_CATEGORIES = [ { key: 'product', label: 'Product updates', description: 'New products and sales', fieldName: 'group[54660][1]' }, @@ -84,6 +85,7 @@ export function createEmailUpdatesForm() { try { await submitEmailUpdates(get(email), get(selectedCategories), get(car).trim()); status.set('success'); + identifyByEmail(get(email)); } catch (error) { errorMessage.set(error.message); status.set('error'); diff --git a/src/lib/utils/attribution.js b/src/lib/utils/attribution.js new file mode 100644 index 00000000..7afb32e9 --- /dev/null +++ b/src/lib/utils/attribution.js @@ -0,0 +1,123 @@ +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'attribution'; +const MAX_ATTR_LEN = 255; + +// params that mark a real inbound touch, not internal navigation +const TOUCH_PARAMS = [ + 'mc_cid', 'mc_eid', + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'fbclid', 'gclid', 'igshid', 'ttclid', 'msclkid', +]; + +const isInternalHost = (host) => /(^|\.)comma\.ai$/.test(host); + +function posthog() { + return browser ? window.posthog : null; +} + +// posthog's stub queues calls before load and returns undefined, so fall back to its cookie +function distinctId() { + const id = posthog()?.get_distinct_id?.(); + if (id) return id; + if (!browser) return null; + const match = document.cookie.match(/ph_[^=]+_posthog=([^;]+)/); + if (!match) return null; + try { + return JSON.parse(decodeURIComponent(match[1]))?.distinct_id || null; + } catch { + return null; + } +} + +function load() { + if (!browser) return {}; + try { + return JSON.parse(window.localStorage.getItem(STORAGE_KEY)) || {}; + } catch { + return {}; + } +} + +function save(value) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + // private browsing / storage disabled + } +} + +function readTouch() { + const url = new URL(window.location.href); + const touch = { ts: Date.now(), path: url.pathname }; + + for (const param of TOUCH_PARAMS) { + const value = url.searchParams.get(param); + if (value) touch[param] = value; + } + + try { + const host = document.referrer ? new URL(document.referrer).hostname : ''; + if (host && !isInternalHost(host)) touch.referrer = host; + } catch { + // malformed referrer + } + + return touch; +} + +// a direct visit or internal navigation must not clobber an earlier campaign touch +function isRealTouch(touch) { + return TOUCH_PARAMS.some((param) => touch[param]) || !!touch.referrer; +} + +export function captureTouch() { + if (!browser) return; + + const touch = readTouch(); + const attribution = load(); + + if (!attribution.first) attribution.first = touch; + if (isRealTouch(touch)) attribution.last = touch; + + save(attribution); + identifyFromEmailClick(touch); +} + +// an email click identifies the person without them filling anything in. +// never re-identify an already-identified device: a forwarded email or a shared +// machine would otherwise merge two different people. +function identifyFromEmailClick(touch) { + if (!touch.mc_eid) return; + + const current = posthog()?.get_distinct_id?.(); + if (current && current.startsWith('mc:')) return; + + posthog()?.identify?.(`mc:${touch.mc_eid}`); +} + +// $set_once, so a typo or someone else's address can never replace a known one +export function identifyByEmail(email) { + if (!browser || !email) return; + posthog()?.setPersonProperties?.(undefined, { submitted_email: email }); +} + +// written into the shopify cart so the order can be joined back to the session +export function cartAttributes() { + if (!browser) return []; + + const { first, last } = load(); + const attributes = []; + const add = (key, value) => { + if (value) attributes.push({ key, value: String(value).slice(0, MAX_ATTR_LEN) }); + }; + + add('_ph_distinct_id', distinctId()); + add('_ph_session_id', posthog()?.get_session_id?.()); + add('_mc_cid', last?.mc_cid || first?.mc_cid); + add('_utm_source', last?.utm_source || first?.utm_source); + add('_first_touch', first && JSON.stringify(first)); + add('_last_touch', last && JSON.stringify(last)); + + return attributes; +} diff --git a/src/lib/utils/shopify.js b/src/lib/utils/shopify.js index 91df19d9..8e6f0ea1 100644 --- a/src/lib/utils/shopify.js +++ b/src/lib/utils/shopify.js @@ -1,5 +1,6 @@ import { get } from 'svelte/store'; import { cartId, cartCreatedAt, checkoutUrl, cartTotalQuantity } from '../../store'; +import { cartAttributes } from './attribution'; // GraphQL fragments for error handling const USER_ERRORS_GQL = `userErrors { code field message }`; @@ -206,7 +207,10 @@ export async function createCart(referralCode = null) { } `, variables: { - input: { discountCodes: referralCode ? [referralCode] : [] } + input: { + discountCodes: referralCode ? [referralCode] : [], + attributes: cartAttributes(), + } } }).then(response => { cartId.set(response.body?.data?.cartCreate?.cart?.id) @@ -217,6 +221,23 @@ export async function createCart(referralCode = null) { } +// carts persist in localStorage across visits, so refresh attribution before handing off to checkout +export async function refreshCartAttributes() { + const id = get(cartId); + if (!id) return; + + return shopifyFetch({ + query: /* graphql */ ` + mutation cartAttributesUpdate($cartId: ID!, $attributes: [AttributeInput!]!) { + cartAttributesUpdate(cartId: $cartId, attributes: $attributes) { + ${USER_ERRORS_GQL} + } + } + `, + variables: { cartId: id, attributes: cartAttributes() } + }); +} + export async function updateCart({ cartId, lineId, variantId, quantity }) { return shopifyFetch({ query: /* graphql */ ` diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e54a74c8..3f9154e1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -22,6 +22,7 @@ import { createCart, updateCart } from '$lib/utils/shopify'; import { printConsoleBanner } from '$lib/utils/console'; import { getReferralCode } from '$lib/utils/referral'; + import { captureTouch } from '$lib/utils/attribution'; import HeaderBanner from "$lib/components/HeaderBanner.svelte"; import HeaderMenu from "$lib/components/HeaderMenu.svelte"; @@ -58,7 +59,11 @@ loading = false; } + // record the touch on every navigation, before anything can create a cart + $: if ($page.url) captureTouch(); + onMount(async () => { + captureTouch(); const referralCode = getReferralCode(); if (referralCode) await createCart(referralCode); await loadCart();