merge: affiliate product system (racket/gear editorial cards, click tracking, admin CRUD)

This commit is contained in:
Deeman
2026-02-28 21:07:03 +01:00
29 changed files with 2416 additions and 10 deletions

View File

@@ -2499,7 +2499,12 @@ async def article_results():
@csrf_protect
async def article_new():
"""Create a manual article."""
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
from ..content.routes import (
BUILD_DIR,
bake_product_cards,
bake_scenario_cards,
is_reserved_path,
)
if request.method == "POST":
form = await request.form
@@ -2523,9 +2528,10 @@ async def article_new():
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False)
# Render markdown → HTML with scenario cards baked in
# Render markdown → HTML with scenario + product cards baked in
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html)
body_html = await bake_product_cards(body_html, lang=language)
build_dir = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True)
@@ -2561,7 +2567,12 @@ async def article_new():
@csrf_protect
async def article_edit(article_id: int):
"""Edit a manual article."""
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
from ..content.routes import (
BUILD_DIR,
bake_product_cards,
bake_scenario_cards,
is_reserved_path,
)
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article:
@@ -2591,6 +2602,7 @@ async def article_edit(article_id: int):
if body:
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html)
body_html = await bake_product_cards(body_html, lang=language)
build_dir = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article['slug']}.html").write_text(body_html)
@@ -2735,7 +2747,7 @@ async def rebuild_all():
async def _rebuild_article(article_id: int):
"""Re-render a single article from its source."""
from ..content.routes import BUILD_DIR, bake_scenario_cards
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article:
@@ -2760,6 +2772,7 @@ async def _rebuild_article(article_id: int):
body_html = mistune.html(md_path.read_text())
lang = article.get("language", "en") if hasattr(article, "get") else "en"
body_html = await bake_scenario_cards(body_html, lang=lang)
body_html = await bake_product_cards(body_html, lang=lang)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
@@ -3233,3 +3246,363 @@ async def outreach_import():
await flash(f"Imported {imported} suppliers. Skipped {skipped} (duplicates or missing data).", "success")
return redirect(url_for("admin.outreach"))
# =============================================================================
# Affiliate Product Catalog
# =============================================================================
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
AFFILIATE_STATUSES = ("draft", "active", "archived")
def _form_to_product(form) -> dict:
"""Parse affiliate product form values into a data dict."""
price_str = form.get("price_eur", "").strip()
price_cents = None
if price_str:
try:
price_cents = round(float(price_str.replace(",", ".")) * 100)
except ValueError:
price_cents = None
rating_str = form.get("rating", "").strip()
rating = None
if rating_str:
try:
rating = float(rating_str.replace(",", "."))
except ValueError:
rating = None
pros_raw = form.get("pros", "").strip()
cons_raw = form.get("cons", "").strip()
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
return {
"slug": form.get("slug", "").strip(),
"name": form.get("name", "").strip(),
"brand": form.get("brand", "").strip(),
"category": form.get("category", "accessory").strip(),
"retailer": form.get("retailer", "").strip(),
"affiliate_url": form.get("affiliate_url", "").strip(),
"image_url": form.get("image_url", "").strip(),
"price_cents": price_cents,
"currency": "EUR",
"rating": rating,
"pros": pros,
"cons": cons,
"description": form.get("description", "").strip(),
"cta_label": form.get("cta_label", "").strip(),
"status": form.get("status", "draft").strip(),
"language": form.get("language", "de").strip() or "de",
"sort_order": int(form.get("sort_order", "0") or "0"),
}
@bp.route("/affiliate")
@role_required("admin")
async def affiliate_products():
"""Affiliate product list — full page."""
from ..affiliate import get_all_products, get_click_counts, get_distinct_retailers
q = request.args.get("q", "").strip()
category = request.args.get("category", "").strip()
retailer_filter = request.args.get("retailer", "").strip()
status_filter = request.args.get("status", "").strip()
products = await get_all_products(
status=status_filter or None,
retailer=retailer_filter or None,
)
if q:
q_lower = q.lower()
products = [p for p in products if q_lower in p["name"].lower() or q_lower in p["brand"].lower()]
if category:
products = [p for p in products if p["category"] == category]
click_counts = await get_click_counts()
for p in products:
p["click_count"] = click_counts.get(p["id"], 0)
retailers = await get_distinct_retailers()
return await render_template(
"admin/affiliate_products.html",
admin_page="affiliate",
products=products,
click_counts=click_counts,
retailers=retailers,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
q=q,
category=category,
retailer_filter=retailer_filter,
status_filter=status_filter,
)
@bp.route("/affiliate/results")
@role_required("admin")
async def affiliate_results():
"""HTMX partial: filtered product rows."""
from ..affiliate import get_all_products, get_click_counts
q = request.args.get("q", "").strip()
category = request.args.get("category", "").strip()
retailer_filter = request.args.get("retailer", "").strip()
status_filter = request.args.get("status", "").strip()
products = await get_all_products(
status=status_filter or None,
retailer=retailer_filter or None,
)
if q:
q_lower = q.lower()
products = [p for p in products if q_lower in p["name"].lower() or q_lower in p["brand"].lower()]
if category:
products = [p for p in products if p["category"] == category]
click_counts = await get_click_counts()
for p in products:
p["click_count"] = click_counts.get(p["id"], 0)
return await render_template(
"admin/partials/affiliate_results.html",
products=products,
)
@bp.route("/affiliate/new", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def affiliate_new():
"""Create an affiliate product."""
from ..affiliate import get_distinct_retailers
if request.method == "POST":
form = await request.form
data = _form_to_product(form)
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
await flash("Slug, name, and affiliate URL are required.", "error")
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
data=data,
editing=False,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
)
existing = await fetch_one(
"SELECT id FROM affiliate_products WHERE slug = ? AND language = ?",
(data["slug"], data["language"]),
)
if existing:
await flash(f"Slug '{data['slug']}' already exists for language '{data['language']}'.", "error")
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
data=data,
editing=False,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
)
await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url, image_url,
price_cents, currency, rating, pros, cons, description, cta_label,
status, language, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data["slug"], data["name"], data["brand"], data["category"],
data["retailer"], data["affiliate_url"], data["image_url"],
data["price_cents"], data["currency"], data["rating"],
data["pros"], data["cons"], data["description"], data["cta_label"],
data["status"], data["language"], data["sort_order"],
),
)
await flash(f"Product '{data['name']}' created.", "success")
return redirect(url_for("admin.affiliate_products"))
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
data={},
editing=False,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
)
@bp.route("/affiliate/<int:product_id>/edit", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def affiliate_edit(product_id: int):
"""Edit an affiliate product."""
from ..affiliate import get_distinct_retailers
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
if not product:
await flash("Product not found.", "error")
return redirect(url_for("admin.affiliate_products"))
if request.method == "POST":
form = await request.form
data = _form_to_product(form)
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
await flash("Slug, name, and affiliate URL are required.", "error")
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
data={**dict(product), **data},
editing=True,
product_id=product_id,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
)
# Check slug collision only if slug or language changed
if data["slug"] != product["slug"] or data["language"] != product["language"]:
collision = await fetch_one(
"SELECT id FROM affiliate_products WHERE slug = ? AND language = ? AND id != ?",
(data["slug"], data["language"], product_id),
)
if collision:
await flash(f"Slug '{data['slug']}' already exists for language '{data['language']}'.", "error")
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
data={**dict(product), **data},
editing=True,
product_id=product_id,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
)
await execute(
"""UPDATE affiliate_products
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?,
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?,
description=?, cta_label=?, status=?, language=?, sort_order=?,
updated_at=datetime('now')
WHERE id=?""",
(
data["slug"], data["name"], data["brand"], data["category"],
data["retailer"], data["affiliate_url"], data["image_url"],
data["price_cents"], data["currency"], data["rating"],
data["pros"], data["cons"], data["description"], data["cta_label"],
data["status"], data["language"], data["sort_order"],
product_id,
),
)
await flash(f"Product '{data['name']}' updated.", "success")
return redirect(url_for("admin.affiliate_products"))
# Render pros/cons JSON arrays as newline-separated text for the form
product_dict = dict(product)
try:
product_dict["pros_text"] = "\n".join(json.loads(product["pros"] or "[]"))
product_dict["cons_text"] = "\n".join(json.loads(product["cons"] or "[]"))
except (json.JSONDecodeError, TypeError):
product_dict["pros_text"] = ""
product_dict["cons_text"] = ""
if product["price_cents"]:
product_dict["price_eur"] = f"{product['price_cents'] / 100:.2f}"
else:
product_dict["price_eur"] = ""
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
data=product_dict,
editing=True,
product_id=product_id,
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
)
@bp.route("/affiliate/<int:product_id>/delete", methods=["POST"])
@role_required("admin")
@csrf_protect
async def affiliate_delete(product_id: int):
"""Delete an affiliate product."""
product = await fetch_one("SELECT name FROM affiliate_products WHERE id = ?", (product_id,))
if product:
await execute("DELETE FROM affiliate_products WHERE id = ?", (product_id,))
await flash(f"Product '{product['name']}' deleted.", "success")
return redirect(url_for("admin.affiliate_products"))
@bp.route("/affiliate/dashboard")
@role_required("admin")
async def affiliate_dashboard():
"""Affiliate click statistics dashboard."""
from ..affiliate import get_click_stats
days_count = int(request.args.get("days", "30") or "30")
days_count = max(7, min(days_count, 365))
stats = await get_click_stats(days_count)
# Build estimated revenue: clicks × assumed 3% CR × avg basket €80
est_revenue = round(stats["total_clicks"] * 0.03 * 80)
# Article count (live articles that have been clicked)
article_count = len(stats["top_articles"])
# Retailer bars: compute pct of max for width
max_ret_clicks = max((r["click_count"] for r in stats["by_retailer"]), default=1)
for r in stats["by_retailer"]:
r["pct"] = round(r["click_count"] / max_ret_clicks * 100) if max_ret_clicks else 0
total = stats["total_clicks"] or 1
r["share_pct"] = round(r["click_count"] / total * 100)
return await render_template(
"admin/affiliate_dashboard.html",
admin_page="affiliate_dashboard",
stats=stats,
est_revenue=est_revenue,
article_count=article_count,
days_count=days_count,
)
@bp.route("/affiliate/<int:product_id>/toggle", methods=["POST"])
@role_required("admin")
async def affiliate_toggle(product_id: int):
"""Toggle product status: draft → active → archived → draft."""
product = await fetch_one(
"SELECT id, name, status FROM affiliate_products WHERE id = ?", (product_id,)
)
if not product:
return "", 404
cycle = {"draft": "active", "active": "archived", "archived": "draft"}
new_status = cycle.get(product["status"], "draft")
await execute(
"UPDATE affiliate_products SET status=?, updated_at=datetime('now') WHERE id=?",
(new_status, product_id),
)
product_updated = await fetch_one(
"SELECT * FROM affiliate_products WHERE id = ?", (product_id,)
)
from ..affiliate import get_click_counts
click_counts = await get_click_counts()
product_dict = dict(product_updated)
product_dict["click_count"] = click_counts.get(product_id, 0)
return await render_template(
"admin/partials/affiliate_row.html",
product=product_dict,
)

