Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions app/[locale]/ab-code/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { generatePermutations, getPrecomputed } from "flags/next"
import { notFound } from "next/navigation"
import { setRequestLocale } from "next-intl/server"

import type { Lang } from "@/lib/types"

import { DEFAULT_LOCALE } from "@/lib/constants"

import OriginalHomePage from "../../page"

import { decodeABCode, encodeABCode } from "@/lib/ab-testing/constants"
import { homepageFlags, homepageHeroFlag } from "@/lib/ab-testing/flags"

export { generateMetadata } from "../../page"

/**
* Generate a static page for each possible combination of flag values.
* Falls back to on-demand rendering (dynamicParams) when permutations
* can't be generated at build time (e.g. FLAGS_SECRET missing in CI).
*/
export async function generateStaticParams() {
try {
const codes = await generatePermutations(homepageFlags)
// Only the default locale is A/B tested. Codes are dot-encoded to keep
// the URL segment directory-like (see encodeABCode).
return codes.map((code) => ({
locale: DEFAULT_LOCALE,
code: encodeABCode(code),
}))
} catch (error) {
console.warn(
"[A/B Testing] generatePermutations failed (missing FLAGS_SECRET?):",
error instanceof Error ? error.message : error
)
return []
}
}

interface PageProps {
params: Promise<{ locale: string; code: string }>
}

/**
* Precomputed homepage with A/B test variants.
* The proxy rewrites eligible requests to include the signed flags code,
* which is used here to retrieve the precomputed flag values.
* This route is internal-only: it is never linked and only reached via rewrite.
*/
export default async function PrecomputedHomePage({ params }: PageProps) {
const { locale, code } = await params

// Only the default locale is A/B tested
if (locale !== DEFAULT_LOCALE) notFound()

// Enable static rendering
setRequestLocale(locale)

let heroVariant: number
try {
;[heroVariant] = await getPrecomputed(
[homepageHeroFlag],
homepageFlags,
decodeABCode(code)
)
} catch {
// Invalid or tampered code - this route is only reachable via the proxy rewrite
notFound()
}

return (
<OriginalHomePage
params={Promise.resolve({ locale: locale as Lang })}
heroVariant={heroVariant}
/>
)
}
76 changes: 76 additions & 0 deletions app/[locale]/ab-code/[code]/wallets/find-wallet/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { generatePermutations, getPrecomputed } from "flags/next"
import { notFound } from "next/navigation"
import { setRequestLocale } from "next-intl/server"

import type { Lang } from "@/lib/types"

import { DEFAULT_LOCALE } from "@/lib/constants"

import OriginalFindWalletPage from "../../../../wallets/find-wallet/page"

import { decodeABCode, encodeABCode } from "@/lib/ab-testing/constants"
import { findWalletFlags, findWalletHeroFlag } from "@/lib/ab-testing/flags"

export { generateMetadata } from "../../../../wallets/find-wallet/page"

/**
* Generate a static page for each possible combination of flag values.
* Falls back to on-demand rendering (dynamicParams) when permutations
* can't be generated at build time (e.g. FLAGS_SECRET missing in CI).
*/
export async function generateStaticParams() {
try {
const codes = await generatePermutations(findWalletFlags)
// Only the default locale is A/B tested. Codes are dot-encoded to keep
// the URL segment directory-like (see encodeABCode).
return codes.map((code) => ({
locale: DEFAULT_LOCALE,
code: encodeABCode(code),
}))
} catch (error) {
console.warn(
"[A/B Testing] generatePermutations failed (missing FLAGS_SECRET?):",
error instanceof Error ? error.message : error
)
return []
}
}

interface PageProps {
params: Promise<{ locale: string; code: string }>
}

/**
* Precomputed find-wallet page with A/B test variants.
* The proxy rewrites eligible requests to include the signed flags code,
* which is used here to retrieve the precomputed flag values.
* This route is internal-only: it is never linked and only reached via rewrite.
*/
export default async function PrecomputedFindWalletPage({ params }: PageProps) {
const { locale, code } = await params

// Only the default locale is A/B tested
if (locale !== DEFAULT_LOCALE) notFound()

// Enable static rendering
setRequestLocale(locale)

let heroVariant: number
try {
;[heroVariant] = await getPrecomputed(
[findWalletHeroFlag],
findWalletFlags,
decodeABCode(code)
)
} catch {
// Invalid or tampered code - this route is only reachable via the proxy rewrite
notFound()
}

return (
<OriginalFindWalletPage
params={Promise.resolve({ locale: locale as Lang })}
heroVariant={heroVariant}
/>
)
}
28 changes: 26 additions & 2 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import type { PageParams } from "@/lib/types"

