feat(affiliate): product card baking — PRODUCT_RE, bake_product_cards(), templates

Adds [product:slug] and [product-group:category] marker replacement.
Templates: product_card.html (horizontal editorial callout) and
product_group.html (responsive comparison grid). Chained after
bake_scenario_cards() in generate_articles(), preview_article(),
article_new(), article_edit(), and _rebuild_article().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-28 18:40:27 +01:00
parent b5db9d16b9
commit 4d45b99cd8
5 changed files with 217 additions and 6 deletions

View File

@@ -2499,7 +2499,7 @@ async def article_results():
@csrf_protect @csrf_protect
async def article_new(): async def article_new():
"""Create a manual article.""" """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": if request.method == "POST":
form = await request.form form = await request.form
@@ -2523,9 +2523,10 @@ async def article_new():
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") 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) 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 = mistune.html(body)
body_html = await bake_scenario_cards(body_html) 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 = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True) build_dir.mkdir(parents=True, exist_ok=True)
@@ -2561,7 +2562,7 @@ async def article_new():
@csrf_protect @csrf_protect
async def article_edit(article_id: int): async def article_edit(article_id: int):
"""Edit a manual article.""" """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,)) article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article: if not article:
@@ -2591,6 +2592,7 @@ async def article_edit(article_id: int):
if body: if body:
body_html = mistune.html(body) body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html) 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 = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True) build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article['slug']}.html").write_text(body_html) (build_dir / f"{article['slug']}.html").write_text(body_html)
@@ -2735,7 +2737,7 @@ async def rebuild_all():
async def _rebuild_article(article_id: int): async def _rebuild_article(article_id: int):
"""Re-render a single article from its source.""" """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,)) article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article: if not article:
@@ -2760,6 +2762,7 @@ async def _rebuild_article(article_id: int):
body_html = mistune.html(md_path.read_text()) body_html = mistune.html(md_path.read_text())
lang = article.get("language", "en") if hasattr(article, "get") else "en" 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_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.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html) (BUILD_DIR / f"{article['slug']}.html").write_text(body_html)

View File

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

View File

@@ -27,6 +27,8 @@ RESERVED_PREFIXES = (
) )
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]') 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 = { SECTION_TEMPLATES = {
None: "partials/scenario_summary.html", None: "partials/scenario_summary.html",
@@ -112,6 +114,53 @@ async def bake_scenario_cards(
return html 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 # 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 %}