diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d538f0..b7bcf4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Affiliate programs management** — centralised retailer config (`affiliate_programs` table) with URL template + tracking tag + commission %. Products now use a program dropdown + product identifier (e.g. ASIN) instead of manually baking full URLs. URL is assembled at redirect time via `build_affiliate_url()`, so changing a tag propagates instantly to all products. Legacy products (baked `affiliate_url`) continue to work via fallback. Amazon OneLink configured in the Associates dashboard handles geo-redirect to local marketplaces — no per-country programs needed. + - `web/src/padelnomics/migrations/versions/0027_affiliate_programs.py`: `affiliate_programs` table, nullable `program_id` + `product_identifier` columns on `affiliate_products`, seeds "Amazon" program, backfills ASINs from existing URLs + - `web/src/padelnomics/affiliate.py`: `get_all_programs()`, `get_program()`, `get_program_by_slug()`, `build_affiliate_url()`; `get_product()` JOINs program for redirect assembly; `_parse_product()` extracts `_program` sub-dict + - `web/src/padelnomics/app.py`: `/go/` uses `build_affiliate_url()` — program-based products get URLs assembled at redirect time + - `web/src/padelnomics/admin/routes.py`: program CRUD routes (list, new, edit, delete — delete blocked if products reference the program); product form updated to program dropdown + identifier; `retailer` auto-populated from program name + - New templates: `admin/affiliate_programs.html`, `admin/affiliate_program_form.html`, `admin/partials/affiliate_program_results.html` + - Updated templates: `admin/affiliate_form.html` (program dropdown + JS toggle), `admin/base_admin.html` (Programs subnav tab) + - 15 new tests in `web/tests/test_affiliate.py` (41 total) + +### Fixed +- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews. + ### Added - **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded. - `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema diff --git a/PROJECT.md b/PROJECT.md index 9b1a226..f2be3ae 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,7 +1,7 @@ # Padelnomics — Project Tracker > Move tasks across columns as you work. Add new tasks at the top of the relevant column. -> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking). +> Last updated: 2026-02-28 (Affiliate programs management — centralised retailer config + URL template assembly). --- @@ -133,6 +133,7 @@ - [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row - [x] **Email-gated report PDF** — `reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/` - [x] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/` click-tracking redirect (302, GDPR daily-rotated IP hash, rate-limited); admin CRUD with live preview, HTMX filter/search, status toggle; click stats dashboard (pure CSS charts); 10 German equipment review article scaffolds; 26 tests +- [x] **Affiliate programs management** — `affiliate_programs` table centralises retailer configs (URL template, tracking tag, commission %); product form uses program dropdown + product identifier (ASIN etc.); `build_affiliate_url()` assembles at redirect time; legacy baked-URL products still work; admin CRUD (delete blocked if products reference program); Amazon OneLink for multi-marketplace; article frontmatter preview bug fixed; 41 tests ### SEO & Legal - [x] Sitemap (both language variants, `` on all entries) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 84ef4d4..4031c69 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2769,7 +2769,10 @@ async def _rebuild_article(article_id: int): md_path = Path("data/content/articles") / f"{article['slug']}.md" if not md_path.exists(): return - body_html = mistune.html(md_path.read_text()) + raw = md_path.read_text() + m = _FRONTMATTER_RE.match(raw) + body = raw[m.end():] if m else raw + body_html = mistune.html(body) 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) @@ -3254,6 +3257,210 @@ async def outreach_import(): AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory") AFFILIATE_STATUSES = ("draft", "active", "archived") +AFFILIATE_PROGRAM_STATUSES = ("active", "inactive") + + +# ── Affiliate Programs ──────────────────────────────────────────────────────── + +def _form_to_program(form) -> dict: + """Parse affiliate program form values into a data dict.""" + commission_str = form.get("commission_pct", "").strip() + commission_pct = 0.0 + if commission_str: + try: + commission_pct = float(commission_str.replace(",", ".")) + except ValueError: + commission_pct = 0.0 + + return { + "name": form.get("name", "").strip(), + "slug": form.get("slug", "").strip(), + "url_template": form.get("url_template", "").strip(), + "tracking_tag": form.get("tracking_tag", "").strip(), + "commission_pct": commission_pct, + "homepage_url": form.get("homepage_url", "").strip(), + "status": form.get("status", "active").strip(), + "notes": form.get("notes", "").strip(), + } + + +@bp.route("/affiliate/programs") +@role_required("admin") +async def affiliate_programs(): + """Affiliate programs list — full page.""" + from ..affiliate import get_all_programs + + programs = await get_all_programs() + return await render_template( + "admin/affiliate_programs.html", + admin_page="affiliate_programs", + programs=programs, + ) + + +@bp.route("/affiliate/programs/results") +@role_required("admin") +async def affiliate_program_results(): + """HTMX partial: program rows.""" + from ..affiliate import get_all_programs + + programs = await get_all_programs() + return await render_template( + "admin/partials/affiliate_program_results.html", + programs=programs, + ) + + +@bp.route("/affiliate/programs/new", methods=["GET", "POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_program_new(): + """Create an affiliate program.""" + if request.method == "POST": + form = await request.form + data = _form_to_program(form) + + if not data["name"] or not data["slug"] or not data["url_template"]: + await flash("Name, slug, and URL template are required.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data=data, + editing=False, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + existing = await fetch_one( + "SELECT id FROM affiliate_programs WHERE slug = ?", (data["slug"],) + ) + if existing: + await flash(f"Slug '{data['slug']}' already exists.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data=data, + editing=False, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + await execute( + """INSERT INTO affiliate_programs + (name, slug, url_template, tracking_tag, commission_pct, + homepage_url, status, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + data["name"], data["slug"], data["url_template"], + data["tracking_tag"], data["commission_pct"], + data["homepage_url"], data["status"], data["notes"], + ), + ) + await flash(f"Program '{data['name']}' created.", "success") + return redirect(url_for("admin.affiliate_programs")) + + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data={}, + editing=False, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + +@bp.route("/affiliate/programs//edit", methods=["GET", "POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_program_edit(program_id: int): + """Edit an affiliate program.""" + program = await fetch_one( + "SELECT * FROM affiliate_programs WHERE id = ?", (program_id,) + ) + if not program: + await flash("Program not found.", "error") + return redirect(url_for("admin.affiliate_programs")) + + if request.method == "POST": + form = await request.form + data = _form_to_program(form) + + if not data["name"] or not data["slug"] or not data["url_template"]: + await flash("Name, slug, and URL template are required.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data={**dict(program), **data}, + editing=True, + program_id=program_id, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + if data["slug"] != program["slug"]: + collision = await fetch_one( + "SELECT id FROM affiliate_programs WHERE slug = ? AND id != ?", + (data["slug"], program_id), + ) + if collision: + await flash(f"Slug '{data['slug']}' already exists.", "error") + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data={**dict(program), **data}, + editing=True, + program_id=program_id, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + await execute( + """UPDATE affiliate_programs + SET name=?, slug=?, url_template=?, tracking_tag=?, commission_pct=?, + homepage_url=?, status=?, notes=?, updated_at=datetime('now') + WHERE id=?""", + ( + data["name"], data["slug"], data["url_template"], + data["tracking_tag"], data["commission_pct"], + data["homepage_url"], data["status"], data["notes"], + program_id, + ), + ) + await flash(f"Program '{data['name']}' updated.", "success") + return redirect(url_for("admin.affiliate_programs")) + + return await render_template( + "admin/affiliate_program_form.html", + admin_page="affiliate_programs", + data=dict(program), + editing=True, + program_id=program_id, + program_statuses=AFFILIATE_PROGRAM_STATUSES, + ) + + +@bp.route("/affiliate/programs//delete", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_program_delete(program_id: int): + """Delete an affiliate program — blocked if products reference it.""" + program = await fetch_one( + "SELECT name FROM affiliate_programs WHERE id = ?", (program_id,) + ) + if not program: + return redirect(url_for("admin.affiliate_programs")) + + product_count = await fetch_one( + "SELECT COUNT(*) AS cnt FROM affiliate_products WHERE program_id = ?", + (program_id,), + ) + count = product_count["cnt"] if product_count else 0 + if count > 0: + await flash( + f"Cannot delete '{program['name']}' — {count} product(s) reference it. " + "Reassign or remove those products first.", + "error", + ) + return redirect(url_for("admin.affiliate_programs")) + + await execute("DELETE FROM affiliate_programs WHERE id = ?", (program_id,)) + await flash(f"Program '{program['name']}' deleted.", "success") + return redirect(url_for("admin.affiliate_programs")) def _form_to_product(form) -> dict: @@ -3279,13 +3486,26 @@ def _form_to_product(form) -> dict: 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()]) + # Program-based URL vs manual URL. + # When a program is selected, product_identifier holds the ASIN/path; + # affiliate_url is cleared. Manual mode is the reverse. + program_id_str = form.get("program_id", "").strip() + program_id = int(program_id_str) if program_id_str and program_id_str != "0" else None + product_identifier = form.get("product_identifier", "").strip() + affiliate_url = form.get("affiliate_url", "").strip() + + # retailer is auto-populated from program name on save (kept for display/filter) + retailer = form.get("retailer", "").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(), + "retailer": retailer, + "program_id": program_id, + "product_identifier": product_identifier, + "affiliate_url": affiliate_url, "image_url": form.get("image_url", "").strip(), "price_cents": price_cents, "currency": "EUR", @@ -3403,14 +3623,15 @@ async def affiliate_preview(): @csrf_protect async def affiliate_new(): """Create an affiliate product.""" - from ..affiliate import get_distinct_retailers + from ..affiliate import get_all_programs, 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") + has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"]) + if not data["slug"] or not data["name"] or not has_url: + await flash("Slug, name, and either a program+product ID or manual URL are required.", "error") return await render_template( "admin/affiliate_form.html", admin_page="affiliate", @@ -3419,6 +3640,7 @@ async def affiliate_new(): categories=AFFILIATE_CATEGORIES, statuses=AFFILIATE_STATUSES, retailers=await get_distinct_retailers(), + programs=await get_all_programs(status="active"), ) existing = await fetch_one( @@ -3435,17 +3657,27 @@ async def affiliate_new(): categories=AFFILIATE_CATEGORIES, statuses=AFFILIATE_STATUSES, retailers=await get_distinct_retailers(), + programs=await get_all_programs(status="active"), ) + # Auto-populate retailer from program name if not manually set + if data["program_id"] and not data["retailer"]: + prog = await fetch_one( + "SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],) + ) + if prog: + data["retailer"] = prog["name"] + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (slug, name, brand, category, retailer, program_id, product_identifier, + 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["retailer"], data["program_id"], data["product_identifier"], + 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"], @@ -3462,6 +3694,7 @@ async def affiliate_new(): categories=AFFILIATE_CATEGORIES, statuses=AFFILIATE_STATUSES, retailers=await get_distinct_retailers(), + programs=await get_all_programs(status="active"), ) @@ -3470,7 +3703,7 @@ async def affiliate_new(): @csrf_protect async def affiliate_edit(product_id: int): """Edit an affiliate product.""" - from ..affiliate import get_distinct_retailers + from ..affiliate import get_all_programs, get_distinct_retailers product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,)) if not product: @@ -3481,8 +3714,9 @@ async def affiliate_edit(product_id: int): 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") + has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"]) + if not data["slug"] or not data["name"] or not has_url: + await flash("Slug, name, and either a program+product ID or manual URL are required.", "error") return await render_template( "admin/affiliate_form.html", admin_page="affiliate", @@ -3492,6 +3726,7 @@ async def affiliate_edit(product_id: int): categories=AFFILIATE_CATEGORIES, statuses=AFFILIATE_STATUSES, retailers=await get_distinct_retailers(), + programs=await get_all_programs(status="active"), ) # Check slug collision only if slug or language changed @@ -3511,18 +3746,29 @@ async def affiliate_edit(product_id: int): categories=AFFILIATE_CATEGORIES, statuses=AFFILIATE_STATUSES, retailers=await get_distinct_retailers(), + programs=await get_all_programs(status="active"), ) + # Auto-populate retailer from program name if not manually set + if data["program_id"] and not data["retailer"]: + prog = await fetch_one( + "SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],) + ) + if prog: + data["retailer"] = prog["name"] + await execute( """UPDATE affiliate_products - SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?, - image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?, + SET slug=?, name=?, brand=?, category=?, retailer=?, program_id=?, + product_identifier=?, 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["retailer"], data["program_id"], data["product_identifier"], + 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"], @@ -3554,6 +3800,7 @@ async def affiliate_edit(product_id: int): categories=AFFILIATE_CATEGORIES, statuses=AFFILIATE_STATUSES, retailers=await get_distinct_retailers(), + programs=await get_all_programs(status="active"), ) diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_form.html index 42ea4bf..9acd571 100644 --- a/web/src/padelnomics/admin/templates/admin/affiliate_form.html +++ b/web/src/padelnomics/admin/templates/admin/affiliate_form.html @@ -24,6 +24,20 @@ document.addEventListener('DOMContentLoaded', function() { slugInput.dataset.manual = '1'; }); } + + // Toggle program-based vs manual URL fields + function toggleProgramFields() { + var sel = document.getElementById('f-program'); + if (!sel) return; + var isManual = sel.value === '0' || sel.value === ''; + document.getElementById('f-product-id-row').style.display = isManual ? 'none' : ''; + document.getElementById('f-manual-url-row').style.display = isManual ? '' : 'none'; + } + var programSel = document.getElementById('f-program'); + if (programSel) { + programSel.addEventListener('change', toggleProgramFields); + toggleProgramFields(); + } }); {% endblock %} @@ -87,9 +101,38 @@ document.addEventListener('DOMContentLoaded', function() { - {# Retailer #} + {# Program dropdown #}
- + + +

