Skip to content

Commit 9a33aa6

Browse files
committed
Merge branch 'dev' into lazy-find-wallets
2 parents d599cec + ebc74b8 commit 9a33aa6

File tree

15 files changed

+827
-22
lines changed

15 files changed

+827
-22
lines changed

CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,62 @@ pnpm events-import # Import community events
192192
- `wagmi` - React hooks for Ethereum
193193
- `@rainbow-me/rainbowkit` - Wallet connection
194194

195+
## A/B Testing
196+
197+
### Overview
198+
199+
The site uses a GDPR-compliant, cookie-less A/B testing system integrated with Matomo. Tests are configured entirely through the Matomo dashboard with no code changes required.
200+
201+
### Key Features
202+
203+
- **Matomo API Integration** - Experiments configured in Matomo dashboard
204+
- **Cookie-less Variant Persistence** - Uses deterministic IP + User-Agent fingerprinting for variant assignment
205+
- **Server-side Rendering** - No layout shifts, consistent variants on first load
206+
- **Real-time Updates** - Change weights instantly via Matomo (no deployments)
207+
- **Preview Mode** - Debug panel available in development and preview environments
208+
- **Automatic Fallbacks** - Graceful degradation when API fails (shows original variant)
209+
210+
### Adding a New A/B Test
211+
212+
1. **Create experiment in Matomo dashboard**:
213+
- Go to Experiments → Manage Experiments
214+
- Create new experiment with desired name (e.g., "HomepageHero")
215+
- Add variations with weights (original is implicit)
216+
- Set status to "running"
217+
218+
2. **Implement in component**:
219+
```tsx
220+
import ABTestWrapper from "@/components/AB/TestWrapper"
221+
222+
<ABTestWrapper
223+
testKey="HomepageHero" // Must match Matomo experiment name exactly
224+
variants={[
225+
<OriginalComponent key="current-hero" />, // Index 0: Original
226+
<NewComponent key="redesigned-hero" /> // Index 1: Variation
227+
]}
228+
fallback={<OriginalComponent />}
229+
/>
230+
```
231+
232+
**Important**:
233+
- Variants matched by **array index**, not names
234+
- Array order must match Matomo experiment order exactly
235+
- JSX `key` props become debug panel labels: `"redesigned-hero"``"Redesigned Hero"`
236+
- No TypeScript changes required - system fetches configuration from Matomo
237+
238+
### Architecture
239+
240+
- **`/api/ab-config`** - Fetches experiment data from Matomo API
241+
- **`src/lib/ab-testing/`** - Core logic for assignment and tracking
242+
- **`src/components/AB/`** - React components for testing and debugging
243+
244+
### Environment Variables
245+
246+
Required for Matomo integration:
247+
- `NEXT_PUBLIC_MATOMO_URL` - Matomo instance URL
248+
- `NEXT_PUBLIC_MATOMO_SITE_ID` - Site ID in Matomo
249+
- `MATOMO_API_TOKEN` - API token with experiments access
250+
195251
## Deployment
196252

197253
- **Platform**: Netlify (config in `netlify.toml`)

