feat(affiliate): product form uses program dropdown + product identifier

Replaces the manual affiliate URL field with a program selector and
product identifier input. JS toggles visibility between program mode and
manual (custom URL) mode. retailer field is auto-populated from the
program name on save. INSERT/UPDATE statements include new program_id
and product_identifier columns. Validation accepts program+ID or manual
URL as the URL source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-28 22:43:10 +01:00
parent 53117094ee
commit 47acf4d3df
2 changed files with 101 additions and 26 deletions

View File

@@ -3486,13 +3486,26 @@ def _form_to_product(form) -> dict:
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()]) pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()]) cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
# Program-based URL vs manual URL.
# When a program is selected, product_identifier holds the ASIN/path;
# affiliate_url is cleared. Manual mode is the reverse.
program_id_str = form.get("program_id", "").strip()
program_id = int(program_id_str) if program_id_str and program_id_str != "0" else None
product_identifier = form.get("product_identifier", "").strip()
affiliate_url = form.get("affiliate_url", "").strip()
# retailer is auto-populated from program name on save (kept for display/filter)
retailer = form.get("retailer", "").strip()
return { return {
"slug": form.get("slug", "").strip(), "slug": form.get("slug", "").strip(),
"name": form.get("name", "").strip(), "name": form.get("name", "").strip(),
"brand": form.get("brand", "").strip(), "brand": form.get("brand", "").strip(),
"category": form.get("category", "accessory").strip(), "category": form.get("category", "accessory").strip(),
"retailer": form.get("retailer", "").strip(), "retailer": retailer,
"affiliate_url": form.get("affiliate_url", "").strip(), "program_id": program_id,
"product_identifier": product_identifier,
"affiliate_url": affiliate_url,
"image_url": form.get("image_url", "").strip(), "image_url": form.get("image_url", "").strip(),
"price_cents": price_cents, "price_cents": price_cents,
"currency": "EUR", "currency": "EUR",
@@ -3610,14 +3623,15 @@ async def affiliate_preview():
@csrf_protect @csrf_protect
async def affiliate_new(): async def affiliate_new():
"""Create an affiliate product.""" """Create an affiliate product."""
from ..affiliate import get_distinct_retailers from ..affiliate import get_all_programs, get_distinct_retailers
if request.method == "POST": if request.method == "POST":
form = await request.form form = await request.form
data = _form_to_product(form) data = _form_to_product(form)
if not data["slug"] or not data["name"] or not data["affiliate_url"]: has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
await flash("Slug, name, and affiliate URL are required.", "error") if not data["slug"] or not data["name"] or not has_url:
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
return await render_template( return await render_template(
"admin/affiliate_form.html", "admin/affiliate_form.html",
admin_page="affiliate", admin_page="affiliate",
@@ -3626,6 +3640,7 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES, categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES, statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(), retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
) )
existing = await fetch_one( existing = await fetch_one(
@@ -3642,17 +3657,27 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES, categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES, statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(), retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
) )
# Auto-populate retailer from program name if not manually set
if data["program_id"] and not data["retailer"]:
prog = await fetch_one(
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
)
if prog:
data["retailer"] = prog["name"]
await execute( await execute(
"""INSERT INTO affiliate_products """INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url, image_url, (slug, name, brand, category, retailer, program_id, product_identifier,
price_cents, currency, rating, pros, cons, description, cta_label, affiliate_url, image_url, price_cents, currency, rating, pros, cons,
status, language, sort_order) description, cta_label, status, language, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
data["slug"], data["name"], data["brand"], data["category"], data["slug"], data["name"], data["brand"], data["category"],
data["retailer"], data["affiliate_url"], data["image_url"], data["retailer"], data["program_id"], data["product_identifier"],
data["affiliate_url"], data["image_url"],
data["price_cents"], data["currency"], data["rating"], data["price_cents"], data["currency"], data["rating"],
data["pros"], data["cons"], data["description"], data["cta_label"], data["pros"], data["cons"], data["description"], data["cta_label"],
data["status"], data["language"], data["sort_order"], data["status"], data["language"], data["sort_order"],
@@ -3669,6 +3694,7 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES, categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES, statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(), retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
) )
@@ -3677,7 +3703,7 @@ async def affiliate_new():
@csrf_protect @csrf_protect
async def affiliate_edit(product_id: int): async def affiliate_edit(product_id: int):
"""Edit an affiliate product.""" """Edit an affiliate product."""
from ..affiliate import get_distinct_retailers from ..affiliate import get_all_programs, get_distinct_retailers
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,)) product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
if not product: if not product:
@@ -3688,8 +3714,9 @@ async def affiliate_edit(product_id: int):
form = await request.form form = await request.form
data = _form_to_product(form) data = _form_to_product(form)
if not data["slug"] or not data["name"] or not data["affiliate_url"]: has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
await flash("Slug, name, and affiliate URL are required.", "error") if not data["slug"] or not data["name"] or not has_url:
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
return await render_template( return await render_template(
"admin/affiliate_form.html", "admin/affiliate_form.html",
admin_page="affiliate", admin_page="affiliate",
@@ -3699,6 +3726,7 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES, categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES, statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(), retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
) )
# Check slug collision only if slug or language changed # Check slug collision only if slug or language changed
@@ -3718,18 +3746,29 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES, categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES, statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(), retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
) )
# Auto-populate retailer from program name if not manually set
if data["program_id"] and not data["retailer"]:
prog = await fetch_one(
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
)
if prog:
data["retailer"] = prog["name"]
await execute( await execute(
"""UPDATE affiliate_products """UPDATE affiliate_products
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?, SET slug=?, name=?, brand=?, category=?, retailer=?, program_id=?,
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?, product_identifier=?, affiliate_url=?, image_url=?,
price_cents=?, currency=?, rating=?, pros=?, cons=?,
description=?, cta_label=?, status=?, language=?, sort_order=?, description=?, cta_label=?, status=?, language=?, sort_order=?,
updated_at=datetime('now') updated_at=datetime('now')
WHERE id=?""", WHERE id=?""",
( (
data["slug"], data["name"], data["brand"], data["category"], data["slug"], data["name"], data["brand"], data["category"],
data["retailer"], data["affiliate_url"], data["image_url"], data["retailer"], data["program_id"], data["product_identifier"],
data["affiliate_url"], data["image_url"],
data["price_cents"], data["currency"], data["rating"], data["price_cents"], data["currency"], data["rating"],
data["pros"], data["cons"], data["description"], data["cta_label"], data["pros"], data["cons"], data["description"], data["cta_label"],
data["status"], data["language"], data["sort_order"], data["status"], data["language"], data["sort_order"],
@@ -3761,6 +3800,7 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES, categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES, statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(), retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
) )

View File

@@ -24,6 +24,20 @@ document.addEventListener('DOMContentLoaded', function() {
slugInput.dataset.manual = '1'; slugInput.dataset.manual = '1';
}); });
} }
// Toggle program-based vs manual URL fields
function toggleProgramFields() {
var sel = document.getElementById('f-program');
if (!sel) return;
var isManual = sel.value === '0' || sel.value === '';
document.getElementById('f-product-id-row').style.display = isManual ? 'none' : '';
document.getElementById('f-manual-url-row').style.display = isManual ? '' : 'none';
}
var programSel = document.getElementById('f-program');
if (programSel) {
programSel.addEventListener('change', toggleProgramFields);
toggleProgramFields();
}
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -87,9 +101,38 @@ document.addEventListener('DOMContentLoaded', function() {
</div> </div>
</div> </div>
{# Retailer #} {# Program dropdown #}
<div> <div>
<label class="form-label" for="f-retailer">Retailer</label> <label class="form-label" for="f-program">Affiliate Program</label>
<select id="f-program" name="program_id" class="form-input">
<option value="0" {% if not data.get('program_id') %}selected{% endif %}>Manual (custom URL)</option>
{% for prog in programs %}
<option value="{{ prog.id }}" {% if data.get('program_id') == prog.id %}selected{% endif %}>{{ prog.name }}</option>
{% endfor %}
</select>
<p class="form-hint">Select a program to auto-build the URL, or choose Manual for a custom link.</p>
</div>
{# Product Identifier (shown when program selected) #}
<div id="f-product-id-row">
<label class="form-label" for="f-product-id">Product ID *</label>
<input id="f-product-id" type="text" name="product_identifier"
value="{{ data.get('product_identifier','') }}"
class="form-input" placeholder="e.g. B0XXXXXXXXX (ASIN for Amazon)">
<p class="form-hint">ASIN, product path, or other program-specific identifier. URL is assembled at redirect time.</p>
</div>
{# Manual URL (shown when Manual selected) #}
<div id="f-manual-url-row">
<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">
<p class="form-hint">Full URL with tracking params already baked in. Used as fallback if no program is set.</p>
</div>
{# Retailer (auto-populated from program; editable for manual products) #}
<div>
<label class="form-label" for="f-retailer">Retailer <span class="form-hint" style="font-weight:normal">(auto-filled from program)</span></label>
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}" <input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
class="form-input" placeholder="e.g. Amazon, Padel Nuestro" class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
list="retailers-list"> list="retailers-list">
@@ -100,14 +143,6 @@ document.addEventListener('DOMContentLoaded', function() {
</datalist> </datalist>
</div> </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 #} {# Image URL #}
<div> <div>
<label class="form-label" for="f-image">Image URL</label> <label class="form-label" for="f-image">Image URL</label>