feat(affiliate): product card baking — PRODUCT_RE, bake_product_cards(), templates
Adds [product:slug] and [product-group:category] marker replacement. Templates: product_card.html (horizontal editorial callout) and product_group.html (responsive comparison grid). Chained after bake_scenario_cards() in generate_articles(), preview_article(), article_new(), article_edit(), and _rebuild_article(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2499,7 +2499,7 @@ async def article_results():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def article_new():
|
async def article_new():
|
||||||
"""Create a manual article."""
|
"""Create a manual article."""
|
||||||
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
|
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -2523,9 +2523,10 @@ async def article_new():
|
|||||||
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||||
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
||||||
|
|
||||||
# Render markdown → HTML with scenario cards baked in
|
# Render markdown → HTML with scenario + product cards baked in
|
||||||
body_html = mistune.html(body)
|
body_html = mistune.html(body)
|
||||||
body_html = await bake_scenario_cards(body_html)
|
body_html = await bake_scenario_cards(body_html)
|
||||||
|
body_html = await bake_product_cards(body_html, lang=language)
|
||||||
|
|
||||||
build_dir = BUILD_DIR / language
|
build_dir = BUILD_DIR / language
|
||||||
build_dir.mkdir(parents=True, exist_ok=True)
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -2561,7 +2562,7 @@ async def article_new():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def article_edit(article_id: int):
|
async def article_edit(article_id: int):
|
||||||
"""Edit a manual article."""
|
"""Edit a manual article."""
|
||||||
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
|
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path
|
||||||
|
|
||||||
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||||
if not article:
|
if not article:
|
||||||
@@ -2591,6 +2592,7 @@ async def article_edit(article_id: int):
|
|||||||
if body:
|
if body:
|
||||||
body_html = mistune.html(body)
|
body_html = mistune.html(body)
|
||||||
body_html = await bake_scenario_cards(body_html)
|
body_html = await bake_scenario_cards(body_html)
|
||||||
|
body_html = await bake_product_cards(body_html, lang=language)
|
||||||
build_dir = BUILD_DIR / language
|
build_dir = BUILD_DIR / language
|
||||||
build_dir.mkdir(parents=True, exist_ok=True)
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(build_dir / f"{article['slug']}.html").write_text(body_html)
|
(build_dir / f"{article['slug']}.html").write_text(body_html)
|
||||||
@@ -2735,7 +2737,7 @@ async def rebuild_all():
|
|||||||
|
|
||||||
async def _rebuild_article(article_id: int):
|
async def _rebuild_article(article_id: int):
|
||||||
"""Re-render a single article from its source."""
|
"""Re-render a single article from its source."""
|
||||||
from ..content.routes import BUILD_DIR, bake_scenario_cards
|
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
|
||||||
|
|
||||||
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||||
if not article:
|
if not article:
|
||||||
@@ -2760,6 +2762,7 @@ async def _rebuild_article(article_id: int):
|
|||||||
body_html = mistune.html(md_path.read_text())
|
body_html = mistune.html(md_path.read_text())
|
||||||
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
||||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||||
|
body_html = await bake_product_cards(body_html, lang=lang)
|
||||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||||
|
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ async def generate_articles(
|
|||||||
"""
|
"""
|
||||||
from ..core import execute as db_execute
|
from ..core import execute as db_execute
|
||||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||||
from .routes import bake_scenario_cards, is_reserved_path
|
from .routes import bake_product_cards, bake_scenario_cards, is_reserved_path
|
||||||
|
|
||||||
assert articles_per_day > 0, "articles_per_day must be positive"
|
assert articles_per_day > 0, "articles_per_day must be positive"
|
||||||
|
|
||||||
@@ -443,6 +443,7 @@ async def generate_articles(
|
|||||||
body_html = await bake_scenario_cards(
|
body_html = await bake_scenario_cards(
|
||||||
body_html, lang=lang, scenario_overrides=scenario_overrides
|
body_html, lang=lang, scenario_overrides=scenario_overrides
|
||||||
)
|
)
|
||||||
|
body_html = await bake_product_cards(body_html, lang=lang)
|
||||||
t_bake += time.perf_counter() - t0
|
t_bake += time.perf_counter() - t0
|
||||||
|
|
||||||
# Extract FAQ pairs for structured data
|
# Extract FAQ pairs for structured data
|
||||||
@@ -584,7 +585,7 @@ async def preview_article(
|
|||||||
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
|
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
|
||||||
"""
|
"""
|
||||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||||
from .routes import bake_scenario_cards
|
from .routes import bake_product_cards, bake_scenario_cards
|
||||||
|
|
||||||
config = load_template(slug)
|
config = load_template(slug)
|
||||||
|
|
||||||
@@ -641,6 +642,7 @@ async def preview_article(
|
|||||||
body_html = await bake_scenario_cards(
|
body_html = await bake_scenario_cards(
|
||||||
body_html, lang=lang, scenario_overrides=scenario_overrides,
|
body_html, lang=lang, scenario_overrides=scenario_overrides,
|
||||||
)
|
)
|
||||||
|
body_html = await bake_product_cards(body_html, lang=lang)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ RESERVED_PREFIXES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
|
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
|
||||||
|
PRODUCT_RE = re.compile(r'\[product:([a-z0-9_-]+)\]')
|
||||||
|
PRODUCT_GROUP_RE = re.compile(r'\[product-group:([a-z0-9_-]+)\]')
|
||||||
|
|
||||||
SECTION_TEMPLATES = {
|
SECTION_TEMPLATES = {
|
||||||
None: "partials/scenario_summary.html",
|
None: "partials/scenario_summary.html",
|
||||||
@@ -112,6 +114,53 @@ async def bake_scenario_cards(
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
async def bake_product_cards(html: str, lang: str = "de") -> str:
|
||||||
|
"""Replace [product:slug] and [product-group:category] markers with rendered HTML.
|
||||||
|
|
||||||
|
Processes markers in two passes (product first, then groups) to keep logic
|
||||||
|
clear. Reverse iteration preserves string offsets when splicing.
|
||||||
|
"""
|
||||||
|
from ..affiliate import get_product, get_products_by_category
|
||||||
|
|
||||||
|
t = get_translations(lang)
|
||||||
|
|
||||||
|
# ── Pass 1: [product:slug] ────────────────────────────────────────────────
|
||||||
|
product_matches = list(PRODUCT_RE.finditer(html))
|
||||||
|
if product_matches:
|
||||||
|
slugs = list({m.group(1) for m in product_matches})
|
||||||
|
products: dict[str, dict | None] = {}
|
||||||
|
for slug in slugs:
|
||||||
|
products[slug] = await get_product(slug, lang)
|
||||||
|
|
||||||
|
tmpl = _bake_env.get_template("partials/product_card.html")
|
||||||
|
for match in reversed(product_matches):
|
||||||
|
slug = match.group(1)
|
||||||
|
product = products.get(slug)
|
||||||
|
if not product:
|
||||||
|
continue
|
||||||
|
card_html = tmpl.render(product=product, lang=lang, t=t)
|
||||||
|
html = html[:match.start()] + card_html + html[match.end():]
|
||||||
|
|
||||||
|
# ── Pass 2: [product-group:category] ─────────────────────────────────────
|
||||||
|
group_matches = list(PRODUCT_GROUP_RE.finditer(html))
|
||||||
|
if group_matches:
|
||||||
|
categories = list({m.group(1) for m in group_matches})
|
||||||
|
groups: dict[str, list] = {}
|
||||||
|
for cat in categories:
|
||||||
|
groups[cat] = await get_products_by_category(cat, lang)
|
||||||
|
|
||||||
|
tmpl = _bake_env.get_template("partials/product_group.html")
|
||||||
|
for match in reversed(group_matches):
|
||||||
|
cat = match.group(1)
|
||||||
|
group_products = groups.get(cat, [])
|
||||||
|
if not group_products:
|
||||||
|
continue
|
||||||
|
grid_html = tmpl.render(products=group_products, category=cat, lang=lang, t=t)
|
||||||
|
html = html[:match.start()] + grid_html + html[match.end():]
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Markets Hub
|
# Markets Hub
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{# Affiliate product card — editorial recommendation style.
|
||||||
|
Variables: product (dict with parsed pros/cons lists), t (translations), lang.
|
||||||
|
Rendered bake-time by bake_product_cards(); no request context available. #}
|
||||||
|
{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%}
|
||||||
|
{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%}
|
||||||
|
<div class="aff-card" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1.5rem;margin:1.5rem 0;box-shadow:0 1px 3px rgba(0,0,0,.05);transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow='0 1px 3px rgba(0,0,0,.05)'">
|
||||||
|
<div style="display:flex;gap:1.25rem;align-items:flex-start;flex-wrap:wrap;">
|
||||||
|
|
||||||
|
{# ── Image ── #}
|
||||||
|
<div style="width:160px;flex-shrink:0;aspect-ratio:1;border-radius:12px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||||
|
{% if product.image_url %}
|
||||||
|
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<svg width="48" height="48" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Content ── #}
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
|
||||||
|
{# Brand + retailer #}
|
||||||
|
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem;">
|
||||||
|
{% if product.brand %}
|
||||||
|
<span style="text-transform:uppercase;font-size:.6875rem;font-weight:600;letter-spacing:.06em;color:#64748B;">{{ product.brand }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if product.retailer %}
|
||||||
|
<span style="background:#F1F5F9;border-radius:999px;padding:2px 8px;font-size:.625rem;font-weight:600;color:#64748B;letter-spacing:.04em;text-transform:uppercase;">{{ t.affiliate_at_retailer | tformat(retailer=product.retailer) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Name #}
|
||||||
|
<h3 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1.125rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0 0 .375rem;">{{ product.name }}</h3>
|
||||||
|
|
||||||
|
{# Rating #}
|
||||||
|
{% if product.rating %}
|
||||||
|
{%- set stars_full = product.rating | int -%}
|
||||||
|
{%- set has_half = (product.rating - stars_full) >= 0.5 -%}
|
||||||
|
<div style="display:flex;align-items:center;gap:.25rem;margin-bottom:.375rem;">
|
||||||
|
<span style="color:#D97706;font-size:.9375rem;">
|
||||||
|
{%- for i in range(stars_full) %}★{% endfor -%}
|
||||||
|
{%- if has_half %}★{% endif -%}
|
||||||
|
{%- for i in range(5 - stars_full - (1 if has_half else 0)) %}<span style="color:#E2E8F0;">★</span>{% endfor -%}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:.8125rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Price #}
|
||||||
|
{% if price_eur %}
|
||||||
|
<div style="font-family:'Commit Mono',monospace;font-size:1.25rem;font-weight:700;color:#0F172A;margin-bottom:.5rem;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Description #}
|
||||||
|
{% if product.description %}
|
||||||
|
<p style="font-size:.875rem;color:#475569;line-height:1.55;margin:.625rem 0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">{{ product.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Pros #}
|
||||||
|
{% if product.pros %}
|
||||||
|
<ul style="list-style:none;padding:0;margin:.625rem 0 .25rem;">
|
||||||
|
{% for pro in product.pros %}
|
||||||
|
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#16A34A;margin-right:.25rem;">✓</span>{{ pro }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Cons #}
|
||||||
|
{% if product.cons %}
|
||||||
|
<ul style="list-style:none;padding:0;margin:.25rem 0 .75rem;">
|
||||||
|
{% for con in product.cons %}
|
||||||
|
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#EF4444;margin-right:.25rem;">✗</span>{{ con }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# CTA #}
|
||||||
|
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
|
||||||
|
style="display:block;width:100%;background:#1D4ED8;color:#fff;border-radius:12px;padding:.625rem 1.25rem;font-weight:600;font-size:.875rem;text-align:center;text-decoration:none;box-shadow:0 2px 10px rgba(29,78,216,.25);transition:background .2s,transform .2s;margin-top:.5rem;"
|
||||||
|
onmouseover="this.style.background='#1E40AF';this.style.transform='translateY(-1px)'"
|
||||||
|
onmouseout="this.style.background='#1D4ED8';this.style.transform=''">
|
||||||
|
{{ cta }} →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# Disclosure #}
|
||||||
|
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.5rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{# Affiliate product comparison grid — editorial picks layout.
|
||||||
|
Variables: products (list of dicts), category (str), t (translations), lang.
|
||||||
|
Rendered bake-time by bake_product_cards(). #}
|
||||||
|
{% if products %}
|
||||||
|
<div style="margin:2rem 0;">
|
||||||
|
|
||||||
|
{# Section header #}
|
||||||
|
<div style="text-transform:uppercase;font-size:.75rem;font-weight:600;color:#64748B;letter-spacing:.06em;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:2px solid #E2E8F0;">
|
||||||
|
{{ t.affiliate_our_picks }} · {{ category | capitalize }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Responsive grid of compact cards #}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;">
|
||||||
|
{% for product in products %}
|
||||||
|
{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%}
|
||||||
|
{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%}
|
||||||
|
<div class="aff-card-compact" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1rem;display:flex;flex-direction:column;gap:.5rem;transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow=''">
|
||||||
|
|
||||||
|
{# Image #}
|
||||||
|
<div style="aspect-ratio:1;border-radius:10px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||||
|
{% if product.image_url %}
|
||||||
|
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<svg width="36" height="36" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Brand #}
|
||||||
|
{% if product.brand %}
|
||||||
|
<span style="text-transform:uppercase;font-size:.625rem;font-weight:600;letter-spacing:.06em;color:#94A3B8;">{{ product.brand }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Name #}
|
||||||
|
<h4 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0;line-height:1.3;">{{ product.name }}</h4>
|
||||||
|
|
||||||
|
{# Rating + pros/cons counts #}
|
||||||
|
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;">
|
||||||
|
{% if product.rating %}
|
||||||
|
<span style="color:#D97706;font-size:.8125rem;">★</span>
|
||||||
|
<span style="font-size:.75rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if product.pros %}
|
||||||
|
<span style="font-size:.6875rem;color:#16A34A;background:#F0FDF4;border-radius:999px;padding:1px 6px;">{{ product.pros | length }} {{ t.affiliate_pros_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Price #}
|
||||||
|
{% if price_eur %}
|
||||||
|
<div style="font-family:'Commit Mono',monospace;font-size:1.0625rem;font-weight:700;color:#0F172A;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# CTA — pushed to bottom via margin-top:auto #}
|
||||||
|
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
|
||||||
|
style="display:block;background:#1D4ED8;color:#fff;border-radius:10px;padding:.5rem 1rem;font-weight:600;font-size:.8125rem;text-align:center;text-decoration:none;margin-top:auto;transition:background .2s;"
|
||||||
|
onmouseover="this.style.background='#1E40AF'"
|
||||||
|
onmouseout="this.style.background='#1D4ED8'">
|
||||||
|
{{ cta }} →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Shared disclosure #}
|
||||||
|
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.75rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
Reference in New Issue
Block a user