app/[locale]/get-eth/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export default async function Page({
100100
}) {
101101
const { locale } = await params
102102
const t = await getTranslations({ locale, namespace: "page-get-eth" })
103+
const tCommon = await getTranslations({ locale, namespace: "common" })
103104

104105
const tokenSwaps: CardListCardProps[] = [
105106
{
@@ -241,7 +242,7 @@ export default async function Page({
241242
<Stack className="gap-16">
242243
<p>
243244
<em>
244-
{t("common:listing-policy-disclaimer")}{" "}
245+
{tCommon("listing-policy-disclaimer")}{" "}
245246
<InlineLink href="https://github.com/ethereum/ethereum-org-website/issues/new/choose">
246247
{t("listing-policy-raise-issue-link")}
247248
</InlineLink>

app/[locale]/stablecoins/page.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
import { Lang } from "@/lib/types"
1010

11+
import ABTestWrapper from "@/components/AB/TestWrapper"
1112
import CalloutBannerSSR from "@/components/CalloutBannerSSR"
1213
import DataProductCard from "@/components/DataProductCard"
1314
import Emoji from "@/components/Emoji"
@@ -102,7 +103,7 @@ async function Page({ params }: { params: Promise<{ locale: Lang }> }) {
102103
const requiredNamespaces = getRequiredNamespacesForPage("/stablecoins")
103104
const messages = pick(allMessages, requiredNamespaces)
104105

105-
let marketsHasError = false
106+
let marketsHasError = false // TODO: Implement error handling
106107
const coinDetails: CoinDetails[] = []
107108

108109
try {
@@ -131,7 +132,7 @@ async function Page({ params }: { params: Promise<{ locale: Lang }> }) {
131132
coinDetails.push(...ethereumStablecoinData)
132133
} catch (error) {
133134
console.error(error)
134-
marketsHasError = true
135+
marketsHasError = true // TODO: Handle error state
135136
}
136137

137138
const heroContent = {
@@ -590,19 +591,29 @@ async function Page({ params }: { params: Promise<{ locale: Lang }> }) {
590591
imageWidth={600}
591592
alt={t("page-stablecoins-stablecoins-dapp-callout-image-alt")}
592593
>
593-
<div className="flex flex-wrap gap-4">
594-
<ButtonLink href="/dapps/">
595-
{t("page-stablecoins-explore-dapps")}
596-
</ButtonLink>
597-
<ButtonLink
598-
variant="outline"
599-
href="/defi/"
600-
className="whitespace-normal"
601-
isSecondary
602-
>
603-
{t("page-stablecoins-more-defi-button")}
604-
</ButtonLink>
605-
</div>
594+
<ABTestWrapper
595+
testKey="AppTest"
596+
variants={[
597+
<div key="two-buttons" className="flex flex-wrap gap-4">
598+
<ButtonLink href="/dapps/">
599+
{t("page-stablecoins-explore-dapps")}
600+
</ButtonLink>
601+
<ButtonLink
602+
variant="outline"
603+
href="/defi/"
604+
className="whitespace-normal"
605+
isSecondary
606+
>
607+
{t("page-stablecoins-more-defi-button")}
608+
</ButtonLink>
609+
</div>,
610+
<div key="single-button" className="flex flex-wrap gap-4">
611+
<ButtonLink href="/dapps/">
612+
{t("page-stablecoins-explore-apps")}
613+
</ButtonLink>
614+
</div>,
615+
]}
616+
/>
606617
</CalloutBannerSSR>
607618
<h2>{t("page-stablecoins-save-stablecoins")}</h2>
608619
<Flex className="mb-8 me-8 w-full flex-col items-start lg:flex-row">

app/api/ab-config/route.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { NextResponse } from "next/server"
2+
3+
import { IS_PREVIEW_DEPLOY } from "@/lib/utils/env"
4+
5+
import type { ABTestConfig, MatomoExperiment } from "@/lib/ab-testing/types"
6+
7+
const isExperimentActive = (experiment: MatomoExperiment): boolean => {
8+
const now = new Date()
9+
10+
// Check start date - if scheduled for future, not active yet
11+
if (experiment.start_date) {
12+
const startDate = new Date(experiment.start_date)
13+
if (now < startDate) return false
14+
}
15+
16+
// Check end date - if past end date, not active anymore
17+
if (experiment.end_date) {
18+
const endDate = new Date(experiment.end_date)
19+
if (now > endDate) return false
20+
}
21+
22+
// If no scheduling constraints, enabled if created or running
23+
return ["created", "running"].includes(experiment.status)
24+
}
25+
26+
const getPreviewConfig = () => ({
27+
AppTest: {
28+
id: "preview",
29+
enabled: true,
30+
variants: [{ name: "Original", weight: 100 }],
31+
},
32+
})
33+
34+
export async function GET() {
35+
// Preview mode: Show menu with original default
36+
if (IS_PREVIEW_DEPLOY) return NextResponse.json(getPreviewConfig())
37+
38+
try {
39+
const matomoUrl = process.env.NEXT_PUBLIC_MATOMO_URL
40+
const apiToken = process.env.MATOMO_API_TOKEN
41+
42+
if (!matomoUrl || !apiToken) {
43+
return NextResponse.json(
44+
{ error: "Matomo configuration missing" },
45+
{ status: 500 }
46+
)
47+
}
48+
49+
const siteId = process.env.NEXT_PUBLIC_MATOMO_SITE_ID || "4"
50+
51+
// Add cache busting for development
52+
const cacheBuster =
53+
process.env.NODE_ENV === "development" ? `&cb=${Date.now()}` : ""
54+
const matomoApiUrl = `${matomoUrl}/index.php?module=API&method=AbTesting.getAllExperiments&idSite=${siteId}&format=json&token_auth=${apiToken}${cacheBuster}`
55+
56+
const response = await fetch(matomoApiUrl, {
57+
next: { revalidate: process.env.NODE_ENV === "development" ? 0 : 3600 },
58+
headers: { "User-Agent": "ethereum.org-ab-testing/1.0" },
59+
})
60+
61+
const data = await response.json()
62+
63+
if (data.result === "error" || !Array.isArray(data)) {
64+
console.error(
65+
"[AB Config] Matomo API error:",
66+
data.message || "Invalid response"
67+
)
68+
return NextResponse.json(
69+
{},
70+
{
71+
headers: {
72+
"Cache-Control": "s-max-age=300, stale-while-revalidate=600",
73+
},
74+
}
75+
)
76+
}
77+
78+
const experiments: MatomoExperiment[] = data
79+
80+
// Transform Matomo experiments to our config format
81+
const config: Record<string, ABTestConfig> = {}
82+
83+
experiments
84+
.filter((exp) => exp.variations && exp.variations.length > 0)
85+
.forEach((exp) => {
86+
// Calculate Original variant weight (100% - sum of all variations)
87+
const variationsTotalWeight = exp.variations.reduce(
88+
(sum, variation) => sum + (variation.percentage || 0),
89+
0
90+
)
91+
const originalWeight = 100 - variationsTotalWeight
92+
93+
// Build variants array starting with "Original"
94+
const variants = [
95+
{ name: "Original", weight: originalWeight },
96+
...exp.variations.map((variation) => ({
97+
name: variation.name,
98+
weight: variation.percentage || 0,
99+
})),
100+
]
101+
102+
config[exp.name] = {
103+
name: exp.name,
104+
id: exp.idexperiment,
105+
enabled: isExperimentActive(exp),
106+
variants,
107+
}
108+
})
109+
110+
return NextResponse.json(config, {
111+
headers: {
112+
"Cache-Control": "s-max-age=3600, stale-while-revalidate=7200",
113+
},
114+
})
115+
} catch (error) {
116+
console.error("[AB Config] Failed to fetch AB test configuration:", error)
117+
118+
return NextResponse.json(
119+
{},
120+
{
121+
headers: {
122+
"Cache-Control": "no-cache",
123+
},
124+
}
125+
)
126+
}
127+
}

0 commit comments

Comments
 (0)