diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 6c02b3d..5a90230 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -3257,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: 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' %}