Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/lib/components/ShoppingCart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/email-updates.js
Original file line number Diff line number Diff line change
@@ -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]' },
Expand Down Expand Up @@ -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');
Expand Down
123 changes: 123 additions & 0 deletions src/lib/utils/attribution.js
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 22 additions & 1 deletion src/lib/utils/shopify.js
Original file line number Diff line number Diff line change
@@ -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 }`;
Expand Down Expand Up @@ -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)
Expand All @@ -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 */ `
Expand Down
5 changes: 5 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Loading