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
+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