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