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:
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user