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()])
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 {
"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(),
"retailer": retailer,
"program_id": program_id,
"product_identifier": product_identifier,
"affiliate_url": affiliate_url,
"image_url": form.get("image_url", "").strip(),
"price_cents": price_cents,
"currency": "EUR",
@@ -3610,14 +3623,15 @@ async def affiliate_preview():
@csrf_protect
async def affiliate_new():
"""Create an affiliate product."""
from ..affiliate import get_distinct_retailers
from ..affiliate import get_all_programs, 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")
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
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(
"admin/affiliate_form.html",
admin_page="affiliate",
@@ -3626,6 +3640,7 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
existing = await fetch_one(
@@ -3642,17 +3657,27 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
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(
"""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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(slug, name, brand, category, retailer, program_id, product_identifier,
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["retailer"], data["program_id"], data["product_identifier"],
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"],
@@ -3669,6 +3694,7 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
@@ -3677,7 +3703,7 @@ async def affiliate_new():
@csrf_protect
async def affiliate_edit(product_id: int):
"""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,))
if not product:
@@ -3688,8 +3714,9 @@ async def affiliate_edit(product_id: int):
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")
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
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(
"admin/affiliate_form.html",
admin_page="affiliate",
@@ -3699,6 +3726,7 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
# Check slug collision only if slug or language changed
@@ -3718,18 +3746,29 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
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(
"""UPDATE affiliate_products
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?,
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?,
SET slug=?, name=?, brand=?, category=?, retailer=?, program_id=?,
product_identifier=?, 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["retailer"], data["program_id"], data["product_identifier"],
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"],
@@ -3761,6 +3800,7 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
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';
});
}
// 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>
{% endblock %}
@@ -87,9 +101,38 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
{# Retailer #}
{# Program dropdown #}
<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','') }}"
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
list="retailers-list">
@@ -100,14 +143,6 @@ document.addEventListener('DOMContentLoaded', function() {
</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>