From 171b193498f80d5bc9f4e11ad81ac66314fe2e5d Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 25 May 2026 08:14:18 +1200 Subject: [PATCH] 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 --- docker/postgres/init/003-ab-events.sql | 15 ++ src/app.d.ts | 4 + src/hooks.server.ts | 9 ++ src/lib/ab.ts | 61 ++++++++ src/lib/components/BookingWizard.svelte | 3 + src/lib/components/HeroSection.svelte | 23 ++- src/lib/server/ab-results.ts | 76 +++++++++ src/lib/server/ab.ts | 61 ++++++++ src/routes/+page.server.ts | 11 +- src/routes/+page.svelte | 21 ++- src/routes/api/ab/+server.ts | 90 +++++++++++ src/routes/owner/experiments/+page.server.ts | 8 + src/routes/owner/experiments/+page.svelte | 153 +++++++++++++++++++ src/test/fixtures.ts | 7 +- 14 files changed, 534 insertions(+), 8 deletions(-) create mode 100644 docker/postgres/init/003-ab-events.sql create mode 100644 src/lib/ab.ts create mode 100644 src/lib/server/ab-results.ts create mode 100644 src/lib/server/ab.ts create mode 100644 src/routes/api/ab/+server.ts create mode 100644 src/routes/owner/experiments/+page.server.ts create mode 100644 src/routes/owner/experiments/+page.svelte diff --git a/docker/postgres/init/003-ab-events.sql b/docker/postgres/init/003-ab-events.sql new file mode 100644 index 0000000..53b168d --- /dev/null +++ b/docker/postgres/init/003-ab-events.sql @@ -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); diff --git a/src/app.d.ts b/src/app.d.ts index 2e40ce0..d0e8988 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,9 @@ declare global { namespace App { + interface Locals { + abHero?: import('$lib/server/ab').HeroVariant; + anonId?: string; + } interface PageData { content: import('$lib/types').HomePageContent | import('$lib/types').SiteSharedContent; } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f05afe1..f056da8 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,6 @@ import type { Handle } from '@sveltejs/kit'; import { resolveSurface } from '$lib/server/surface'; +import { resolveAnonId, resolveHeroVariant } from '$lib/server/ab'; const ADMIN_PATH = '/owner/welcome'; @@ -7,6 +8,14 @@ export const handle: Handle = async ({ event, resolve }) => { const { surface } = resolveSurface(event.url, event.cookies); 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. if (surface === 'cp' && (path === '/' || path === '')) { return new Response(null, { diff --git a/src/lib/ab.ts b/src/lib/ab.ts new file mode 100644 index 0000000..0d82351 --- /dev/null +++ b/src/lib/ab.ts @@ -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; +} + +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(() => {}); +} diff --git a/src/lib/components/BookingWizard.svelte b/src/lib/components/BookingWizard.svelte index 305f366..40a0751 100644 --- a/src/lib/components/BookingWizard.svelte +++ b/src/lib/components/BookingWizard.svelte @@ -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; diff --git a/src/lib/components/HeroSection.svelte b/src/lib/components/HeroSection.svelte index d16d375..ffbc9e4 100644 --- a/src/lib/components/HeroSection.svelte +++ b/src/lib/components/HeroSection.svelte @@ -1,9 +1,23 @@ + + + Experiments · Goodwalk + + +
+
+

Experiments

+

+ Experiment {results.experiment} · computed + {new Date(results.computedAt).toLocaleString('en-NZ')} +

+
+ + {#if !results.available} +

{results.note ?? 'Unavailable.'}

+ {:else if results.rows.length === 0} +

{results.note ?? 'No events recorded yet.'}

+ {:else} + + + + + + + + + + + + + {#each results.rows as row} + + + + + + + + + {/each} + +
VariantExposuresCTA clicksConversionsClick-thruCVR
{row.variant}{row.exposures}{row.cta_clicks}{row.conversions}{row.click_thru_pct != null ? `${row.click_thru_pct}%` : '—'}{row.cvr_pct != null ? `${row.cvr_pct}%` : '—'}
+ + {#if lift != null && control && variant} +

0} class:exp-lift-negative={lift < 0}> + {variant.variant} vs control: {lift > 0 ? '+' : ''}{lift}% + relative CVR. +

+

+ Counts are unique visitors per event type. Statistical significance is not computed here — + wait for ≥200 conversions per variant before drawing conclusions. +

+ {/if} + {/if} + +
+

Preview a variant

+ +
+
+ + diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 6b2f887..1a3d159 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -14,7 +14,12 @@ export function createHomepageRouteData() { return { siteVariant: 'marketing', content: homepageContent, - howItWorksEnabled: false + howItWorksEnabled: false, + ab: { + experiment: 'hero_cta', + variant: 'control' as const, + anonId: 'test-anon' + } }; }