Select a program to auto-build the URL, or choose Manual for a custom link.

+
+ + {# Product Identifier (shown when program selected) #} +
+ + +

ASIN, product path, or other program-specific identifier. URL is assembled at redirect time.

+
+ + {# Manual URL (shown when Manual selected) #} +
+ + +

Full URL with tracking params already baked in. Used as fallback if no program is set.

+
+ + {# Retailer (auto-populated from program; editable for manual products) #} +
+ @@ -100,14 +143,6 @@ document.addEventListener('DOMContentLoaded', function() {
- {# Affiliate URL #} -
- - -

Full URL with tracking params already baked in.

-
- {# Image URL #}
diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html new file mode 100644 index 0000000..9c2949d --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html @@ -0,0 +1,134 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "affiliate_programs" %} + +{% block title %}{% if editing %}Edit Program{% else %}New Program{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+
+ ← Programs +

{% if editing %}Edit Program{% else %}New Program{% endif %}

+
+
+ +
+
+ + +
+ + {# Name #} +
+ + +
+ + {# Slug #} +
+ + +

Lowercase letters, numbers, hyphens only.

+
+ + {# URL Template #} +
+ + +

+ Use {product_id} for the ASIN/product path and {tag} for the tracking tag.
+ Example: https://www.amazon.de/dp/{product_id}?tag={tag} +

+
+ + {# Tracking Tag + Commission row #} +
+
+ + +
+
+ + +

Used for revenue estimates (e.g. 3 = 3%).

+
+
+ + {# Homepage URL #} +
+ + +

Shown as a link in the programs list.

+
+ + {# Status #} +
+ + +

Inactive programs are hidden from the product form dropdown.

+
+ + {# Notes #} +
+ + +
+ + {# Actions #} +
+
+ + Cancel +
+ {% if editing %} + + + + + {% endif %} +
+ +
+ +
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_programs.html b/web/src/padelnomics/admin/templates/admin/affiliate_programs.html new file mode 100644 index 0000000..6b91d7e --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_programs.html @@ -0,0 +1,30 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "affiliate_programs" %} + +{% block title %}Affiliate Programs - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_content %} +
+

Affiliate Programs

+ + New Program +
+ +
+ + + + + + + + + + + + + + {% include "admin/partials/affiliate_program_results.html" %} + +
NameSlugTracking TagCommissionProductsStatusActions
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 39d3a5f..8f8189c 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -99,7 +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', 'affiliate_dashboard': 'affiliate', + 'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', 'affiliate_programs': 'affiliate', 'billing': 'billing', 'seo': 'analytics', 'pipeline': 'pipeline', @@ -206,6 +206,7 @@ {% elif active_section == 'system' %}