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:
@@ -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(() => {});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user