feat(affiliate): admin dashboard — click stats, daily bar chart, top products/articles
Pure CSS bar chart (div heights via inline %). Stats computed server-side in SQL. Days filter (7d/30d/90d). Estimated revenue shown as rough indicator (~3% CR × €80). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3534,6 +3534,39 @@ async def affiliate_delete(product_id: int):
|
|||||||
return redirect(url_for("admin.affiliate_products"))
|
return redirect(url_for("admin.affiliate_products"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/dashboard")
|
||||||
|
@role_required("admin")
|
||||||
|
async def affiliate_dashboard():
|
||||||
|
"""Affiliate click statistics dashboard."""
|
||||||
|
from ..affiliate import get_click_stats
|
||||||
|
|
||||||
|
days_count = int(request.args.get("days", "30") or "30")
|
||||||
|
days_count = max(7, min(days_count, 365))
|
||||||
|
stats = await get_click_stats(days_count)
|
||||||
|
|
||||||
|
# Build estimated revenue: clicks × assumed 3% CR × avg basket €80
|
||||||
|
est_revenue = round(stats["total_clicks"] * 0.03 * 80)
|
||||||
|
|
||||||
|
# Article count (live articles that have been clicked)
|
||||||
|
article_count = len(stats["top_articles"])
|
||||||
|
|
||||||
|
# Retailer bars: compute pct of max for width
|
||||||
|
max_ret_clicks = max((r["click_count"] for r in stats["by_retailer"]), default=1)
|
||||||
|
for r in stats["by_retailer"]:
|
||||||
|
r["pct"] = round(r["click_count"] / max_ret_clicks * 100) if max_ret_clicks else 0
|
||||||
|
total = stats["total_clicks"] or 1
|
||||||
|
r["share_pct"] = round(r["click_count"] / total * 100)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_dashboard.html",
|
||||||
|
admin_page="affiliate_dashboard",
|
||||||
|
stats=stats,
|
||||||
|
est_revenue=est_revenue,
|
||||||
|
article_count=article_count,
|
||||||
|
days_count=days_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/affiliate/<int:product_id>/toggle", methods=["POST"])
|
@bp.route("/affiliate/<int:product_id>/toggle", methods=["POST"])
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def affiliate_toggle(product_id: int):
|
async def affiliate_toggle(product_id: int):
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "affiliate_dashboard" %}
|
||||||
|
|
||||||
|
{% block title %}Affiliate Dashboard - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl">Affiliate Dashboard</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% for d in [7, 30, 90] %}
|
||||||
|
<a href="?days={{ d }}" class="btn-outline btn-sm {% if days_count == d %}active{% endif %}">{{ d }}d</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{# ── Stats strip ── #}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:1.5rem;">
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.25rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Clicks ({{ days_count }}d)</div>
|
||||||
|
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.total_clicks | int }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.25rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Products</div>
|
||||||
|
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.active_products or 0 }}</div>
|
||||||
|
<div class="text-xs text-slate">{{ stats.draft_products or 0 }} draft</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.25rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Articles (clicked)</div>
|
||||||
|
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ article_count }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.25rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Est. Revenue</div>
|
||||||
|
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">~€{{ est_revenue }}</div>
|
||||||
|
<div class="text-xs text-slate">3% CR × €80 basket</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Daily bar chart ── #}
|
||||||
|
{% if stats.daily_bars %}
|
||||||
|
<div class="card mb-6" style="padding:1.5rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks · Last {{ days_count }} Days</div>
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;overflow-x:auto;">
|
||||||
|
{% for bar in stats.daily_bars %}
|
||||||
|
<div title="{{ bar.day }}: {{ bar.click_count }} clicks"
|
||||||
|
style="flex-shrink:0;width:8px;background:#1D4ED8;border-radius:3px 3px 0 0;min-height:2px;height:{{ bar.pct }}%;transition:opacity .15s;"
|
||||||
|
onmouseover="this.style.opacity='.7'" onmouseout="this.style.opacity='1'">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:.375rem;">
|
||||||
|
<span class="text-xs text-slate">{{ stats.daily_bars[0].day if stats.daily_bars else '' }}</span>
|
||||||
|
<span class="text-xs text-slate">{{ stats.daily_bars[-1].day if stats.daily_bars else '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem;">
|
||||||
|
|
||||||
|
{# ── Top products ── #}
|
||||||
|
<div class="card" style="padding:1.5rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Products</div>
|
||||||
|
{% if stats.top_products %}
|
||||||
|
{% for p in stats.top_products %}
|
||||||
|
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||||
|
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||||
|
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
<a href="{{ url_for('admin.affiliate_edit', product_id=p.id) }}" style="color:inherit;text-decoration:none;">{{ p.name }}</a>
|
||||||
|
</span>
|
||||||
|
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ p.click_count }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No clicks yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Top articles ── #}
|
||||||
|
<div class="card" style="padding:1.5rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Articles</div>
|
||||||
|
{% if stats.top_articles %}
|
||||||
|
{% for a in stats.top_articles %}
|
||||||
|
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||||
|
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||||
|
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||||
|
title="{{ a.article_slug }}">{{ a.article_slug }}</span>
|
||||||
|
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ a.click_count }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No clicks with article source yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Clicks by retailer ── #}
|
||||||
|
{% if stats.by_retailer %}
|
||||||
|
<div class="card" style="padding:1.5rem;">
|
||||||
|
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks by Retailer</div>
|
||||||
|
{% for r in stats.by_retailer %}
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.75rem;">
|
||||||
|
<span style="width:140px;font-size:.8125rem;color:#0F172A;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
{{ r.retailer or 'Unknown' }}
|
||||||
|
</span>
|
||||||
|
<div style="flex:1;background:#F1F5F9;border-radius:4px;height:24px;overflow:hidden;">
|
||||||
|
<div style="width:{{ r.pct }}%;background:#1D4ED8;height:100%;border-radius:4px;min-width:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<span class="mono" style="font-size:.8125rem;font-weight:600;width:60px;text-align:right;flex-shrink:0;">
|
||||||
|
{{ r.click_count }} <span class="text-slate" style="font-weight:400;">({{ r.share_pct }}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user