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
parent a7f8a619b1
commit 171b193498
14 changed files with 534 additions and 8 deletions
+15
View File
@@ -0,0 +1,15 @@
-- A/B test event log. One row per exposure / cta_click / conversion.
-- Kept narrow on purpose: the goal is "did variant X get more conversions
-- than control" — not full session reconstruction.
create table if not exists ab_events (
id bigserial primary key,
experiment text not null,
variant text not null,
event_type text not null check (event_type in ('exposure', 'cta_click', 'conversion')),
anon_id text not null,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists ab_events_experiment_variant_idx
on ab_events (experiment, variant, event_type, created_at desc);
+4
View File
@@ -1,5 +1,9 @@
declare global { declare global {
namespace App { namespace App {
interface Locals {
abHero?: import('$lib/server/ab').HeroVariant;
anonId?: string;
}
interface PageData { interface PageData {
content: import('$lib/types').HomePageContent | import('$lib/types').SiteSharedContent; content: import('$lib/types').HomePageContent | import('$lib/types').SiteSharedContent;
} }
+9
View File
@@ -1,5 +1,6 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { resolveSurface } from '$lib/server/surface'; import { resolveSurface } from '$lib/server/surface';
import { resolveAnonId, resolveHeroVariant } from '$lib/server/ab';
const ADMIN_PATH = '/owner/welcome'; const ADMIN_PATH = '/owner/welcome';
@@ -7,6 +8,14 @@ export const handle: Handle = async ({ event, resolve }) => {
const { surface } = resolveSurface(event.url, event.cookies); const { surface } = resolveSurface(event.url, event.cookies);
const path = event.url.pathname; const path = event.url.pathname;
// Sticky A/B assignment, marketing surface only — no point polluting the
// owner/clients hosts with marketing-experiment cookies, and it skews
// exposure counts when staff hit the public site from the dashboard.
if (surface === 'marketing') {
event.locals.anonId = resolveAnonId(event.cookies);
event.locals.abHero = resolveHeroVariant(event.url, event.cookies);
}
// The admin host (cp.*) serves the dashboard at its root. // The admin host (cp.*) serves the dashboard at its root.
if (surface === 'cp' && (path === '/' || path === '')) { if (surface === 'cp' && (path === '/' || path === '')) {
return new Response(null, { return new Response(null, {
+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 Icon from '$lib/components/Icon.svelte';
import { reveal } from '$lib/actions/reveal'; import { reveal } from '$lib/actions/reveal';
import type { BookingContent } from '$lib/types'; import type { BookingContent } from '$lib/types';
import { trackAb, type AbContext } from '$lib/ab';
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default; type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default; type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default;
export let booking: BookingContent; export let booking: BookingContent;
export let pagePath = ''; export let pagePath = '';
export let ab: AbContext | undefined = undefined;
$: isCompactContactPage = pagePath === '/contact-us'; $: isCompactContactPage = pagePath === '/contact-us';
const defaultServices = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services']; const defaultServices = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services'];
@@ -285,6 +287,7 @@
} }
submitted = true; submitted = true;
if (ab) trackAb({ ...ab, event_type: 'conversion', meta: { surface: 'booking_submit' } });
} catch (err: unknown) { } catch (err: unknown) {
submitErrorDetail = err instanceof Error ? err.message : String(err); submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true; showErrorModal = true;
+19 -4
View File
@@ -1,9 +1,23 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import type { CallToAction, HeroContent } from '$lib/types'; import type { CallToAction, HeroContent } from '$lib/types';
import { trackAb, type AbContext } from '$lib/ab';
export let hero: HeroContent; export let hero: HeroContent;
export let reviewCta: CallToAction | undefined = undefined; 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); $: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim(); $: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
@@ -133,12 +147,13 @@
<div class="hero-buttons"> <div class="hero-buttons">
<a <a
href={hero.primaryCta.href} href={primaryCta.href}
target={linkTarget(hero.primaryCta.external)} target={linkTarget(primaryCta.external)}
rel={linkRel(hero.primaryCta.external)} rel={linkRel(primaryCta.external)}
class="btn btn-yellow btn-with-arrow btn-hide-arrow-mobile" 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" /> <Icon name="fas fa-arrow-right" />
</a> </a>
<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;
}
+10 -1
View File
@@ -1,7 +1,8 @@
import { getHomepageContent } from '$lib/server/content'; import { getHomepageContent } from '$lib/server/content';
import { resolveSurface } from '$lib/server/surface'; import { resolveSurface } from '$lib/server/surface';
import { HERO_EXPERIMENT } from '$lib/server/ab';
export async function load({ url, cookies }) { export async function load({ url, cookies, locals }) {
const { surface, isPreview } = resolveSurface(url, cookies); const { surface, isPreview } = resolveSurface(url, cookies);
const siteVariant = surface === 'clients' ? 'onboarding' : 'marketing'; const siteVariant = surface === 'clients' ? 'onboarding' : 'marketing';
@@ -15,5 +16,13 @@ export async function load({ url, cookies }) {
return { return {
siteVariant, siteVariant,
content: await getHomepageContent(), content: await getHomepageContent(),
ab:
locals.abHero && locals.anonId
? {
experiment: HERO_EXPERIMENT,
variant: locals.abHero,
anonId: locals.anonId,
}
: undefined,
}; };
} }
+19 -2
View File
@@ -14,11 +14,23 @@
import OnboardingPage from '$lib/components/OnboardingPage.svelte'; import OnboardingPage from '$lib/components/OnboardingPage.svelte';
import { buildAreaServed } from '$lib/seo'; import { buildAreaServed } from '$lib/seo';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { CallToAction } from '$lib/types';
export let data: PageData; export let data: PageData;
const siteUrl = 'https://www.goodwalk.co.nz'; const siteUrl = 'https://www.goodwalk.co.nz';
// hero_cta experiment: free_emphasis swaps "Book a Meet & Greet"
// for "Book a FREE Meet & Greet". Same href, same destination.
$: heroAb = data.ab;
$: heroOverride =
data.siteVariant === 'marketing' && data.content && heroAb?.variant === 'free_emphasis'
? ({
...data.content.hero.primaryCta,
label: 'Book a FREE Meet & Greet',
} satisfies CallToAction)
: undefined;
function absoluteUrl(value: string) { function absoluteUrl(value: string) {
if (value.startsWith('http://') || value.startsWith('https://')) { if (value.startsWith('http://') || value.startsWith('https://')) {
return value; return value;
@@ -168,14 +180,19 @@
/> />
<Header navigation={content.navigation} /> <Header navigation={content.navigation} />
<HeroSection hero={content.hero} reviewCta={content.intro.reviewCta} /> <HeroSection
hero={content.hero}
reviewCta={content.intro.reviewCta}
primaryCtaOverride={heroOverride}
ab={heroAb}
/>
<ValuesSection values={content.values} /> <ValuesSection values={content.values} />
<ServicesSection services={content.services} /> <ServicesSection services={content.services} />
<HowItWorksSection content={content.howItWorks} /> <HowItWorksSection content={content.howItWorks} />
<TestimonialsSection testimonials={content.testimonials} seedKey="/" /> <TestimonialsSection testimonials={content.testimonials} seedKey="/" />
<FounderStorySection founderStory={content.founderStory} /> <FounderStorySection founderStory={content.founderStory} />
<InfoSection info={content.info} /> <InfoSection info={content.info} />
<BookingWizard booking={content.booking} pagePath="/" /> <BookingWizard booking={content.booking} pagePath="/" ab={heroAb} />
<InstagramSection instagram={content.instagram} /> <InstagramSection instagram={content.instagram} />
<Footer footer={content.footer} /> <Footer footer={content.footer} />
{/if} {/if}
+90
View File
@@ -0,0 +1,90 @@
import { json, error } from '@sveltejs/kit';
import { getPool } from '$lib/server/db';
const ALLOWED_EVENT_TYPES = new Set(['exposure', 'cta_click', 'conversion']);
const MAX_FIELD = 64;
const MAX_META_BYTES = 2048;
const BOT_UA_RE = /bot|spider|crawl|slurp|facebookexternalhit|whatsapp|telegram|preview|monitor|pingdom|uptime|lighthouse|headlesschrome|axios|curl|wget|python-requests/i;
// Ensures the events table exists. Idempotent and cheap. We run this once
// per Node process to handle prod where the init script's only chance was
// volume creation. CREATE TABLE IF NOT EXISTS is the contract.
let schemaReady: Promise<void> | null = null;
function ensureSchema(pool: ReturnType<typeof getPool>): Promise<void> {
if (!pool) return Promise.resolve();
if (!schemaReady) {
schemaReady = pool
.query(
`create table if not exists ab_events (
id bigserial primary key,
experiment text not null,
variant text not null,
event_type text not null check (event_type in ('exposure', 'cta_click', 'conversion')),
anon_id text not null,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists ab_events_experiment_variant_idx
on ab_events (experiment, variant, event_type, created_at desc);`
)
.then(() => undefined)
.catch((err) => {
schemaReady = null; // retry on next request
throw err;
});
}
return schemaReady;
}
function clamp(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.slice(0, MAX_FIELD);
}
export async function POST({ request, locals }) {
const ua = request.headers.get('user-agent') ?? '';
// No UA at all, or a known bot UA → drop silently. Don't pollute metrics
// with scraper exposures or load-balancer health probes.
if (!ua || BOT_UA_RE.test(ua)) return json({ ok: true, recorded: false, reason: 'ua' });
if (!locals.anonId) return json({ ok: true, recorded: false, reason: 'no_anon' });
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
throw error(400, 'invalid json');
}
const experiment = clamp(body.experiment);
const variant = clamp(body.variant);
const eventType = clamp(body.event_type);
if (!experiment || !variant || !eventType) throw error(400, 'missing fields');
if (!ALLOWED_EVENT_TYPES.has(eventType)) throw error(400, 'bad event_type');
const meta = body.meta && typeof body.meta === 'object' ? body.meta : {};
const metaJson = JSON.stringify(meta);
if (metaJson.length > MAX_META_BYTES) throw error(400, 'meta too large');
const pool = getPool();
// No DB configured (local dev without Postgres) → silently accept so the
// marketing site keeps working. Variant assignment still happens; we just
// can't measure it. Production has DATABASE_URL set.
if (!pool) return json({ ok: true, recorded: false, reason: 'no_db' });
try {
await ensureSchema(pool);
await pool.query(
`insert into ab_events (experiment, variant, event_type, anon_id, meta)
values ($1, $2, $3, $4, $5::jsonb)`,
[experiment, variant, eventType, locals.anonId, metaJson]
);
} catch (err) {
console.error('ab_events insert failed', err);
throw error(500, 'insert failed');
}
return json({ ok: true, recorded: true });
}
@@ -0,0 +1,8 @@
import { HERO_EXPERIMENT } from '$lib/server/ab';
import { getExperimentResults } from '$lib/server/ab-results';
export const load = async () => {
return {
results: await getExperimentResults(HERO_EXPERIMENT),
};
};
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
$: results = data.results;
$: control = results.rows.find((r) => r.variant === 'control');
$: variant = results.rows.find((r) => r.variant !== 'control');
$: lift =
control && variant && control.cvr_pct != null && variant.cvr_pct != null && control.cvr_pct > 0
? Math.round(((variant.cvr_pct - control.cvr_pct) / control.cvr_pct) * 10000) / 100
: null;
</script>
<svelte:head>
<title>Experiments · Goodwalk</title>
</svelte:head>
<main class="exp">
<header class="exp-header">
<h1>Experiments</h1>
<p class="exp-meta">
Experiment <code>{results.experiment}</code> · computed
{new Date(results.computedAt).toLocaleString('en-NZ')}
</p>
</header>
{#if !results.available}
<p class="exp-note exp-note-error">{results.note ?? 'Unavailable.'}</p>
{:else if results.rows.length === 0}
<p class="exp-note">{results.note ?? 'No events recorded yet.'}</p>
{:else}
<table class="exp-table">
<thead>
<tr>
<th>Variant</th>
<th>Exposures</th>
<th>CTA clicks</th>
<th>Conversions</th>
<th>Click-thru</th>
<th>CVR</th>
</tr>
</thead>
<tbody>
{#each results.rows as row}
<tr>
<td><code>{row.variant}</code></td>
<td>{row.exposures}</td>
<td>{row.cta_clicks}</td>
<td>{row.conversions}</td>
<td>{row.click_thru_pct != null ? `${row.click_thru_pct}%` : '—'}</td>
<td>{row.cvr_pct != null ? `${row.cvr_pct}%` : '—'}</td>
</tr>
{/each}
</tbody>
</table>
{#if lift != null && control && variant}
<p class="exp-lift" class:exp-lift-positive={lift > 0} class:exp-lift-negative={lift < 0}>
<strong>{variant.variant}</strong> vs <strong>control</strong>: {lift > 0 ? '+' : ''}{lift}%
relative CVR.
</p>
<p class="exp-note">
Counts are unique visitors per event type. Statistical significance is not computed here —
wait for ≥200 conversions per variant before drawing conclusions.
</p>
{/if}
{/if}
<section class="exp-preview">
<h2>Preview a variant</h2>
<ul>
<li><a href="/?ab=control" target="_blank" rel="noopener">View control</a></li>
<li><a href="/?ab=free_emphasis" target="_blank" rel="noopener">View free_emphasis</a></li>
</ul>
</section>
</main>
<style>
.exp {
max-width: 880px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
font-family: var(--font-family-body, system-ui, sans-serif);
color: var(--color-text, #213021);
}
.exp-header h1 {
margin: 0 0 0.25rem;
font-size: 2rem;
}
.exp-meta {
margin: 0 0 2rem;
color: #6b6b6b;
font-size: 0.9rem;
}
.exp-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.exp-table th,
.exp-table td {
text-align: left;
padding: 0.75rem 0.75rem;
border-bottom: 1px solid #e5e5e5;
}
.exp-table th {
font-weight: 600;
background: #f6f6f3;
}
.exp-table code {
background: #eef2ee;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.85rem;
}
.exp-lift {
font-size: 1.1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
background: #f6f6f3;
}
.exp-lift-positive {
background: #e6f4e6;
}
.exp-lift-negative {
background: #fde8e8;
}
.exp-note {
color: #6b6b6b;
font-size: 0.9rem;
}
.exp-note-error {
color: #b00020;
}
.exp-preview {
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e5e5;
}
.exp-preview h2 {
font-size: 1.1rem;
margin: 0 0 0.5rem;
}
.exp-preview ul {
margin: 0;
padding-left: 1.2rem;
}
.exp-preview a {
color: #213021;
}
</style>
+6 -1
View File
@@ -14,7 +14,12 @@ export function createHomepageRouteData() {
return { return {
siteVariant: 'marketing', siteVariant: 'marketing',
content: homepageContent, content: homepageContent,
howItWorksEnabled: false howItWorksEnabled: false,
ab: {
experiment: 'hero_cta',
variant: 'control' as const,
anonId: 'test-anon'
}
}; };
} }