Files
hanzo.ai/lib/campaign.ts

83 lines
3.5 KiB
TypeScript

/**
* The campaign a visitor arrived from: the link's utm_* tags and the ad
* networks' click ids — the keys pay carries on every hop inside it
* (hanzo-inc/pay lib/campaign). A page that sends a visitor on to pay hands
* them over as they arrived, so a purchase is attributed to the link that
* brought the buyer. Read off the address, or off what this tab kept from the
* page the visit landed on, never invented.
*/
import { CONSENT_EVENT, declined, readConsent, storedConsent, type Choice } from '@hanzo/event'
/** The query keys a campaign or referral/affiliate link is carried in. */
const KEY = /^(utm_(source|medium|campaign|term|content|id)|gclid|gbraid|wbraid|fbclid|msclkid|ttclid|li_fat_id|ref|aff)$/
/** At most this many keys, each value at most this long: pay's own bounds. */
const MOST = 12
const LONGEST = 200
/** The campaign keys in a query, in the order they came, and nothing else. */
export function campaign(search: string): [string, string][] {
const out: [string, string][] = []
for (const [k, v] of new URLSearchParams(search)) {
if (out.length === MOST) break
if (KEY.test(k) && v) out.push([k, v.slice(0, LONGEST)])
}
return out
}
/** Where this tab keeps the campaign its visit landed with: one tab, one visit. */
const KEPT = 'hz_campaign'
/**
* The keys a choice lets this tab keep: a utm/ref/aff tag under Analytics and an ad
* network's click id under Marketing, defaulting to opt-in unless declined or explicitly turned off.
*/
function allowed(keys: [string, string][], c: Choice): [string, string][] {
const hasExplicit = storedConsent()
const isDeclined = declined()
const allowAnalytics = isDeclined ? false : hasExplicit ? c.analytics : true
const allowMarketing = isDeclined ? false : hasExplicit ? c.marketing : true
return keys.filter(([k]) => (k.startsWith('utm_') || k === 'ref' || k === 'aff' ? allowAnalytics : allowMarketing))
}
/** Holds the kept campaign to a choice: the landing's own keys, else the ones kept before, as far as it allows. */
function hold(search: string, c: Choice): void {
try {
const store = window.sessionStorage
const here = campaign(search)
const keep = allowed(here.length ? here : campaign(store.getItem(KEPT) ?? ''), c)
if (keep.length) store.setItem(KEPT, new URLSearchParams(keep).toString())
else store.removeItem(KEPT)
} catch {
/* no store: the address alone carries them */
}
}
/**
* Keep the campaign the address carries for this tab, as far as the visitor's
* choice allows, and hold it to that choice for the page's life: the page a
* visitor lands on calls it (app/providers.tsx), so the tags outlive a sign-in
* that takes them out of the address. Nothing is kept before a choice; an Accept
* on this page keeps what it now allows, and a withdrawal drops what it no longer
* does, here and on every later page. A later page with tags of its own replaces
* them; one with none leaves them. Returns the function that stops listening.
*/
export function arrive(search: string): () => void {
const keep = () => hold(search, readConsent())
keep()
window.addEventListener(CONSENT_EVENT, keep)
return () => window.removeEventListener(CONSENT_EVENT, keep)
}
/** The campaign that brought this visit: the address's own keys, else the ones this tab kept from the page it landed on. */
export function brought(search: string): [string, string][] {
const here = campaign(search)
if (here.length) return here
try {
return campaign(window.sessionStorage.getItem(KEPT) ?? '')
} catch {
return []
}
}