diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index e29eb8b..6f276a0 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2499,7 +2499,7 @@ 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 +2523,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 +2562,7 @@ 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 +2592,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 +2737,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 +2762,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) diff --git a/web/src/padelnomics/content/__init__.py b/web/src/padelnomics/content/__init__.py index faadcae..2c16f8c 100644 --- a/web/src/padelnomics/content/__init__.py +++ b/web/src/padelnomics/content/__init__.py @@ -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, diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index 5b84376..48bb02b 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -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 # ============================================================================= diff --git a/web/src/padelnomics/content/templates/partials/product_card.html b/web/src/padelnomics/content/templates/partials/product_card.html new file mode 100644 index 0000000..7ad0c67 --- /dev/null +++ b/web/src/padelnomics/content/templates/partials/product_card.html @@ -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 -%} +
+
+ + {# ── Image ── #} +
+ {% if product.image_url %} + {{ product.name }} + {% else %} + + {% endif %} +
+ + {# ── Content ── #} +
+ + {# Brand + retailer #} +
+ {% if product.brand %} + {{ product.brand }} + {% endif %} + {% if product.retailer %} + {{ t.affiliate_at_retailer | tformat(retailer=product.retailer) }} + {% endif %} +
+ + {# Name #} +

{{ product.name }}

+ + {# Rating #} + {% if product.rating %} + {%- set stars_full = product.rating | int -%} + {%- set has_half = (product.rating - stars_full) >= 0.5 -%} +
+ + {%- for i in range(stars_full) %}★{% endfor -%} + {%- if has_half %}★{% endif -%} + {%- for i in range(5 - stars_full - (1 if has_half else 0)) %}{% endfor -%} + + {{ "%.1f" | format(product.rating) }} +
+ {% endif %} + + {# Price #} + {% if price_eur %} +
{{ "%.2f" | format(price_eur) | replace('.', ',') }} €
+ {% endif %} + + {# Description #} + {% if product.description %} +

{{ product.description }}

+ {% endif %} + + {# Pros #} + {% if product.pros %} +
    + {% for pro in product.pros %} +
  • {{ pro }}
  • + {% endfor %} +
+ {% endif %} + + {# Cons #} + {% if product.cons %} +
    + {% for con in product.cons %} +
  • {{ con }}
  • + {% endfor %} +
+ {% endif %} + + {# CTA #} + + {{ cta }} → + + + {# Disclosure #} +

{{ t.affiliate_disclosure }}

+ +
+
+
diff --git a/web/src/padelnomics/content/templates/partials/product_group.html b/web/src/padelnomics/content/templates/partials/product_group.html new file mode 100644 index 0000000..85ea210 --- /dev/null +++ b/web/src/padelnomics/content/templates/partials/product_group.html @@ -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 %} +
+ + {# Section header #} +
+ {{ t.affiliate_our_picks }} · {{ category | capitalize }} +
+ + {# Responsive grid of compact cards #} +
+ {% 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 -%} +
+ + {# Image #} +
+ {% if product.image_url %} + {{ product.name }} + {% else %} + + {% endif %} +
+ + {# Brand #} + {% if product.brand %} + {{ product.brand }} + {% endif %} + + {# Name #} +

{{ product.name }}

+ + {# Rating + pros/cons counts #} +
+ {% if product.rating %} + + {{ "%.1f" | format(product.rating) }} + {% endif %} + {% if product.pros %} + {{ product.pros | length }} {{ t.affiliate_pros_label }} + {% endif %} +
+ + {# Price #} + {% if price_eur %} +
{{ "%.2f" | format(price_eur) | replace('.', ',') }} €
+ {% endif %} + + {# CTA — pushed to bottom via margin-top:auto #} + + {{ cta }} → + + +
+ {% endfor %} +
+ + {# Shared disclosure #} +

{{ t.affiliate_disclosure }}

+ +
+{% endif %}