add simple A/B testing with @ab_test decorator and Umami data-tag
Assigns visitors to experiment variants via cookie (30-day), sets g.ab_variant and g.ab_tag, and conditionally adds data-tag to the Umami script tag so all pageviews and events are automatically tagged in the Umami dashboard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
- `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
|
- Meta descriptions and OG tags on all public pages: about, terms, privacy, pricing, features, suppliers, directory, supplier detail
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ def create_app() -> Quart:
|
|||||||
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
|
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
|
||||||
"now": datetime.utcnow(),
|
"now": datetime.utcnow(),
|
||||||
"csrf_token": get_csrf_token,
|
"csrf_token": get_csrf_token,
|
||||||
|
"ab_variant": getattr(g, "ab_variant", None),
|
||||||
|
"ab_tag": getattr(g, "ab_tag", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Core infrastructure: database, config, email, and shared utilities.
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import unicodedata
|
import unicodedata
|
||||||
@@ -15,7 +16,7 @@ from pathlib import Path
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
import resend
|
import resend
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from quart import g, request, session
|
from quart import g, make_response, request, session
|
||||||
|
|
||||||
load_dotenv()
|
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"[^\w\s-]", "", text.lower())
|
||||||
text = re.sub(r"[-\s]+", "-", text).strip("-")
|
text = re.sub(r"[-\s]+", "-", text).strip("-")
|
||||||
return text[:max_length_chars]
|
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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||||
|
|
||||||
<!-- Umami Analytics -->
|
<!-- Umami Analytics -->
|
||||||
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"></script>
|
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
|
||||||
|
|
||||||
<!-- Paddle.js -->
|
<!-- Paddle.js -->
|
||||||
<script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
<script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user