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