import { ABTest } from "@/components/AB"
import HomeHero from "@/components/Hero/HomeHero"
import FeatureCards from "@/components/Homepage/FeatureCards"
import GetStartedGrid from "@/components/Homepage/GetStartedGrid"
Expand All @@ -32,9 +33,14 @@ import IndexPageJsonLD from "./page-jsonld"

import { getAccountHolders, getGrowThePieData } from "@/lib/data"

const Page = async (props: { params: Promise<PageParams> }) => {
const Page = async (props: {
params: Promise<PageParams>
/** Precomputed A/B test variant index, passed by the ab-code route */
heroVariant?: number
}) => {
const params = await props.params
const { locale } = params
const { heroVariant } = props

if (!LOCALES_CODES.includes(locale)) return notFound()

Expand Down Expand Up @@ -84,7 +90,25 @@ const Page = async (props: { params: Promise<PageParams> }) => {
<IndexPageJsonLD locale={locale} />
<I18nProvider locale={locale} messages={messages}>
<MainArticle className="flex w-full flex-col items-center" dir={dir}>
<HomeHero eventCategory={eventCategory} />
{heroVariant !== undefined ? (
<ABTest
testKey="HomepageHero"
variantIndex={heroVariant}
variants={[
<HomeHero key="original" eventCategory={eventCategory} />,
// Demo placeholder: visibly distinct variant for preview
// verification. Replace with a real variant component.
<div key="variant-a" className="w-full">
<div className="bg-warning py-2 text-center font-bold">
A/B VARIANT A (HomepageHero demo)
</div>
<HomeHero eventCategory={eventCategory} />
</div>,
]}
/>
) : (
<HomeHero eventCategory={eventCategory} />
)}

<div className="my-24 w-full space-y-24 px-4 md:mx-6 lg:my-32 lg:space-y-32">
<KPISection
Expand Down
49 changes: 42 additions & 7 deletions app/[locale]/wallets/find-wallet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

import type { Lang, PageParams, WalletData } from "@/lib/types"

import { ABTest } from "@/components/AB"
import FindWalletProductTable from "@/components/FindWalletProductTable"
import PageHero from "@/components/Hero/PageHero"
import I18nProvider from "@/components/I18nProvider"
Expand All @@ -27,9 +28,14 @@ import {

import FindWalletPageJsonLD from "./page-jsonld"

const Page = async (props: { params: Promise<PageParams> }) => {
const Page = async (props: {
params: Promise<PageParams>
/** Precomputed A/B test variant index, passed by the ab-code route */
heroVariant?: number
}) => {
const params = await props.params
const { locale } = params
const { heroVariant } = props
const t = await getTranslations("page-wallets-find-wallet")

setRequestLocale(locale)
Expand Down Expand Up @@ -79,12 +85,41 @@ const Page = async (props: { params: Promise<PageParams> }) => {

<I18nProvider locale={locale} messages={messages}>
<MainArticle className="relative flex flex-col">
<PageHero
breadcrumbs={{ slug: "/wallets/find-wallet" }}
title={t("page-find-wallet-title")}
description={t("page-find-wallet-description")}
variant="no-divider"
/>
{heroVariant !== undefined ? (
<ABTest
testKey="FindWalletHero"
variantIndex={heroVariant}
variants={[
<PageHero
key="original"
breadcrumbs={{ slug: "/wallets/find-wallet" }}
title={t("page-find-wallet-title")}
description={t("page-find-wallet-description")}
variant="no-divider"
/>,
// Demo placeholder: visibly distinct variant for preview
// verification. Replace with the redesigned hero.
<div key="variant-a" className="w-full">
<div className="bg-warning py-2 text-center font-bold">
A/B VARIANT A (FindWalletHero demo)
</div>
<PageHero
breadcrumbs={{ slug: "/wallets/find-wallet" }}
title={t("page-find-wallet-title")}
description={t("page-find-wallet-description")}
variant="no-divider"
/>
</div>,
]}
/>
) : (
<PageHero
breadcrumbs={{ slug: "/wallets/find-wallet" }}
title={t("page-find-wallet-title")}
description={t("page-find-wallet-description")}
variant="no-divider"
/>
)}

<Section id="wallets">
<h2 className="sr-only select-none">
Expand Down
8 changes: 7 additions & 1 deletion docs/ab-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ One public URL, several static pages behind it:

The same fingerprint always hashes to the same bucket, so returning visitors get a consistent variant without cookies. On any failure (Matomo down, no experiment running, invalid code) everything falls through to the original page — a test can never break the site.

### Config propagation and latency

Next's Data Cache doesn't exist in the proxy runtime, so the adapter caches the Matomo config at **module level per edge isolate with a 5-minute TTL** (plus in-flight dedupe). Cost per request: one ~50–160 ms Matomo round-trip per isolate per TTL window, ~0 otherwise. Dashboard changes (pause, re-weight, scheduling) propagate within ~5 minutes — no deploy needed. The fetch is bounded by a 2 s timeout; on failure the isolate keeps serving the last known config (stale-if-error) and retries once per TTL window.

### Scope and safety properties

- **Default locale only.** Route keys are locale-less paths and English URLs are unprefixed, so `/es/...` etc. never match — non-English visitors get the original page with no tracker, entirely outside the experiment.
Expand Down Expand Up @@ -142,7 +146,9 @@ Add a mock entry to `MOCK_EXPERIMENTS` in `src/lib/ab-testing/matomo-adapter.ts`

### 5. Create the Matomo experiment and ship

In the Matomo dashboard, create an A/B experiment named exactly like the `testKey`. Variations map to the `variants` array **by position**: Matomo's built-in "Original" is index 0, the first variation you add is index 1, and so on. Set traffic weights, start the experiment, merge the PR. Pausing, re-weighting, or scheduling the test afterwards happens entirely in Matomo — no deploy needed.
In the Matomo dashboard, create an A/B experiment named exactly like the `testKey`. Variations map to the `variants` array **by position**: Matomo's built-in "Original" is index 0, the first variation you add is index 1, and so on. Set traffic weights, start the experiment, merge the PR. Pausing, re-weighting, or scheduling the test afterwards happens entirely in Matomo — no deploy needed (changes propagate within ~5 minutes).

An experiment buckets users only while it's **running** (and inside its date window, if set). A merely **created** experiment stays inactive unless it has an explicit `start_date` — so you can safely prepare experiments in the dashboard ahead of launch.

To remove a finished test: delete the flag, the `abTestRoutes` entry, the coded page, and the `ABTest` wrapper.

Expand Down
1 change: 0 additions & 1 deletion src/components/AB/ABTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ export function ABTest({ testKey, variantIndex, variants }: ABTestProps) {
experimentName: testKey,
variant: availableVariants[safeIndex],
variantIndex: safeIndex,
assignedAt: Date.now(),
}}
/>

Expand Down
29 changes: 28 additions & 1 deletion src/lib/ab-testing/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ export const defineABFlag = (
adapter: createMatomoAdapter(key),
})

/**
* Homepage Hero A/B test flag.
* Demo flag - replace with a real experiment.
*/
export const homepageHeroFlag = defineABFlag(
"HomepageHero",
"Homepage hero A/B test variant index"
)

/**
* Find wallet Hero A/B test flag.
* Demo flag for the /wallets/find-wallet redesign test.
*/
export const findWalletHeroFlag = defineABFlag(
"FindWalletHero",
"Find wallet hero A/B test variant index"
)

/** Flags precomputed for the homepage */
export const homepageFlags = [homepageHeroFlag] as const

/** Flags precomputed for /wallets/find-wallet */
export const findWalletFlags = [findWalletHeroFlag] as const

/**
* A/B-tested routes and the flags precomputed for each.
*
Expand All @@ -96,4 +120,7 @@ export const defineABFlag = (
* pages. Every route registered here needs a matching coded page under
* app/[locale]/ab-code/[code]/<path> - see docs/ab-testing.md for the recipe.
*/
export const abTestRoutes: Record<string, readonly ABFlag[]> = {}
export const abTestRoutes: Record<string, readonly ABFlag[]> = {
"/": homepageFlags,
"/wallets/find-wallet/": findWalletFlags,
}
Loading
Loading