diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0752e54..45bc689 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
+- Simple A/B testing with `@ab_test` decorator and Umami `data-tag` integration
- SEO defaults in `base.html`: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed
- `robots.txt` route: disallows `/admin/`, `/auth/`, `/dashboard/`, `/billing/`, `/directory/results`; includes Sitemap reference
- Meta descriptions and OG tags on all public pages: about, terms, privacy, pricing, features, suppliers, directory, supplier detail
diff --git a/padelnomics/src/padelnomics/app.py b/padelnomics/src/padelnomics/app.py
index 9a55f7e..a349a1d 100644
--- a/padelnomics/src/padelnomics/app.py
+++ b/padelnomics/src/padelnomics/app.py
@@ -92,6 +92,8 @@ def create_app() -> Quart:
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
"now": datetime.utcnow(),
"csrf_token": get_csrf_token,
+ "ab_variant": getattr(g, "ab_variant", None),
+ "ab_tag": getattr(g, "ab_tag", None),
}
# Health check
diff --git a/padelnomics/src/padelnomics/core.py b/padelnomics/src/padelnomics/core.py
index 2fb21fd..8d63f5b 100644
--- a/padelnomics/src/padelnomics/core.py
+++ b/padelnomics/src/padelnomics/core.py
@@ -4,6 +4,7 @@ Core infrastructure: database, config, email, and shared utilities.
import hashlib
import hmac
import os
+import random
import re
import secrets
import unicodedata
@@ -15,7 +16,7 @@ from pathlib import Path
import aiosqlite
import resend
from dotenv import load_dotenv
-from quart import g, request, session
+from quart import g, make_response, request, session
load_dotenv()
@@ -390,3 +391,27 @@ def slugify(text: str, max_length_chars: int = 80) -> str:
text = re.sub(r"[^\w\s-]", "", text.lower())
text = re.sub(r"[-\s]+", "-", text).strip("-")
return text[:max_length_chars]
+
+
+# =============================================================================
+# A/B Testing
+# =============================================================================
+
+def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
+ """Assign visitor to an A/B test variant via cookie, tag Umami pageviews."""
+ def decorator(f):
+ @wraps(f)
+ async def wrapper(*args, **kwargs):
+ cookie_key = f"ab_{experiment}"
+ assigned = request.cookies.get(cookie_key)
+ if assigned not in variants:
+ assigned = random.choice(variants)
+
+ g.ab_variant = assigned
+ g.ab_tag = f"{experiment}-{assigned}"
+
+ response = await make_response(await f(*args, **kwargs))
+ response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
+ return response
+ return wrapper
+ return decorator
diff --git a/padelnomics/src/padelnomics/templates/base.html b/padelnomics/src/padelnomics/templates/base.html
index 4c2bf56..4ba2393 100644
--- a/padelnomics/src/padelnomics/templates/base.html
+++ b/padelnomics/src/padelnomics/templates/base.html
@@ -20,7 +20,7 @@
-
+