View File

@@ -0,0 +1,121 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "affiliate_dashboard" %}
{% block title %}Affiliate Dashboard - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<h1 class="text-2xl">Affiliate Dashboard</h1>
<div class="flex gap-2">
{% for d in [7, 30, 90] %}
<a href="?days={{ d }}" class="btn-outline btn-sm {% if days_count == d %}active{% endif %}">{{ d }}d</a>
{% endfor %}
</div>
</header>
{# ── Stats strip ── #}
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:1.5rem;">
<div class="card" style="padding:1.25rem;">
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Clicks ({{ days_count }}d)</div>
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.total_clicks | int }}</div>
</div>
<div class="card" style="padding:1.25rem;">
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Products</div>
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.active_products or 0 }}</div>
<div class="text-xs text-slate">{{ stats.draft_products or 0 }} draft</div>
</div>
<div class="card" style="padding:1.25rem;">
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Articles (clicked)</div>
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ article_count }}</div>
</div>
<div class="card" style="padding:1.25rem;">
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Est. Revenue</div>
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">~€{{ est_revenue }}</div>
<div class="text-xs text-slate">3% CR × €80 basket</div>
</div>
</div>
{# ── Daily bar chart ── #}
{% if stats.daily_bars %}
<div class="card mb-6" style="padding:1.5rem;">
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks · Last {{ days_count }} Days</div>
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;overflow-x:auto;">
{% for bar in stats.daily_bars %}
<div title="{{ bar.day }}: {{ bar.click_count }} clicks"
style="flex-shrink:0;width:8px;background:#1D4ED8;border-radius:3px 3px 0 0;min-height:2px;height:{{ bar.pct }}%;transition:opacity .15s;"
onmouseover="this.style.opacity='.7'" onmouseout="this.style.opacity='1'">
</div>
{% endfor %}
</div>
<div style="display:flex;justify-content:space-between;margin-top:.375rem;">
<span class="text-xs text-slate">{{ stats.daily_bars[0].day if stats.daily_bars else '' }}</span>
<span class="text-xs text-slate">{{ stats.daily_bars[-1].day if stats.daily_bars else '' }}</span>
</div>
</div>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem;">
{# ── Top products ── #}
<div class="card" style="padding:1.5rem;">
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Products</div>
{% if stats.top_products %}
{% for p in stats.top_products %}
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
<a href="{{ url_for('admin.affiliate_edit', product_id=p.id) }}" style="color:inherit;text-decoration:none;">{{ p.name }}</a>
</span>
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ p.click_count }}</span>
</div>
{% endfor %}
{% else %}
<p class="text-slate text-sm">No clicks yet.</p>
{% endif %}
</div>
{# ── Top articles ── #}
<div class="card" style="padding:1.5rem;">
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Articles</div>
{% if stats.top_articles %}
{% for a in stats.top_articles %}
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="{{ a.article_slug }}">{{ a.article_slug }}</span>
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ a.click_count }}</span>
</div>
{% endfor %}
{% else %}
<p class="text-slate text-sm">No clicks with article source yet.</p>
{% endif %}
</div>
</div>
{# ── Clicks by retailer ── #}
{% if stats.by_retailer %}
<div class="card" style="padding:1.5rem;">
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks by Retailer</div>
{% for r in stats.by_retailer %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.75rem;">
<span style="width:140px;font-size:.8125rem;color:#0F172A;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ r.retailer or 'Unknown' }}
</span>
<div style="flex:1;background:#F1F5F9;border-radius:4px;height:24px;overflow:hidden;">
<div style="width:{{ r.pct }}%;background:#1D4ED8;height:100%;border-radius:4px;min-width:2px;"></div>
</div>
<span class="mono" style="font-size:.8125rem;font-weight:600;width:60px;text-align:right;flex-shrink:0;">
{{ r.click_count }} <span class="text-slate" style="font-weight:400;">({{ r.share_pct }}%)</span>
</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,216 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "affiliate" %}
{% block title %}{% if editing %}Edit Product{% else %}New Product{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<script>
function slugify(text) {
return text.toLowerCase()
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
document.addEventListener('DOMContentLoaded', function() {
var nameInput = document.getElementById('f-name');
var slugInput = document.getElementById('f-slug');
if (nameInput && slugInput && !slugInput.value) {
nameInput.addEventListener('input', function() {
if (!slugInput.dataset.manual) {
slugInput.value = slugify(nameInput.value);
}
});
slugInput.addEventListener('input', function() {
slugInput.dataset.manual = '1';
});
}
});
</script>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<div>
<a href="{{ url_for('admin.affiliate_products') }}" class="text-slate text-sm" style="text-decoration:none">← Products</a>
<h1 class="text-2xl mt-1">{% if editing %}Edit Product{% else %}New Product{% endif %}</h1>
</div>
</header>
<div style="display:grid;grid-template-columns:1fr 380px;gap:2rem;align-items:start" class="affiliate-form-grid">
{# ── Left: form ── #}
<form method="post" id="affiliate-form"
hx-post="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}"
hx-target="#product-preview"
hx-trigger="input delay:600ms"
hx-push-url="false">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
{# Name #}
<div>
<label class="form-label" for="f-name">Name *</label>
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
class="form-input" placeholder="e.g. Bullpadel Vertex 04" required>
</div>
{# Slug #}
<div>
<label class="form-label" for="f-slug">Slug *</label>
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
class="form-input" placeholder="e.g. bullpadel-vertex-04-amazon" required
pattern="[a-z0-9][a-z0-9\-]*">
<p class="form-hint">Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. <code>-amazon</code>, <code>-padelnuestro</code>).</p>
</div>
{# Brand + Category row #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="form-label" for="f-brand">Brand</label>
<input id="f-brand" type="text" name="brand" value="{{ data.get('brand','') }}"
class="form-input" placeholder="e.g. Bullpadel">
</div>
<div>
<label class="form-label" for="f-category">Category</label>
<select id="f-category" name="category" class="form-input">
{% for cat in categories %}
<option value="{{ cat }}" {% if data.get('category','accessory') == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
{% endfor %}
</select>
</div>
</div>
{# Retailer #}
<div>
<label class="form-label" for="f-retailer">Retailer</label>
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
list="retailers-list">
<datalist id="retailers-list">
{% for r in retailers %}
<option value="{{ r }}">
{% endfor %}
</datalist>
</div>
{# Affiliate URL #}
<div>
<label class="form-label" for="f-url">Affiliate URL *</label>
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21" required>
<p class="form-hint">Full URL with tracking params already baked in.</p>
</div>
{# Image URL #}
<div>
<label class="form-label" for="f-image">Image URL</label>
<input id="f-image" type="text" name="image_url" value="{{ data.get('image_url','') }}"
class="form-input" placeholder="/static/images/affiliate/bullpadel-vertex-04.webp">
<p class="form-hint">Local path (recommended) or external URL.</p>
</div>
{# Price + Rating row #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="form-label" for="f-price">Price (EUR)</label>
<input id="f-price" type="number" name="price_eur" value="{{ data.get('price_eur','') }}"
class="form-input" placeholder="149.99" step="0.01" min="0">
</div>
<div>
<label class="form-label" for="f-rating">Rating (05)</label>
<input id="f-rating" type="number" name="rating" value="{{ data.get('rating','') }}"
class="form-input" placeholder="4.3" step="0.1" min="0" max="5">
</div>
</div>
{# Description #}
<div>
<label class="form-label" for="f-desc">Short Description</label>
<textarea id="f-desc" name="description" rows="3"
class="form-input" placeholder="One to two sentences describing the product...">{{ data.get('description','') }}</textarea>
</div>
{# Pros #}
<div>
<label class="form-label" for="f-pros">Pros <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
<textarea id="f-pros" name="pros" rows="4"
class="form-input" placeholder="Carbon frame for maximum power&#10;Diamond shape for aggressive players">{{ data.get('pros_text', data.get('pros','')) }}</textarea>
</div>
{# Cons #}
<div>
<label class="form-label" for="f-cons">Cons <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
<textarea id="f-cons" name="cons" rows="3"
class="form-input" placeholder="Only for advanced players">{{ data.get('cons_text', data.get('cons','')) }}</textarea>
</div>
{# CTA Label #}
<div>
<label class="form-label" for="f-cta">CTA Label</label>
<input id="f-cta" type="text" name="cta_label" value="{{ data.get('cta_label','') }}"
class="form-input" placeholder='Leave empty for default "Zum Angebot"'>
</div>
{# Status + Language + Sort #}
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
<div>
<label class="form-label" for="f-status">Status</label>
<select id="f-status" name="status" class="form-input">
{% for s in statuses %}
<option value="{{ s }}" {% if data.get('status','draft') == s %}selected{% endif %}>{{ s | capitalize }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label" for="f-lang">Language</label>
<select id="f-lang" name="language" class="form-input">
<option value="de" {% if data.get('language','de') == 'de' %}selected{% endif %}>DE</option>
<option value="en" {% if data.get('language','de') == 'en' %}selected{% endif %}>EN</option>
</select>
</div>
<div>
<label class="form-label" for="f-sort">Sort Order</label>
<input id="f-sort" type="number" name="sort_order" value="{{ data.get('sort_order', 0) }}"
class="form-input" min="0">
</div>
</div>
{# Actions #}
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
<div class="flex gap-2">
<button type="submit" class="btn" formaction="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
{% if editing %}Save Changes{% else %}Create Product{% endif %}
</button>
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
</div>
{% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline"
onclick="return confirm('Delete this product? This cannot be undone.')">Delete</button>
</form>
{% endif %}
</div>
</div>
</form>
{# ── Right: live preview ── #}
<div style="position:sticky;top:1.5rem;">
<div class="text-xs font-semibold text-slate mb-2" style="text-transform:uppercase;letter-spacing:.06em;">Preview</div>
<div id="product-preview" style="border:1px solid #E2E8F0;border-radius:12px;padding:1rem;background:#F8FAFC;min-height:180px;">
<p class="text-slate text-sm" style="text-align:center;margin-top:2rem;">
Fill in the form to see a live preview.
</p>
</div>
</div>
</div>
<style>
@media (max-width: 900px) {
.affiliate-form-grid { grid-template-columns: 1fr !important; }
}
</style>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "affiliate" %}
{% block title %}Affiliate Products - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<h1 class="text-2xl">Affiliate Products</h1>
<a href="{{ url_for('admin.affiliate_new') }}" class="btn btn-sm">+ New Product</a>
</header>
{# Filters #}
<div class="card mb-6" style="padding:1rem 1.25rem">
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.affiliate_results') }}"
hx-target="#aff-results"
hx-trigger="change, input delay:300ms"
hx-indicator="#aff-loading">
<div>
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
<input type="text" name="q" value="{{ q }}" placeholder="Name or brand..."
class="form-input" style="min-width:200px">
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Category</label>
<select name="category" class="form-input" style="min-width:120px">
<option value="">All</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if cat == category %}selected{% endif %}>{{ cat | capitalize }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Retailer</label>
<select name="retailer" class="form-input" style="min-width:140px">
<option value="">All</option>
{% for r in retailers %}
<option value="{{ r }}" {% if r == retailer_filter %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
<select name="status" class="form-input" style="min-width:110px">
<option value="">All</option>
{% for s in statuses %}
<option value="{{ s }}" {% if s == status_filter %}selected{% endif %}>{{ s | capitalize }}</option>
{% endfor %}
</select>
</div>
<svg id="aff-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>
{# Results #}
<div id="aff-results">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Brand</th>
<th>Retailer</th>
<th>Category</th>
<th>Price</th>
<th>Status</th>
<th class="text-right">Clicks</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{% include "admin/partials/affiliate_results.html" %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -99,6 +99,7 @@
'suppliers': 'suppliers',
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
'affiliate': 'affiliate',
'billing': 'billing',
'seo': 'analytics',
'pipeline': 'pipeline',
@@ -149,6 +150,11 @@
Billing
</a>
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016 2.993 2.993 0 0 0 2.25-1.016 3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
Affiliate
</a>
<a href="{{ url_for('admin.seo') }}" class="{% if active_section == 'analytics' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
Analytics
@@ -196,6 +202,11 @@
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">Audiences</a>
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">Outreach</a>
</nav>
{% elif active_section == 'affiliate' %}
<nav class="admin-subnav">
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
</nav>
{% elif active_section == 'system' %}
<nav class="admin-subnav">
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">Users</a>

View File

@@ -0,0 +1,9 @@
{% if products %}
{% for product in products %}
{% include "admin/partials/affiliate_row.html" %}
{% endfor %}
{% else %}
<tr>
<td colspan="8" class="text-slate" style="text-align:center;padding:2rem;">No products found.</td>
</tr>
{% endif %}

View File

@@ -0,0 +1,29 @@
<tr id="aff-{{ product.id }}">
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ product.name }}">
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" style="color:#0F172A;text-decoration:none;font-weight:500;">{{ product.name }}</a>
</td>
<td class="text-slate">{{ product.brand or '—' }}</td>
<td class="text-slate">{{ product.retailer or '—' }}</td>
<td class="text-slate">{{ product.category }}</td>
<td class="mono">
{% if product.price_cents %}{{ "%.0f" | format(product.price_cents / 100) }}€{% else %}—{% endif %}
</td>
<td>
<button hx-post="{{ url_for('admin.affiliate_toggle', product_id=product.id) }}"
hx-target="#aff-{{ product.id }}" hx-swap="outerHTML"
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
class="badge {% if product.status == 'active' %}badge-success{% elif product.status == 'draft' %}badge-warning{% else %}badge{% endif %}"
style="cursor:pointer;border:none;">
{{ product.status }}
</button>
</td>
<td class="mono text-right">{{ product.click_count or 0 }}</td>
<td class="text-right" style="white-space:nowrap">
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete {{ product.name }}?')">Delete</button>
</form>
</td>
</tr>

View File

@@ -0,0 +1,224 @@
"""
Affiliate product catalog: product lookup, click logging, and stats queries.
All functions are plain async procedures — no classes, no state.
Design decisions:
- IP hashing uses a daily salt (date + SECRET_KEY[:16]) for GDPR compliance.
Rotating salt prevents re-identification across days without storing PII.
- Products are fetched by (slug, language) with a graceful fallback to any
language, so DE cards appear in EN articles rather than nothing.
- Stats are computed entirely in SQL — no Python aggregation.
"""
import hashlib
import json
import logging
from datetime import date
from .core import config, execute, fetch_all, fetch_one
logger = logging.getLogger(__name__)
VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
VALID_STATUSES = ("draft", "active", "archived")
def hash_ip(ip_address: str) -> str:
"""SHA256(ip + YYYY-MM-DD + SECRET_KEY[:16]) with daily salt rotation."""
assert ip_address, "ip_address must not be empty"
today = date.today().isoformat()
salt = config.SECRET_KEY[:16]
raw = f"{ip_address}:{today}:{salt}"
return hashlib.sha256(raw.encode()).hexdigest()
async def get_product(slug: str, language: str = "de") -> dict | None:
"""Return active product by slug+language, falling back to any language."""
assert slug, "slug must not be empty"
row = await fetch_one(
"SELECT * FROM affiliate_products"
" WHERE slug = ? AND language = ? AND status = 'active'",
(slug, language),
)
if row:
return _parse_product(row)
# Graceful fallback: show any language rather than nothing
row = await fetch_one(
"SELECT * FROM affiliate_products"
" WHERE slug = ? AND status = 'active' LIMIT 1",
(slug,),
)
return _parse_product(row) if row else None
async def get_products_by_category(category: str, language: str = "de") -> list[dict]:
"""Return active products in category sorted by sort_order, with fallback."""
assert category in VALID_CATEGORIES, f"unknown category: {category}"
rows = await fetch_all(
"SELECT * FROM affiliate_products"
" WHERE category = ? AND language = ? AND status = 'active'"
" ORDER BY sort_order ASC, id ASC",
(category, language),
)
if rows:
return [_parse_product(r) for r in rows]
# Fallback: any language for this category
rows = await fetch_all(
"SELECT * FROM affiliate_products"
" WHERE category = ? AND status = 'active'"
" ORDER BY sort_order ASC, id ASC",
(category,),
)
return [_parse_product(r) for r in rows]
async def get_all_products(
status: str | None = None,
retailer: str | None = None,
) -> list[dict]:
"""Admin listing — all products, optionally filtered by status and/or retailer."""
conditions = []
params: list = []
if status:
assert status in VALID_STATUSES, f"unknown status: {status}"
conditions.append("status = ?")
params.append(status)
if retailer:
conditions.append("retailer = ?")
params.append(retailer)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = await fetch_all(
f"SELECT * FROM affiliate_products {where} ORDER BY sort_order ASC, id ASC",
tuple(params),
)
return [_parse_product(r) for r in rows]
async def get_click_counts() -> dict[int, int]:
"""Return {product_id: click_count} for all products (used in admin list)."""
rows = await fetch_all(
"SELECT product_id, COUNT(*) AS cnt FROM affiliate_clicks GROUP BY product_id"
)
return {r["product_id"]: r["cnt"] for r in rows}
async def log_click(
product_id: int,
ip_address: str,
article_slug: str | None,
referrer: str | None,
) -> None:
"""Insert a click event. Hashes IP for GDPR compliance."""
assert product_id > 0, "product_id must be positive"
assert ip_address, "ip_address must not be empty"
ip = hash_ip(ip_address)
await execute(
"INSERT INTO affiliate_clicks (product_id, article_slug, referrer, ip_hash)"
" VALUES (?, ?, ?, ?)",
(product_id, article_slug, referrer, ip),
)
async def get_click_stats(days_count: int = 30) -> dict:
"""Compute click statistics over the last N days, entirely in SQL."""
assert 1 <= days_count <= 365, f"days must be 1-365, got {days_count}"
# Total clicks in window
total_row = await fetch_one(
"SELECT COUNT(*) AS cnt FROM affiliate_clicks"
" WHERE clicked_at >= datetime('now', ?)",
(f"-{days_count} days",),
)
total = total_row["cnt"] if total_row else 0
# Active product count
product_counts = await fetch_one(
"SELECT"
" SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active_count,"
" SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft_count"
" FROM affiliate_products"
)
# Top products by clicks
top_products = await fetch_all(
"SELECT p.id, p.name, p.slug, p.retailer, COUNT(c.id) AS click_count"
" FROM affiliate_products p"
" LEFT JOIN affiliate_clicks c"
" ON c.product_id = p.id"
" AND c.clicked_at >= datetime('now', ?)"
" GROUP BY p.id"
" ORDER BY click_count DESC"
" LIMIT 10",
(f"-{days_count} days",),
)
# Top articles by clicks
top_articles = await fetch_all(
"SELECT article_slug, COUNT(*) AS click_count"
" FROM affiliate_clicks"
" WHERE clicked_at >= datetime('now', ?)"
" AND article_slug IS NOT NULL"
" GROUP BY article_slug"
" ORDER BY click_count DESC"
" LIMIT 10",
(f"-{days_count} days",),
)
# Clicks by retailer
by_retailer = await fetch_all(
"SELECT p.retailer, COUNT(c.id) AS click_count"
" FROM affiliate_products p"
" LEFT JOIN affiliate_clicks c"
" ON c.product_id = p.id"
" AND c.clicked_at >= datetime('now', ?)"
" GROUP BY p.retailer"
" ORDER BY click_count DESC",
(f"-{days_count} days",),
)
# Daily click counts for bar chart
daily = await fetch_all(
"SELECT date(clicked_at) AS day, COUNT(*) AS click_count"
" FROM affiliate_clicks"
" WHERE clicked_at >= datetime('now', ?)"
" GROUP BY day"
" ORDER BY day ASC",
(f"-{days_count} days",),
)
# Normalize daily to percentage heights for CSS bar chart
max_daily = max((r["click_count"] for r in daily), default=1)
daily_bars = [
{"day": r["day"], "click_count": r["click_count"],
"pct": round(r["click_count"] / max_daily * 100)}
for r in daily
]
return {
"total_clicks": total,
"active_products": product_counts["active_count"] if product_counts else 0,
"draft_products": product_counts["draft_count"] if product_counts else 0,
"top_products": [dict(r) for r in top_products],
"top_articles": [dict(r) for r in top_articles],
"by_retailer": [dict(r) for r in by_retailer],
"daily_bars": daily_bars,
"days": days_count,
}
async def get_distinct_retailers() -> list[str]:
"""Return sorted list of distinct retailer names for form datalist."""
rows = await fetch_all(
"SELECT DISTINCT retailer FROM affiliate_products"
" WHERE retailer != '' ORDER BY retailer"
)
return [r["retailer"] for r in rows]
def _parse_product(row) -> dict:
"""Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays."""
d = dict(row)
d["pros"] = json.loads(d.get("pros") or "[]")
d["cons"] = json.loads(d.get("cons") or "[]")
return d

View File

@@ -280,6 +280,49 @@ def create_app() -> Quart:
except Exception as e:
return {"status": "unhealthy", "db": str(e)}, 500
# -------------------------------------------------------------------------
# Affiliate click redirect — language-agnostic, no blueprint prefix
# -------------------------------------------------------------------------
@app.route("/go/<slug>")
async def affiliate_redirect(slug: str):
"""302 redirect to affiliate URL, logging the click.
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
Extracts article_slug and lang from Referer header best-effort.
"""
from .affiliate import get_product, log_click
from .core import check_rate_limit
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
referer = request.headers.get("Referer", "")
lang = "de"
article_slug = None
if referer:
try:
from urllib.parse import urlparse
ref_path = urlparse(referer).path
parts = ref_path.strip("/").split("/")
if parts and len(parts[0]) == 2:
lang = parts[0]
if len(parts) > 1:
article_slug = parts[-1] or None
except Exception:
pass
product = await get_product(slug, lang)
if not product:
abort(404)
ip = request.remote_addr or "unknown"
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
if not allowed:
# Still redirect even if rate-limited; just don't log the click
return redirect(product["affiliate_url"], 302)
await log_click(product["id"], ip, article_slug, referer or None)
return redirect(product["affiliate_url"], 302)
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
@app.route("/terms")
async def legacy_terms():

View File

@@ -315,7 +315,7 @@ async def generate_articles(
"""
from ..core import execute as db_execute
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards, is_reserved_path
from .routes import bake_product_cards, bake_scenario_cards, is_reserved_path
assert articles_per_day > 0, "articles_per_day must be positive"
@@ -443,6 +443,7 @@ async def generate_articles(
body_html = await bake_scenario_cards(
body_html, lang=lang, scenario_overrides=scenario_overrides
)
body_html = await bake_product_cards(body_html, lang=lang)
t_bake += time.perf_counter() - t0
# Extract FAQ pairs for structured data
@@ -584,7 +585,7 @@ async def preview_article(
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
"""
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards
from .routes import bake_product_cards, bake_scenario_cards
config = load_template(slug)
@@ -641,6 +642,7 @@ async def preview_article(
body_html = await bake_scenario_cards(
body_html, lang=lang, scenario_overrides=scenario_overrides,
)
body_html = await bake_product_cards(body_html, lang=lang)
return {
"title": title,

View File

@@ -27,6 +27,8 @@ RESERVED_PREFIXES = (
)
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
PRODUCT_RE = re.compile(r'\[product:([a-z0-9_-]+)\]')
PRODUCT_GROUP_RE = re.compile(r'\[product-group:([a-z0-9_-]+)\]')
SECTION_TEMPLATES = {
None: "partials/scenario_summary.html",
@@ -112,6 +114,53 @@ async def bake_scenario_cards(
return html
async def bake_product_cards(html: str, lang: str = "de") -> str:
"""Replace [product:slug] and [product-group:category] markers with rendered HTML.
Processes markers in two passes (product first, then groups) to keep logic
clear. Reverse iteration preserves string offsets when splicing.
"""
from ..affiliate import get_product, get_products_by_category
t = get_translations(lang)
# ── Pass 1: [product:slug] ────────────────────────────────────────────────
product_matches = list(PRODUCT_RE.finditer(html))
if product_matches:
slugs = list({m.group(1) for m in product_matches})
products: dict[str, dict | None] = {}
for slug in slugs:
products[slug] = await get_product(slug, lang)
tmpl = _bake_env.get_template("partials/product_card.html")
for match in reversed(product_matches):
slug = match.group(1)
product = products.get(slug)
if not product:
continue
card_html = tmpl.render(product=product, lang=lang, t=t)
html = html[:match.start()] + card_html + html[match.end():]
# ── Pass 2: [product-group:category] ─────────────────────────────────────
group_matches = list(PRODUCT_GROUP_RE.finditer(html))
if group_matches:
categories = list({m.group(1) for m in group_matches})
groups: dict[str, list] = {}
for cat in categories:
groups[cat] = await get_products_by_category(cat, lang)
tmpl = _bake_env.get_template("partials/product_group.html")
for match in reversed(group_matches):
cat = match.group(1)
group_products = groups.get(cat, [])
if not group_products:
continue
grid_html = tmpl.render(products=group_products, category=cat, lang=lang, t=t)
html = html[:match.start()] + grid_html + html[match.end():]
return html
# =============================================================================
# Markets Hub
# =============================================================================

View File

@@ -0,0 +1,89 @@
{# Affiliate product card — editorial recommendation style.
Variables: product (dict with parsed pros/cons lists), t (translations), lang.
Rendered bake-time by bake_product_cards(); no request context available. #}
{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%}
{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%}
<div class="aff-card" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1.5rem;margin:1.5rem 0;box-shadow:0 1px 3px rgba(0,0,0,.05);transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow='0 1px 3px rgba(0,0,0,.05)'">
<div style="display:flex;gap:1.25rem;align-items:flex-start;flex-wrap:wrap;">
{# ── Image ── #}
<div style="width:160px;flex-shrink:0;aspect-ratio:1;border-radius:12px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
{% if product.image_url %}
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
{% else %}
<svg width="48" height="48" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
{% endif %}
</div>
{# ── Content ── #}
<div style="flex:1;min-width:0;">
{# Brand + retailer #}
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem;">
{% if product.brand %}
<span style="text-transform:uppercase;font-size:.6875rem;font-weight:600;letter-spacing:.06em;color:#64748B;">{{ product.brand }}</span>
{% endif %}
{% if product.retailer %}
<span style="background:#F1F5F9;border-radius:999px;padding:2px 8px;font-size:.625rem;font-weight:600;color:#64748B;letter-spacing:.04em;text-transform:uppercase;">{{ t.affiliate_at_retailer | tformat(retailer=product.retailer) }}</span>
{% endif %}
</div>
{# Name #}
<h3 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1.125rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0 0 .375rem;">{{ product.name }}</h3>
{# Rating #}
{% if product.rating %}
{%- set stars_full = product.rating | int -%}
{%- set has_half = (product.rating - stars_full) >= 0.5 -%}
<div style="display:flex;align-items:center;gap:.25rem;margin-bottom:.375rem;">
<span style="color:#D97706;font-size:.9375rem;">
{%- for i in range(stars_full) %}★{% endfor -%}
{%- if has_half %}★{% endif -%}
{%- for i in range(5 - stars_full - (1 if has_half else 0)) %}<span style="color:#E2E8F0;"></span>{% endfor -%}
</span>
<span style="font-size:.8125rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
</div>
{% endif %}
{# Price #}
{% if price_eur %}
<div style="font-family:'Commit Mono',monospace;font-size:1.25rem;font-weight:700;color:#0F172A;margin-bottom:.5rem;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
{% endif %}
{# Description #}
{% if product.description %}
<p style="font-size:.875rem;color:#475569;line-height:1.55;margin:.625rem 0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">{{ product.description }}</p>
{% endif %}
{# Pros #}
{% if product.pros %}
<ul style="list-style:none;padding:0;margin:.625rem 0 .25rem;">
{% for pro in product.pros %}
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#16A34A;margin-right:.25rem;"></span>{{ pro }}</li>
{% endfor %}
</ul>
{% endif %}
{# Cons #}
{% if product.cons %}
<ul style="list-style:none;padding:0;margin:.25rem 0 .75rem;">
{% for con in product.cons %}
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#EF4444;margin-right:.25rem;"></span>{{ con }}</li>
{% endfor %}
</ul>
{% endif %}
{# CTA #}
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
style="display:block;width:100%;background:#1D4ED8;color:#fff;border-radius:12px;padding:.625rem 1.25rem;font-weight:600;font-size:.875rem;text-align:center;text-decoration:none;box-shadow:0 2px 10px rgba(29,78,216,.25);transition:background .2s,transform .2s;margin-top:.5rem;"
onmouseover="this.style.background='#1E40AF';this.style.transform='translateY(-1px)'"
onmouseout="this.style.background='#1D4ED8';this.style.transform=''">
{{ cta }} →
</a>
{# Disclosure #}
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.5rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,68 @@
{# Affiliate product comparison grid — editorial picks layout.
Variables: products (list of dicts), category (str), t (translations), lang.
Rendered bake-time by bake_product_cards(). #}
{% if products %}
<div style="margin:2rem 0;">
{# Section header #}
<div style="text-transform:uppercase;font-size:.75rem;font-weight:600;color:#64748B;letter-spacing:.06em;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:2px solid #E2E8F0;">
{{ t.affiliate_our_picks }} · {{ category | capitalize }}
</div>
{# Responsive grid of compact cards #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;">
{% for product in products %}
{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%}
{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%}
<div class="aff-card-compact" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1rem;display:flex;flex-direction:column;gap:.5rem;transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow=''">
{# Image #}
<div style="aspect-ratio:1;border-radius:10px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
{% if product.image_url %}
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
{% else %}
<svg width="36" height="36" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
{% endif %}
</div>
{# Brand #}
{% if product.brand %}
<span style="text-transform:uppercase;font-size:.625rem;font-weight:600;letter-spacing:.06em;color:#94A3B8;">{{ product.brand }}</span>
{% endif %}
{# Name #}
<h4 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0;line-height:1.3;">{{ product.name }}</h4>
{# Rating + pros/cons counts #}
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;">
{% if product.rating %}
<span style="color:#D97706;font-size:.8125rem;"></span>
<span style="font-size:.75rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
{% endif %}
{% if product.pros %}
<span style="font-size:.6875rem;color:#16A34A;background:#F0FDF4;border-radius:999px;padding:1px 6px;">{{ product.pros | length }} {{ t.affiliate_pros_label }}</span>
{% endif %}
</div>
{# Price #}
{% if price_eur %}
<div style="font-family:'Commit Mono',monospace;font-size:1.0625rem;font-weight:700;color:#0F172A;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
{% endif %}
{# CTA — pushed to bottom via margin-top:auto #}
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
style="display:block;background:#1D4ED8;color:#fff;border-radius:10px;padding:.5rem 1rem;font-weight:600;font-size:.8125rem;text-align:center;text-decoration:none;margin-top:auto;transition:background .2s;"
onmouseover="this.style.background='#1E40AF'"
onmouseout="this.style.background='#1D4ED8'">
{{ cta }} →
</a>
</div>
{% endfor %}
</div>
{# Shared disclosure #}
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.75rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
</div>
{% endif %}

View File

@@ -1777,5 +1777,12 @@
"report_q1_confirmed_title": "Download bereit",
"report_q1_confirmed_body": "Unten auf den Button klicken, um das vollständige Bericht-PDF zu öffnen.",
"report_q1_download_btn": "PDF herunterladen",
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern."
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern.",
"affiliate_cta_buy": "Zum Angebot",
"affiliate_disclosure": "Affiliate-Link — wir erhalten eine Provision ohne Mehrkosten für dich.",
"affiliate_pros_label": "Vorteile",
"affiliate_cons_label": "Nachteile",
"affiliate_at_retailer": "bei {retailer}",
"affiliate_our_picks": "Unsere Empfehlungen"
}

View File

@@ -1780,5 +1780,12 @@
"report_q1_confirmed_title": "Your download is ready",
"report_q1_confirmed_body": "Click below to open the full report PDF.",
"report_q1_download_btn": "Download PDF",
"report_q1_download_note": "PDF opens in your browser. Right-click to save."
"report_q1_download_note": "PDF opens in your browser. Right-click to save.",
"affiliate_cta_buy": "View offer",
"affiliate_disclosure": "Affiliate link — we may earn a commission at no extra cost to you.",
"affiliate_pros_label": "Pros",
"affiliate_cons_label": "Cons",
"affiliate_at_retailer": "at {retailer}",
"affiliate_our_picks": "Our picks"
}

View File

@@ -0,0 +1,65 @@
"""Migration 0026: Affiliate product catalog + click tracking tables.
affiliate_products: admin-managed product catalog for editorial affiliate cards.
- slug+language uniqueness mirrors articles (same slug can exist in DE + EN
with different affiliate URLs, copy, and pros/cons).
- retailer: display name (Amazon, Padel Nuestro, etc.) — stored in full URL
with tracking params already baked into affiliate_url.
- cta_label: per-product override; empty → use i18n default "Zum Angebot".
- status: draft/active/archived — only active products are baked into articles.
affiliate_clicks: one row per /go/<slug> redirect hit.
- ip_hash: SHA256(ip + YYYY-MM-DD + SECRET_KEY[:16]), daily rotation for GDPR.
- article_slug: best-effort extraction from Referer header.
"""
def up(conn) -> None:
conn.execute("""
CREATE TABLE affiliate_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
name TEXT NOT NULL,
brand TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'accessory',
retailer TEXT NOT NULL DEFAULT '',
affiliate_url TEXT NOT NULL,
image_url TEXT NOT NULL DEFAULT '',
price_cents INTEGER,
currency TEXT NOT NULL DEFAULT 'EUR',
rating REAL,
pros TEXT NOT NULL DEFAULT '[]',
cons TEXT NOT NULL DEFAULT '[]',
description TEXT NOT NULL DEFAULT '',
cta_label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
language TEXT NOT NULL DEFAULT 'de',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT,
UNIQUE(slug, language)
)
""")
conn.execute("""
CREATE TABLE affiliate_clicks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES affiliate_products(id),
article_slug TEXT,
referrer TEXT,
ip_hash TEXT NOT NULL,
clicked_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# Queries: products by category+status, clicks by product and time
conn.execute(
"CREATE INDEX idx_affiliate_products_category_status"
" ON affiliate_products(category, status)"
)
conn.execute(
"CREATE INDEX idx_affiliate_clicks_product_id"
" ON affiliate_clicks(product_id)"
)
conn.execute(
"CREATE INDEX idx_affiliate_clicks_clicked_at"
" ON affiliate_clicks(clicked_at)"
)

332
web/tests/test_affiliate.py Normal file
View File

@@ -0,0 +1,332 @@
"""
Tests for the affiliate product system.
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer.
"""
import json
from datetime import date
from unittest.mock import patch
import pytest
from padelnomics.affiliate import (
get_all_products,
get_click_counts,
get_click_stats,
get_product,
get_products_by_category,
hash_ip,
log_click,
)
from padelnomics.content.routes import PRODUCT_GROUP_RE, PRODUCT_RE, bake_product_cards
from padelnomics.core import execute, fetch_all
# ── Helpers ────────────────────────────────────────────────────────────────────
async def _insert_product(
slug="test-racket-amazon",
name="Test Racket",
brand="TestBrand",
category="racket",
retailer="Amazon",
affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
status="active",
language="de",
price_cents=14999,
pros=None,
cons=None,
sort_order=0,
) -> int:
"""Insert an affiliate product, return its id."""
return await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url,
price_cents, currency, status, language, pros, cons, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
(
slug, name, brand, category, retailer, affiliate_url,
price_cents, status, language,
json.dumps(pros or ["Gut"]),
json.dumps(cons or ["Teuer"]),
sort_order,
),
)
# ── hash_ip ────────────────────────────────────────────────────────────────────
def test_hash_ip_deterministic():
"""Same IP + same day → same hash."""
h1 = hash_ip("1.2.3.4")
h2 = hash_ip("1.2.3.4")
assert h1 == h2
assert len(h1) == 64 # SHA256 hex digest
def test_hash_ip_different_ips_differ():
"""Different IPs → different hashes."""
assert hash_ip("1.2.3.4") != hash_ip("5.6.7.8")
def test_hash_ip_rotates_daily():
"""Different days → different hashes for same IP (GDPR daily rotation)."""
with patch("padelnomics.affiliate.date") as mock_date:
mock_date.today.return_value = date(2026, 2, 1)
h1 = hash_ip("1.2.3.4")
mock_date.today.return_value = date(2026, 2, 2)
h2 = hash_ip("1.2.3.4")
assert h1 != h2
# ── get_product ────────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_product_active_by_lang(db):
"""get_product returns active product for correct language."""
await _insert_product(slug="vertex-amazon", language="de", status="active")
product = await get_product("vertex-amazon", "de")
assert product is not None
assert product["slug"] == "vertex-amazon"
assert isinstance(product["pros"], list)
@pytest.mark.usefixtures("db")
async def test_get_product_draft_returns_none(db):
"""Draft products are not returned."""
await _insert_product(slug="vertex-draft", status="draft")
product = await get_product("vertex-draft", "de")
assert product is None
@pytest.mark.usefixtures("db")
async def test_get_product_lang_fallback(db):
"""Falls back to any language when no match for requested lang."""
await _insert_product(slug="vertex-de-only", language="de", status="active")
# Request EN but only DE exists — should fall back
product = await get_product("vertex-de-only", "en")
assert product is not None
assert product["language"] == "de"
@pytest.mark.usefixtures("db")
async def test_get_product_not_found(db):
"""Returns None for unknown slug."""
product = await get_product("nonexistent-slug", "de")
assert product is None
# ── get_products_by_category ───────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_products_by_category_sorted(db):
"""Returns products sorted by sort_order."""
await _insert_product(slug="racket-b", name="Racket B", sort_order=2)
await _insert_product(slug="racket-a", name="Racket A", sort_order=1)
products = await get_products_by_category("racket", "de")
assert len(products) == 2
assert products[0]["sort_order"] == 1
assert products[1]["sort_order"] == 2
@pytest.mark.usefixtures("db")
async def test_get_products_by_category_inactive_excluded(db):
"""Draft and archived products are excluded."""
await _insert_product(slug="racket-draft", status="draft")
await _insert_product(slug="racket-archived", status="archived")
products = await get_products_by_category("racket", "de")
assert products == []
# ── get_all_products ───────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_all_products_no_filter(db):
"""Returns all products regardless of status."""
await _insert_product(slug="p1", status="active")
await _insert_product(slug="p2", status="draft")
products = await get_all_products()
assert len(products) == 2
@pytest.mark.usefixtures("db")
async def test_get_all_products_status_filter(db):
"""Status filter returns only matching rows."""
await _insert_product(slug="p-active", status="active")
await _insert_product(slug="p-draft", status="draft")
active = await get_all_products(status="active")
assert len(active) == 1
assert active[0]["slug"] == "p-active"
# ── log_click + get_click_counts ──────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_log_click_inserts_row(db):
"""log_click inserts a row into affiliate_clicks."""
product_id = await _insert_product(slug="clickable")
await log_click(product_id, "1.2.3.4", "beste-padelschlaeger", "https://example.com/de/blog/test")
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (product_id,))
assert len(rows) == 1
assert rows[0]["article_slug"] == "beste-padelschlaeger"
# IP hash must not be the raw IP
assert rows[0]["ip_hash"] != "1.2.3.4"
@pytest.mark.usefixtures("db")
async def test_get_click_counts(db):
"""get_click_counts returns dict of product_id → count."""
pid = await _insert_product(slug="tracked-product")
await log_click(pid, "1.2.3.4", None, None)
await log_click(pid, "5.6.7.8", None, None)
counts = await get_click_counts()
assert counts.get(pid) == 2
# ── get_click_stats ────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_get_click_stats_structure(db):
"""get_click_stats returns expected keys."""
stats = await get_click_stats(days_count=30)
assert "total_clicks" in stats
assert "top_products" in stats
assert "daily_bars" in stats
assert "by_retailer" in stats
# ── bake_product_cards ────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_replaces_marker(db):
"""[product:slug] marker is replaced with rendered HTML."""
await _insert_product(slug="vertex-04-amazon", name="Bullpadel Vertex 04", status="active")
html = "<p>Intro</p>\n[product:vertex-04-amazon]\n<p>Outro</p>"
result = await bake_product_cards(html, lang="de")
assert "[product:vertex-04-amazon]" not in result
assert "Bullpadel Vertex 04" in result
assert "/go/vertex-04-amazon" in result
assert "sponsored" in result
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_missing_slug_passthrough(db):
"""Unknown slugs pass through unchanged — no product card rendered."""
html = "<p>Text</p>\n[product:nonexistent-slug]\n<p>End</p>"
result = await bake_product_cards(html, lang="de")
# Surrounding content is intact; no product HTML injected
assert "<p>Text</p>" in result
assert "<p>End</p>" in result
assert "<article" not in result # no product card rendered
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_group_marker(db):
"""[product-group:category] renders a grid of products."""
await _insert_product(slug="shoe-1-amazon", name="Test Shoe", category="shoe", status="active")
html = "<h2>Shoes</h2>\n[product-group:shoe]\n<p>End</p>"
result = await bake_product_cards(html, lang="de")
assert "[product-group:shoe]" not in result
assert "Test Shoe" in result
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_no_markers(db):
"""HTML without markers is returned unchanged."""
html = "<p>No markers here.</p>"
result = await bake_product_cards(html, lang="de")
assert result == html
@pytest.mark.usefixtures("db")
async def test_bake_product_cards_draft_not_shown(db):
"""Draft products are not baked into articles."""
await _insert_product(slug="draft-product", name="Draft Product", status="draft")
html = "[product:draft-product]"
result = await bake_product_cards(html, lang="de")
assert "Draft Product" not in result
# ── regex patterns ─────────────────────────────────────────────────────────────
def test_product_re_matches():
"""PRODUCT_RE matches valid [product:slug] markers."""
assert PRODUCT_RE.match("[product:bullpadel-vertex-04-amazon]")
assert PRODUCT_RE.match("[product:test-123]")
def test_product_group_re_matches():
"""PRODUCT_GROUP_RE matches valid [product-group:category] markers."""
assert PRODUCT_GROUP_RE.match("[product-group:racket]")
assert PRODUCT_GROUP_RE.match("[product-group:shoe]")
# ── multi-retailer ────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_multi_retailer_same_slug_different_lang(db):
"""Same slug can exist in DE and EN with different affiliate URLs."""
await _insert_product(
slug="vertex-04", language="de",
affiliate_url="https://amazon.de/dp/TEST?tag=de-21",
)
await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url,
price_cents, currency, status, language, pros, cons, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
(
"vertex-04", "Test Racket EN", "TestBrand", "racket", "Amazon UK",
"https://amazon.co.uk/dp/TEST?tag=en-21",
14999, "active", "en", "[]", "[]", 0,
),
)
de_product = await get_product("vertex-04", "de")
en_product = await get_product("vertex-04", "en")
assert de_product is not None
assert en_product is not None
assert de_product["affiliate_url"] != en_product["affiliate_url"]
assert "amazon.de" in de_product["affiliate_url"]
assert "amazon.co.uk" in en_product["affiliate_url"]
# ── click redirect (e2e via Quart test client) ────────────────────────────────
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_302(app, db):
"""GET /go/<slug> redirects to affiliate_url with 302."""
await _insert_product(slug="redirect-test", affiliate_url="https://amazon.de/dp/XYZ?tag=test-21")
async with app.test_client() as client:
response = await client.get("/go/redirect-test")
assert response.status_code == 302
assert "amazon.de" in response.headers.get("Location", "")
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_logs_click(app, db):
"""Successful redirect logs a click in affiliate_clicks."""
pid = await _insert_product(slug="logged-test", affiliate_url="https://amazon.de/dp/LOG?tag=test-21")
async with app.test_client() as client:
await client.get(
"/go/logged-test",
headers={"Referer": "https://padelnomics.io/de/beste-padelschlaeger-2026"},
)
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (pid,))
assert len(rows) == 1
assert rows[0]["article_slug"] == "beste-padelschlaeger-2026"
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_inactive_404(app, db):
"""Draft products return 404 on /go/<slug>."""
await _insert_product(slug="inactive-test", status="draft")
async with app.test_client() as client:
response = await client.get("/go/inactive-test")
assert response.status_code == 404
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_unknown_404(app, db):
"""Unknown slug returns 404."""
async with app.test_client() as client:
response = await client.get("/go/totally-unknown-xyz")
assert response.status_code == 404