Add hero CTA A/B test (hero_cta: control vs free_emphasis)

Sticky 50/50 variant assignment via gw_ab_hero cookie, server-rendered
so no flicker. Tracks exposures, CTA clicks, and booking conversions
to ab_events (table self-creates on first POST). Bot UAs are dropped;
exposures/clicks dedupe per session.

- ?ab=control / ?ab=free_emphasis forces and persists a variant
- /owner/experiments shows per-variant CVR and relative lift
- AB only runs on the marketing surface

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 08:14:18 +12:00
co-authored by Claude Opus 4.7
parent a7f8a619b1
commit 171b193498
14 changed files with 534 additions and 8 deletions
+61
View File
@@ -0,0 +1,61 @@
/**
* Client-side A/B event reporter. Server already assigned the variant; the
* client just reports exposures + conversions back. Uses sendBeacon when
* available so it survives page navigation (e.g. CTA click → external href).
*/
export interface AbContext {
experiment: string;
variant: string;
}
export interface AbEventPayload extends AbContext {
event_type: 'exposure' | 'cta_click' | 'conversion';
meta?: Record<string, unknown>;
}
const ENDPOINT = '/api/ab';
const SESSION_FIRED_KEY = 'gw_ab_fired';
// Dedupe `exposure` and `cta_click` per session — we only need one row per
// visitor per surface to compute CVR. Conversions are not deduped: every
// genuine booking submit should be recorded.
function shouldDedupe(payload: AbEventPayload): boolean {
if (payload.event_type === 'conversion') return false;
if (typeof sessionStorage === 'undefined') return false;
const key = `${payload.experiment}:${payload.variant}:${payload.event_type}:${payload.meta?.surface ?? ''}`;
try {
const raw = sessionStorage.getItem(SESSION_FIRED_KEY);
const fired = raw ? (JSON.parse(raw) as string[]) : [];
if (fired.includes(key)) return true;
fired.push(key);
sessionStorage.setItem(SESSION_FIRED_KEY, JSON.stringify(fired));
return false;
} catch {
return false;
}
}
export function trackAb(payload: AbEventPayload): void {
if (typeof window === 'undefined') return;
if (!payload.experiment || !payload.variant) return;
if (shouldDedupe(payload)) return;
const body = JSON.stringify(payload);
try {
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon(ENDPOINT, blob)) return;
}
} catch {
// fall through to fetch
}
void fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => {});
}
+3
View File
@@ -4,12 +4,14 @@
import Icon from '$lib/components/Icon.svelte';
import { reveal } from '$lib/actions/reveal';
import type { BookingContent } from '$lib/types';
import { trackAb, type AbContext } from '$lib/ab';
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default;
export let booking: BookingContent;
export let pagePath = '';
export let ab: AbContext | undefined = undefined;
$: isCompactContactPage = pagePath === '/contact-us';
const defaultServices = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services'];
@@ -285,6 +287,7 @@
}
submitted = true;
if (ab) trackAb({ ...ab, event_type: 'conversion', meta: { surface: 'booking_submit' } });
} catch (err: unknown) {
submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true;
+19 -4
View File
@@ -1,9 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import type { CallToAction, HeroContent } from '$lib/types';
import { trackAb, type AbContext } from '$lib/ab';
export let hero: HeroContent;
export let reviewCta: CallToAction | undefined = undefined;
export let primaryCtaOverride: CallToAction | undefined = undefined;
export let ab: AbContext | undefined = undefined;
$: primaryCta = primaryCtaOverride ?? hero.primaryCta;
onMount(() => {
if (ab) trackAb({ ...ab, event_type: 'exposure', meta: { surface: 'hero' } });
});
function handlePrimaryCtaClick() {
if (ab) trackAb({ ...ab, event_type: 'cta_click', meta: { surface: 'hero_primary' } });
}
$: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
@@ -133,12 +147,13 @@
<div class="hero-buttons">
<a
href={hero.primaryCta.href}
target={linkTarget(hero.primaryCta.external)}
rel={linkRel(hero.primaryCta.external)}
href={primaryCta.href}
target={linkTarget(primaryCta.external)}
rel={linkRel(primaryCta.external)}
class="btn btn-yellow btn-with-arrow btn-hide-arrow-mobile"
on:click={handlePrimaryCtaClick}
>
{hero.primaryCta.label}
{primaryCta.label}
<Icon name="fas fa-arrow-right" />
</a>
<a
+76
View File
@@ -0,0 +1,76 @@
import { getPool } from '$lib/server/db';
export interface VariantResult {
variant: string;
exposures: number;
cta_clicks: number;
conversions: number;
cvr_pct: number | null;
click_thru_pct: number | null;
}
export interface ExperimentResults {
experiment: string;
rows: VariantResult[];
computedAt: string;
available: boolean;
note?: string;
}
const QUERY = `
select
variant,
count(distinct anon_id) filter (where event_type = 'exposure') as exposures,
count(distinct anon_id) filter (where event_type = 'cta_click') as cta_clicks,
count(distinct anon_id) filter (where event_type = 'conversion') as conversions
from ab_events
where experiment = $1
group by variant
order by variant;
`;
export async function getExperimentResults(experiment: string): Promise<ExperimentResults> {
const computedAt = new Date().toISOString();
const pool = getPool();
if (!pool) {
return { experiment, rows: [], computedAt, available: false, note: 'DATABASE_URL not configured.' };
}
try {
const result = await pool.query<{
variant: string;
exposures: string;
cta_clicks: string;
conversions: string;
}>(QUERY, [experiment]);
const rows: VariantResult[] = result.rows.map((r) => {
const exposures = Number(r.exposures);
const cta_clicks = Number(r.cta_clicks);
const conversions = Number(r.conversions);
return {
variant: r.variant,
exposures,
cta_clicks,
conversions,
cvr_pct: exposures > 0 ? Math.round((conversions / exposures) * 10000) / 100 : null,
click_thru_pct: exposures > 0 ? Math.round((cta_clicks / exposures) * 10000) / 100 : null,
};
});
return { experiment, rows, computedAt, available: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/relation .*ab_events.* does not exist/i.test(message)) {
return {
experiment,
rows: [],
computedAt,
available: true,
note: 'No data yet — table will be created on the first event.',
};
}
console.error('experiment results query failed', err);
return { experiment, rows: [], computedAt, available: false, note: 'Query failed; check logs.' };
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* A/B test assignment. Sticky per-visitor via cookies, deterministic once
* assigned, and resolved on the server so the first paint already shows the
* correct variant (no flicker).
*
* Current experiment: `hero_cta` does emphasising that the meet & greet is
* FREE lift booking-form conversions?
* control existing "Book a Meet & Greet" copy
* free_emphasis "Book a FREE Meet & Greet"
*/
import type { Cookies } from '@sveltejs/kit';
import { randomBytes } from 'crypto';
export const HERO_EXPERIMENT = 'hero_cta';
export type HeroVariant = 'control' | 'free_emphasis';
const HERO_COOKIE = 'gw_ab_hero';
const ANON_COOKIE = 'gw_anon';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 180; // 180 days
const HERO_VARIANTS: HeroVariant[] = ['control', 'free_emphasis'];
function isHeroVariant(value: string | null | undefined): value is HeroVariant {
return value === 'control' || value === 'free_emphasis';
}
function setStickyCookie(cookies: Cookies, name: string, value: string) {
cookies.set(name, value, {
path: '/',
maxAge: COOKIE_MAX_AGE,
httpOnly: false, // client reads anon_id for event POSTs
sameSite: 'lax',
});
}
export function resolveAnonId(cookies: Cookies): string {
const existing = cookies.get(ANON_COOKIE);
if (existing && existing.length >= 8) return existing;
const fresh = randomBytes(12).toString('base64url');
setStickyCookie(cookies, ANON_COOKIE, fresh);
return fresh;
}
export function resolveHeroVariant(url: URL, cookies: Cookies): HeroVariant {
// ?ab=control or ?ab=free_emphasis forces and persists a variant — useful
// for stakeholder previews, screenshots, and QA. Anyone can use it: the
// only consequence of misuse is one self-skewed cookie.
const forced = url.searchParams.get('ab');
if (isHeroVariant(forced)) {
setStickyCookie(cookies, HERO_COOKIE, forced);
return forced;
}
const existing = cookies.get(HERO_COOKIE);
if (isHeroVariant(existing)) return existing;
const assigned = HERO_VARIANTS[Math.floor(Math.random() * HERO_VARIANTS.length)];
setStickyCookie(cookies, HERO_COOKIE, assigned);
return assigned;
}