feat(affiliate): admin CRUD — routes, list/form templates, sidebar entry
Routes: GET/POST affiliate, affiliate/results (HTMX), affiliate/new, affiliate/<id>/edit, affiliate/<id>/delete, affiliate/<id>/toggle. Templates: affiliate_products.html (filterable list), affiliate_form.html (two-column with live preview slot), partials/affiliate_row.html, partials/affiliate_results.html. Affiliate added to base_admin.html sidebar and subnav (Products | Dashboard). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3236,3 +3236,330 @@ async def outreach_import():
|
|||||||
|
|
||||||
await flash(f"Imported {imported} suppliers. Skipped {skipped} (duplicates or missing data).", "success")
|
await flash(f"Imported {imported} suppliers. Skipped {skipped} (duplicates or missing data).", "success")
|
||||||
return redirect(url_for("admin.outreach"))
|
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/<int:product_id>/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/<int:product_id>/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/<int:product_id>/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,
|
||||||
|
)
|
||||||
|
|||||||
216
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
216
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
@@ -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 %}
|
||||||
|
<script>
|
||||||
|
function slugify(text) {
|
||||||
|
return text.toLowerCase()
|
||||||
|
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var nameInput = document.getElementById('f-name');
|
||||||
|
var slugInput = document.getElementById('f-slug');
|
||||||
|
if (nameInput && slugInput && !slugInput.value) {
|
||||||
|
nameInput.addEventListener('input', function() {
|
||||||
|
if (!slugInput.dataset.manual) {
|
||||||
|
slugInput.value = slugify(nameInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
slugInput.addEventListener('input', function() {
|
||||||
|
slugInput.dataset.manual = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.affiliate_products') }}" class="text-slate text-sm" style="text-decoration:none">← Products</a>
|
||||||
|
<h1 class="text-2xl mt-1">{% if editing %}Edit Product{% else %}New Product{% endif %}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 380px;gap:2rem;align-items:start" class="affiliate-form-grid">
|
||||||
|
|
||||||
|
{# ── Left: form ── #}
|
||||||
|
<form method="post" id="affiliate-form"
|
||||||
|
hx-post="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}"
|
||||||
|
hx-target="#product-preview"
|
||||||
|
hx-trigger="input delay:600ms"
|
||||||
|
hx-push-url="false">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
|
||||||
|
|
||||||
|
{# Name #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-name">Name *</label>
|
||||||
|
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
|
||||||
|
class="form-input" placeholder="e.g. Bullpadel Vertex 04" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Slug #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-slug">Slug *</label>
|
||||||
|
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
|
||||||
|
class="form-input" placeholder="e.g. bullpadel-vertex-04-amazon" required
|
||||||
|
pattern="[a-z0-9][a-z0-9\-]*">
|
||||||
|
<p class="form-hint">Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. <code>-amazon</code>, <code>-padelnuestro</code>).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Brand + Category row #}
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-brand">Brand</label>
|
||||||
|
<input id="f-brand" type="text" name="brand" value="{{ data.get('brand','') }}"
|
||||||
|
class="form-input" placeholder="e.g. Bullpadel">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-category">Category</label>
|
||||||
|
<select id="f-category" name="category" class="form-input">
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if data.get('category','accessory') == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Retailer #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-retailer">Retailer</label>
|
||||||
|
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
|
||||||
|
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
||||||
|
list="retailers-list">
|
||||||
|
<datalist id="retailers-list">
|
||||||
|
{% for r in retailers %}
|
||||||
|
<option value="{{ r }}">
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Affiliate URL #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-url">Affiliate URL *</label>
|
||||||
|
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
|
||||||
|
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21" required>
|
||||||
|
<p class="form-hint">Full URL with tracking params already baked in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Image URL #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-image">Image URL</label>
|
||||||
|
<input id="f-image" type="text" name="image_url" value="{{ data.get('image_url','') }}"
|
||||||
|
class="form-input" placeholder="/static/images/affiliate/bullpadel-vertex-04.webp">
|
||||||
|
<p class="form-hint">Local path (recommended) or external URL.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Price + Rating row #}
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-price">Price (EUR)</label>
|
||||||
|
<input id="f-price" type="number" name="price_eur" value="{{ data.get('price_eur','') }}"
|
||||||
|
class="form-input" placeholder="149.99" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-rating">Rating (0–5)</label>
|
||||||
|
<input id="f-rating" type="number" name="rating" value="{{ data.get('rating','') }}"
|
||||||
|
class="form-input" placeholder="4.3" step="0.1" min="0" max="5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Description #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-desc">Short Description</label>
|
||||||
|
<textarea id="f-desc" name="description" rows="3"
|
||||||
|
class="form-input" placeholder="One to two sentences describing the product...">{{ data.get('description','') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pros #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-pros">Pros <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||||
|
<textarea id="f-pros" name="pros" rows="4"
|
||||||
|
class="form-input" placeholder="Carbon frame for maximum power Diamond shape for aggressive players">{{ data.get('pros_text', data.get('pros','')) }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Cons #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-cons">Cons <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||||
|
<textarea id="f-cons" name="cons" rows="3"
|
||||||
|
class="form-input" placeholder="Only for advanced players">{{ data.get('cons_text', data.get('cons','')) }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# CTA Label #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-cta">CTA Label</label>
|
||||||
|
<input id="f-cta" type="text" name="cta_label" value="{{ data.get('cta_label','') }}"
|
||||||
|
class="form-input" placeholder='Leave empty for default "Zum Angebot"'>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Status + Language + Sort #}
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-status">Status</label>
|
||||||
|
<select id="f-status" name="status" class="form-input">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if data.get('status','draft') == s %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-lang">Language</label>
|
||||||
|
<select id="f-lang" name="language" class="form-input">
|
||||||
|
<option value="de" {% if data.get('language','de') == 'de' %}selected{% endif %}>DE</option>
|
||||||
|
<option value="en" {% if data.get('language','de') == 'en' %}selected{% endif %}>EN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-sort">Sort Order</label>
|
||||||
|
<input id="f-sort" type="number" name="sort_order" value="{{ data.get('sort_order', 0) }}"
|
||||||
|
class="form-input" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Actions #}
|
||||||
|
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn" formaction="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
|
||||||
|
{% if editing %}Save Changes{% else %}Create Product{% endif %}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{% if editing %}
|
||||||
|
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline"
|
||||||
|
onclick="return confirm('Delete this product? This cannot be undone.')">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Right: live preview ── #}
|
||||||
|
<div style="position:sticky;top:1.5rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate mb-2" style="text-transform:uppercase;letter-spacing:.06em;">Preview</div>
|
||||||
|
<div id="product-preview" style="border:1px solid #E2E8F0;border-radius:12px;padding:1rem;background:#F8FAFC;min-height:180px;">
|
||||||
|
<p class="text-slate text-sm" style="text-align:center;margin-top:2rem;">
|
||||||
|
Fill in the form to see a live preview.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.affiliate-form-grid { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl">Affiliate Products</h1>
|
||||||
|
<a href="{{ url_for('admin.affiliate_new') }}" class="btn btn-sm">+ New Product</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{# Filters #}
|
||||||
|
<div class="card mb-6" style="padding:1rem 1.25rem">
|
||||||
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
|
hx-get="{{ url_for('admin.affiliate_results') }}"
|
||||||
|
hx-target="#aff-results"
|
||||||
|
hx-trigger="change, input delay:300ms"
|
||||||
|
hx-indicator="#aff-loading">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Name or brand..."
|
||||||
|
class="form-input" style="min-width:200px">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Category</label>
|
||||||
|
<select name="category" class="form-input" style="min-width:120px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if cat == category %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Retailer</label>
|
||||||
|
<select name="retailer" class="form-input" style="min-width:140px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for r in retailers %}
|
||||||
|
<option value="{{ r }}" {% if r == retailer_filter %}selected{% endif %}>{{ r }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||||
|
<select name="status" class="form-input" style="min-width:110px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if s == status_filter %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg id="aff-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Results #}
|
||||||
|
<div id="aff-results">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Brand</th>
|
||||||
|
<th>Retailer</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-right">Clicks</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% include "admin/partials/affiliate_results.html" %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -99,6 +99,7 @@
|
|||||||
'suppliers': 'suppliers',
|
'suppliers': 'suppliers',
|
||||||
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
||||||
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
||||||
|
'affiliate': 'affiliate',
|
||||||
'billing': 'billing',
|
'billing': 'billing',
|
||||||
'seo': 'analytics',
|
'seo': 'analytics',
|
||||||
'pipeline': 'pipeline',
|
'pipeline': 'pipeline',
|
||||||
@@ -149,6 +150,11 @@
|
|||||||
Billing
|
Billing
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016 2.993 2.993 0 0 0 2.25-1.016 3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
|
||||||
|
Affiliate
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('admin.seo') }}" class="{% if active_section == 'analytics' %}active{% endif %}">
|
<a href="{{ url_for('admin.seo') }}" class="{% if active_section == 'analytics' %}active{% endif %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||||
Analytics
|
Analytics
|
||||||
@@ -196,6 +202,11 @@
|
|||||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">Audiences</a>
|
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">Audiences</a>
|
||||||
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">Outreach</a>
|
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">Outreach</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
{% elif active_section == 'affiliate' %}
|
||||||
|
<nav class="admin-subnav">
|
||||||
|
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
|
||||||
|
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
|
||||||
|
</nav>
|
||||||
{% elif active_section == 'system' %}
|
{% elif active_section == 'system' %}
|
||||||
<nav class="admin-subnav">
|
<nav class="admin-subnav">
|
||||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">Users</a>
|
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">Users</a>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% if products %}
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "admin/partials/affiliate_row.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-slate" style="text-align:center;padding:2rem;">No products found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<tr id="aff-{{ product.id }}">
|
||||||
|
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ product.name }}">
|
||||||
|
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" style="color:#0F172A;text-decoration:none;font-weight:500;">{{ product.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="text-slate">{{ product.brand or '—' }}</td>
|
||||||
|
<td class="text-slate">{{ product.retailer or '—' }}</td>
|
||||||
|
<td class="text-slate">{{ product.category }}</td>
|
||||||
|
<td class="mono">
|
||||||
|
{% if product.price_cents %}{{ "%.0f" | format(product.price_cents / 100) }}€{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button hx-post="{{ url_for('admin.affiliate_toggle', product_id=product.id) }}"
|
||||||
|
hx-target="#aff-{{ product.id }}" hx-swap="outerHTML"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||||
|
class="badge {% if product.status == 'active' %}badge-success{% elif product.status == 'draft' %}badge-warning{% else %}badge{% endif %}"
|
||||||
|
style="cursor:pointer;border:none;">
|
||||||
|
{{ product.status }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="mono text-right">{{ product.click_count or 0 }}</td>
|
||||||
|
<td class="text-right" style="white-space:nowrap">
|
||||||
|
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline btn-sm"
|
||||||
|
onclick="return confirm('Delete {{ product.name }}?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
Reference in New Issue
Block a user