diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 6f276a0..fc35e7d 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -3236,3 +3236,330 @@ 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([l.strip() for l in pros_raw.splitlines() if l.strip()]) + cons = json.dumps([l.strip() for l in cons_raw.splitlines() if l.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//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//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//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, + ) diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_form.html new file mode 100644 index 0000000..35302c6 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_form.html @@ -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 %} + +{% endblock %} + +{% block admin_content %} +
+
+ ← Products +

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

+
+
+ +
+ + {# ── Left: form ── #} +
+ + +
+ + {# Name #} +
+ + +
+ + {# Slug #} +
+ + +

Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. -amazon, -padelnuestro).

+
+ + {# Brand + Category row #} +
+
+ + +
+
+ + +
+
+ + {# Retailer #} +
+ + + + {% for r in retailers %} + +
+ + {# Affiliate URL #} +
+ + +

Full URL with tracking params already baked in.

+
+ + {# Image URL #} +
+ + +

Local path (recommended) or external URL.

+
+ + {# Price + Rating row #} +
+
+ + +
+
+ + +
+
+ + {# Description #} +
+ + +
+ + {# Pros #} +
+ + +
+ + {# Cons #} +
+ + +
+ + {# CTA Label #} +
+ + +
+ + {# Status + Language + Sort #} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {# Actions #} +
+
+ + Cancel +
+ {% if editing %} + + + + + {% endif %} +
+ +
+ + + {# ── Right: live preview ── #} +
+
Preview
+
+

+ Fill in the form to see a live preview. +

+
+
+ +
+ + +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_products.html b/web/src/padelnomics/admin/templates/admin/affiliate_products.html new file mode 100644 index 0000000..925390f --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_products.html @@ -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 %} +
+

Affiliate Products

+ + New Product +
+ + {# Filters #} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + {# Results #} +
+ + + + + + + + + + + + + + + {% include "admin/partials/affiliate_results.html" %} + +
NameBrandRetailerCategoryPriceStatusClicksActions
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index def2822..fde5947 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -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 + + + Affiliate + + Analytics @@ -196,6 +202,11 @@ Audiences Outreach + {% elif active_section == 'affiliate' %} + {% elif active_section == 'system' %}