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,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);
|
||||||
Vendored
+4
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user