merge: admin nav collapsible sidebar + billing products page
# Conflicts: # web/src/padelnomics/admin/templates/admin/base_admin.html
This commit is contained in:
@@ -278,7 +278,7 @@ async def index():
|
||||
stats = await get_dashboard_stats()
|
||||
recent_users = await get_users(limit=10)
|
||||
failed_tasks = await get_failed_tasks()
|
||||
|
||||
|
||||
return await render_template(
|
||||
"admin/index.html",
|
||||
stats=stats,
|
||||
@@ -295,9 +295,9 @@ async def users():
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = 50
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
|
||||
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
|
||||
|
||||
|
||||
return await render_template(
|
||||
"admin/users.html",
|
||||
users=user_list,
|
||||
@@ -331,7 +331,7 @@ async def user_detail(user_id: int):
|
||||
if not user:
|
||||
await flash("User not found.", "error")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
return await render_template("admin/user_detail.html", user=user)
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ async def impersonate(user_id: int):
|
||||
if not user:
|
||||
await flash("User not found.", "error")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
# Store admin session so we can return
|
||||
session["admin_impersonating"] = True
|
||||
session["user_id"] = user_id
|
||||
@@ -375,7 +375,7 @@ async def tasks():
|
||||
"""Task queue management."""
|
||||
task_list = await get_recent_tasks(limit=100)
|
||||
failed = await get_failed_tasks()
|
||||
|
||||
|
||||
return await render_template(
|
||||
"admin/tasks.html",
|
||||
tasks=task_list,
|
||||
@@ -1219,6 +1219,83 @@ async def feedback():
|
||||
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Billing / Products
|
||||
# =============================================================================
|
||||
|
||||
_PRODUCT_CATEGORIES = [
|
||||
("Supplier Plans", ["supplier_growth", "supplier_growth_yearly", "supplier_pro", "supplier_pro_yearly"]),
|
||||
("Planner Plans", ["starter", "pro"]),
|
||||
("Boosts (Subscription)", ["boost_logo", "boost_highlight", "boost_verified", "boost_card_color"]),
|
||||
("Boosts (One-time)", ["boost_sticky_week", "boost_sticky_month"]),
|
||||
("Credit Packs", ["credits_25", "credits_50", "credits_100", "credits_250"]),
|
||||
("One-time Products", ["business_plan"]),
|
||||
]
|
||||
|
||||
|
||||
@bp.route("/billing/products")
|
||||
@role_required("admin")
|
||||
async def billing_products():
|
||||
"""Read-only overview of Paddle products, subscriptions, and revenue proxies."""
|
||||
active_subs_row = await fetch_one("SELECT COUNT(*) as cnt FROM subscriptions WHERE status = 'active'")
|
||||
mrr_row = await fetch_one(
|
||||
"""SELECT COALESCE(SUM(
|
||||
CASE WHEN pp.key LIKE '%_yearly' THEN pp.price_cents / 12
|
||||
ELSE pp.price_cents END
|
||||
), 0) as total_cents
|
||||
FROM subscriptions s
|
||||
JOIN paddle_products pp ON s.plan = pp.key
|
||||
WHERE s.status = 'active' AND pp.billing_type = 'subscription'"""
|
||||
)
|
||||
active_boosts_row = await fetch_one("SELECT COUNT(*) as cnt FROM supplier_boosts WHERE status = 'active'")
|
||||
bp_exports_row = await fetch_one("SELECT COUNT(*) as cnt FROM business_plan_exports WHERE status = 'completed'")
|
||||
|
||||
stats = {
|
||||
"active_subs": (active_subs_row or {}).get("cnt", 0),
|
||||
"mrr_cents": (mrr_row or {}).get("total_cents", 0),
|
||||
"active_boosts": (active_boosts_row or {}).get("cnt", 0),
|
||||
"bp_exports": (bp_exports_row or {}).get("cnt", 0),
|
||||
}
|
||||
|
||||
products_rows = await fetch_all("SELECT * FROM paddle_products ORDER BY key")
|
||||
product_map = {p["key"]: dict(p) for p in products_rows}
|
||||
|
||||
sub_counts = await fetch_all(
|
||||
"SELECT plan, COUNT(*) as cnt FROM subscriptions WHERE status = 'active' GROUP BY plan"
|
||||
)
|
||||
sub_count_map = {r["plan"]: r["cnt"] for r in sub_counts}
|
||||
|
||||
boost_counts = await fetch_all(
|
||||
"SELECT boost_type, COUNT(*) as cnt FROM supplier_boosts WHERE status = 'active' GROUP BY boost_type"
|
||||
)
|
||||
boost_count_map = {r["boost_type"]: r["cnt"] for r in boost_counts}
|
||||
|
||||
categories = []
|
||||
for cat_name, keys in _PRODUCT_CATEGORIES:
|
||||
items = []
|
||||
for key in keys:
|
||||
prod = product_map.get(key)
|
||||
if not prod:
|
||||
continue
|
||||
if key in sub_count_map:
|
||||
prod["active_count"] = sub_count_map[key]
|
||||
elif key in boost_count_map:
|
||||
prod["active_count"] = boost_count_map[key]
|
||||
elif key == "business_plan":
|
||||
prod["active_count"] = stats["bp_exports"]
|
||||
else:
|
||||
prod["active_count"] = 0
|
||||
items.append(prod)
|
||||
if items:
|
||||
categories.append({"name": cat_name, "products": items})
|
||||
|
||||
return await render_template(
|
||||
"admin/billing_products.html",
|
||||
stats=stats,
|
||||
categories=categories,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Email Hub
|
||||
# =============================================================================
|
||||
|
||||
@@ -9,24 +9,61 @@
|
||||
}
|
||||
.admin-sidebar__title {
|
||||
padding: 0 1rem 1rem; font-size: 0.8125rem; font-weight: 700; color: #0F172A;
|
||||
border-bottom: 1px solid #E2E8F0; margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #E2E8F0; margin-bottom: 0.25rem;
|
||||
}
|
||||
.admin-sidebar__section {
|
||||
padding: 0.5rem 0 0.25rem; font-size: 0.5625rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: #94A3B8;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.admin-nav a {
|
||||
|
||||
/* ── Direct links (single-item sections) ── */
|
||||
.sidebar-direct {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 1rem; font-size: 0.8125rem; color: #64748B;
|
||||
text-decoration: none; transition: all 0.1s;
|
||||
}
|
||||
.sidebar-direct:hover { background: #EFF6FF; color: #1D4ED8; }
|
||||
.sidebar-direct.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #1D4ED8; }
|
||||
.sidebar-direct svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
/* ── Collapsible group header ── */
|
||||
.sidebar-group__header {
|
||||
display: flex; align-items: center; gap: 7px; width: 100%;
|
||||
padding: 8px 1rem; font-size: 0.5625rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: #94A3B8;
|
||||
background: none; border: none; cursor: pointer; transition: color 0.12s;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.sidebar-group__header:hover { color: #1D4ED8; }
|
||||
.sidebar-group__header .header-icon { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
.sidebar-group.active-section .sidebar-group__header { color: #1D4ED8; }
|
||||
.sidebar-group__chevron {
|
||||
width: 11px; height: 11px; margin-left: auto; flex-shrink: 0; color: #CBD5E1;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.sidebar-group.collapsed .sidebar-group__chevron { transform: rotate(-90deg); }
|
||||
|
||||
/* Badge on group header (shows unread count even when collapsed) */
|
||||
.sidebar-header-badge {
|
||||
font-size: 9px; padding: 1px 5px; border-radius: 9999px;
|
||||
background: #EF4444; color: white; font-weight: 700; margin-left: 1px;
|
||||
}
|
||||
|
||||
/* ── Collapsible items container ── */
|
||||
.sidebar-group__items {
|
||||
overflow: hidden; max-height: 400px; transition: max-height 0.25s ease;
|
||||
}
|
||||
.sidebar-group.collapsed .sidebar-group__items { max-height: 0; }
|
||||
|
||||
/* ── Links inside groups ── */
|
||||
.admin-nav a {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 1rem 7px 2.125rem; font-size: 0.8125rem; color: #64748B;
|
||||
text-decoration: none; transition: all 0.1s;
|
||||
}
|
||||
.admin-nav a:hover { background: #EFF6FF; color: #1D4ED8; }
|
||||
.admin-nav a.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #1D4ED8; }
|
||||
.admin-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.admin-main { flex: 1; padding: 2rem; overflow-y: auto; }
|
||||
|
||||
/* ── Confirm dialog ── */
|
||||
#confirm-dialog {
|
||||
border: none; border-radius: 12px; padding: 1.5rem; max-width: 380px; width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.08);
|
||||
@@ -36,6 +73,7 @@
|
||||
#confirm-dialog p { margin: 0 0 1.25rem; font-size: 0.9375rem; color: #0F172A; line-height: 1.55; }
|
||||
#confirm-dialog .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout { flex-direction: column; }
|
||||
.admin-sidebar {
|
||||
@@ -43,9 +81,16 @@
|
||||
overflow-x: auto; border-right: none; border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.admin-sidebar__title { display: none; }
|
||||
.admin-sidebar__section { display: none; }
|
||||
.admin-nav { display: flex; flex: none; padding: 0; gap: 2px; }
|
||||
.sidebar-group__header { display: none; }
|
||||
.sidebar-group { display: contents; }
|
||||
.sidebar-group__items,
|
||||
.sidebar-group.collapsed .sidebar-group__items {
|
||||
max-height: none !important; overflow: visible;
|
||||
display: flex; flex: none; gap: 2px;
|
||||
}
|
||||
.admin-nav { display: flex; flex: none; padding: 0; gap: 2px; align-items: center; }
|
||||
.admin-nav a { padding: 8px 12px; white-space: nowrap; border-right: none !important; border-radius: 6px; }
|
||||
.sidebar-direct { padding: 8px 12px; white-space: nowrap; border-right: none !important; border-radius: 6px; }
|
||||
.admin-main { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -53,109 +98,163 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{%- set _section_map = {
|
||||
'dashboard': 'overview',
|
||||
'marketplace': 'marketplace', 'leads': 'marketplace',
|
||||
'suppliers': 'suppliers',
|
||||
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
||||
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
||||
'billing': 'billing',
|
||||
'seo': 'analytics',
|
||||
'pipeline': 'pipeline',
|
||||
'users': 'system', 'flags': 'system', 'tasks': 'system', 'feedback': 'system',
|
||||
} -%}
|
||||
{%- set active_section = _section_map.get(admin_page|default(''), 'overview') -%}
|
||||
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar__title">Admin</div>
|
||||
<nav class="admin-nav">
|
||||
<div class="admin-sidebar__section">Overview</div>
|
||||
<a href="{{ url_for('admin.index') }}" class="{% if admin_page == 'dashboard' %}active{% endif %}">
|
||||
|
||||
{# ── OVERVIEW (direct) ── #}
|
||||
<a href="{{ url_for('admin.index') }}" class="sidebar-direct{% if admin_page == 'dashboard' %} active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Marketplace</div>
|
||||
<a href="{{ url_for('admin.marketplace_dashboard') }}" class="{% if admin_page == 'marketplace' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||
Leads
|
||||
</a>
|
||||
{# ── MARKETPLACE (collapsible) ── #}
|
||||
<div class="sidebar-group{% if active_section == 'marketplace' %} active-section{% endif %}" data-section="marketplace">
|
||||
<button class="sidebar-group__header" type="button">
|
||||
<svg class="header-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
<span>Marketplace</span>
|
||||
<svg class="sidebar-group__chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
<div class="sidebar-group__items">
|
||||
<a href="{{ url_for('admin.marketplace_dashboard') }}" class="{% if admin_page == 'marketplace' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||
Leads
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-sidebar__section">Suppliers</div>
|
||||
<a href="{{ url_for('admin.suppliers') }}" class="{% if admin_page == 'suppliers' %}active{% endif %}">
|
||||
{# ── SUPPLIERS (direct) ── #}
|
||||
<a href="{{ url_for('admin.suppliers') }}" class="sidebar-direct{% if admin_page == 'suppliers' %} active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5M3.75 3v18m4.5-18v18M12 3v18m4.5-18v18m4.5-18v18M6 6.75h.008v.008H6V6.75Zm0 3h.008v.008H6V9.75Zm0 3h.008v.008H6v-.008Zm4.5-6h.008v.008H10.5V6.75Zm0 3h.008v.008H10.5V9.75Zm0 3h.008v.008H10.5v-.008Zm4.5-6h.008v.008H15V6.75Zm0 3h.008v.008H15V9.75Zm0 3h.008v.008H15v-.008Z"/></svg>
|
||||
Suppliers
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Users</div>
|
||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
|
||||
Users
|
||||
{# ── CONTENT (collapsible: Articles, Scenarios, Templates, pSEO) ── #}
|
||||
<div class="sidebar-group{% if active_section == 'content' %} active-section{% endif %}" data-section="content">
|
||||
<button class="sidebar-group__header" type="button">
|
||||
<svg class="header-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
<span>Content</span>
|
||||
<svg class="sidebar-group__chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
<div class="sidebar-group__items">
|
||||
<a href="{{ url_for('admin.articles') }}" class="{% if admin_page == 'articles' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
Articles
|
||||
</a>
|
||||
<a href="{{ url_for('admin.scenarios') }}" class="{% if admin_page == 'scenarios' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/></svg>
|
||||
Scenarios
|
||||
</a>
|
||||
<a href="{{ url_for('admin.templates') }}" class="{% if admin_page == 'templates' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Z"/></svg>
|
||||
Templates
|
||||
</a>
|
||||
<a href="{{ url_for('pseo.pseo_dashboard') }}" class="{% if admin_page == 'pseo' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/></svg>
|
||||
pSEO Engine
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── EMAIL (collapsible) ── #}
|
||||
<div class="sidebar-group{% if active_section == 'email' %} active-section{% endif %}" data-section="email">
|
||||
<button class="sidebar-group__header" type="button">
|
||||
<svg class="header-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
<span>Email</span>
|
||||
{% if unread_count %}<span class="sidebar-header-badge">{{ unread_count }}</span>{% endif %}
|
||||
<svg class="sidebar-group__chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
<div class="sidebar-group__items">
|
||||
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
Sent Log
|
||||
</a>
|
||||
<a href="{{ url_for('admin.inbox') }}" class="{% if admin_page == 'inbox' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>
|
||||
Inbox{% if unread_count %} <span class="badge-danger" style="font-size:10px;padding:1px 6px;margin-left:auto;">{{ unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_compose') }}" class="{% if admin_page == 'compose' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
Compose
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_gallery') }}" class="{% if admin_page == 'gallery' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z"/></svg>
|
||||
Gallery
|
||||
</a>
|
||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
||||
Audiences
|
||||
</a>
|
||||
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/></svg>
|
||||
Outreach
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── BILLING (direct) ── #}
|
||||
<a href="{{ url_for('admin.billing_products') }}" class="sidebar-direct{% if admin_page == 'billing' %} active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
Billing
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Content</div>
|
||||
<a href="{{ url_for('admin.articles') }}" class="{% if admin_page == 'articles' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
Articles
|
||||
</a>
|
||||
<a href="{{ url_for('admin.scenarios') }}" class="{% if admin_page == 'scenarios' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/></svg>
|
||||
Scenarios
|
||||
</a>
|
||||
<a href="{{ url_for('admin.templates') }}" class="{% if admin_page == 'templates' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Z"/></svg>
|
||||
Templates
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">pSEO</div>
|
||||
<a href="{{ url_for('pseo.pseo_dashboard') }}" class="{% if admin_page == 'pseo' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/></svg>
|
||||
pSEO Engine
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Email</div>
|
||||
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
Sent Log
|
||||
</a>
|
||||
<a href="{{ url_for('admin.inbox') }}" class="{% if admin_page == 'inbox' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>
|
||||
Inbox{% if unread_count %} <span class="badge-danger" style="font-size:10px;padding:1px 6px;margin-left:auto;">{{ unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_compose') }}" class="{% if admin_page == 'compose' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
Compose
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_gallery') }}" class="{% if admin_page == 'gallery' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z"/></svg>
|
||||
Gallery
|
||||
</a>
|
||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
||||
Audiences
|
||||
</a>
|
||||
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/></svg>
|
||||
Outreach
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Analytics</div>
|
||||
<a href="{{ url_for('admin.seo') }}" class="{% if admin_page == 'seo' %}active{% endif %}">
|
||||
{# ── ANALYTICS (direct) ── #}
|
||||
<a href="{{ url_for('admin.seo') }}" class="sidebar-direct{% if admin_page == 'seo' %} active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
SEO Hub
|
||||
Analytics
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Pipeline</div>
|
||||
<a href="{{ url_for('pipeline.pipeline_dashboard') }}" class="{% if admin_page == 'pipeline' %}active{% endif %}">
|
||||
{# ── PIPELINE (direct) ── #}
|
||||
<a href="{{ url_for('pipeline.pipeline_dashboard') }}" class="sidebar-direct{% if admin_page == 'pipeline' %} active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"/></svg>
|
||||
Pipeline
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">System</div>
|
||||
<a href="{{ url_for('admin.flags') }}" class="{% if admin_page == 'flags' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"/></svg>
|
||||
Flags
|
||||
</a>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||
Tasks
|
||||
</a>
|
||||
<a href="{{ url_for('admin.feedback') }}" class="{% if admin_page == 'feedback' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||
Feedback
|
||||
</a>
|
||||
{# ── SYSTEM (collapsible: Users, Flags, Tasks, Feedback) ── #}
|
||||
<div class="sidebar-group{% if active_section == 'system' %} active-section{% endif %}" data-section="system">
|
||||
<button class="sidebar-group__header" type="button">
|
||||
<svg class="header-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
|
||||
<span>System</span>
|
||||
<svg class="sidebar-group__chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
<div class="sidebar-group__items">
|
||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="{{ url_for('admin.flags') }}" class="{% if admin_page == 'flags' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"/></svg>
|
||||
Flags
|
||||
</a>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||
Tasks
|
||||
</a>
|
||||
<a href="{{ url_for('admin.feedback') }}" class="{% if admin_page == 'feedback' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||
Feedback
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -182,5 +281,30 @@ function confirmAction(message, form) {
|
||||
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// Collapsible sidebar groups
|
||||
(function() {
|
||||
var STORAGE_KEY = 'admin_sidebar_v1';
|
||||
var activeSection = '{{ active_section }}';
|
||||
var saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch(e) {}
|
||||
|
||||
document.querySelectorAll('.sidebar-group').forEach(function(group) {
|
||||
var section = group.dataset.section;
|
||||
var isActive = section === activeSection;
|
||||
if (isActive) {
|
||||
group.classList.remove('collapsed');
|
||||
} else if (saved[section] === 'open') {
|
||||
group.classList.remove('collapsed');
|
||||
} else {
|
||||
group.classList.add('collapsed');
|
||||
}
|
||||
group.querySelector('.sidebar-group__header').addEventListener('click', function() {
|
||||
group.classList.toggle('collapsed');
|
||||
saved[section] = group.classList.contains('collapsed') ? 'closed' : 'open';
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch(e) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
137
web/src/padelnomics/admin/templates/admin/billing_products.html
Normal file
137
web/src/padelnomics/admin/templates/admin/billing_products.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "billing" %}
|
||||
|
||||
{% block title %}Billing & Products - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.billing-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
|
||||
@media (max-width: 900px) { .billing-stats { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.product-section { margin-bottom: 2rem; }
|
||||
.product-section-title {
|
||||
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #64748B; margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.product-table { width: 100%; border-collapse: collapse; }
|
||||
.product-table th {
|
||||
padding: 8px 12px; text-align: left; font-size: 0.6875rem; font-weight: 600;
|
||||
color: #94A3B8; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #E2E8F0; white-space: nowrap;
|
||||
}
|
||||
.product-table td { padding: 10px 12px; border-bottom: 1px solid #F1F5F9; vertical-align: middle; }
|
||||
.product-table tr:last-child td { border-bottom: none; }
|
||||
.product-table tr:hover td { background: #F8FAFC; }
|
||||
|
||||
.product-name { font-weight: 600; color: #0F172A; font-size: 0.875rem; }
|
||||
.product-key { font-family: monospace; font-size: 0.75rem; color: #94A3B8; }
|
||||
.product-price { font-weight: 600; color: #0F172A; }
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 9999px; font-size: 0.6875rem; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.type-badge--subscription { background: #DBEAFE; color: #1D4ED8; }
|
||||
.type-badge--one_time { background: #F3E8FF; color: #7C3AED; }
|
||||
|
||||
.active-count { font-weight: 700; font-size: 0.9375rem; color: #0F172A; }
|
||||
.active-count-zero { color: #CBD5E1; }
|
||||
|
||||
.paddle-ids { font-family: monospace; font-size: 0.6875rem; color: #CBD5E1; line-height: 1.6; }
|
||||
.paddle-ids span { display: block; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl">Billing & Products</h1>
|
||||
<p class="text-sm text-slate mt-1">Read-only overview — manage products via <code>setup_paddle.py</code> and the Paddle dashboard.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Stats cards #}
|
||||
<div class="billing-stats">
|
||||
<div class="card text-center">
|
||||
<p class="card-header">Active Subscriptions</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.active_subs }}</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">Est. MRR</p>
|
||||
<p class="text-3xl font-bold text-navy metric">€{{ "%.0f"|format(stats.mrr_cents / 100) }}</p>
|
||||
<p class="text-xs text-slate mt-1">approx (yearly ÷ 12)</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">Active Boosts</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.active_boosts }}</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">BP Exports</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.bp_exports }}</p>
|
||||
<p class="text-xs text-slate mt-1">completed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Product categories #}
|
||||
{% for cat in categories %}
|
||||
<div class="product-section">
|
||||
<p class="product-section-title">{{ cat.name }}</p>
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="product-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Price</th>
|
||||
<th>Type</th>
|
||||
<th style="text-align:center;">Active</th>
|
||||
<th>Paddle IDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in cat.products %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="product-name">{{ p.name }}</span>
|
||||
<br><span class="product-key">{{ p.key }}</span>
|
||||
</td>
|
||||
<td class="product-price">€{{ "%.2f"|format(p.price_cents / 100) }}</td>
|
||||
<td>
|
||||
<span class="type-badge type-badge--{{ p.billing_type }}">
|
||||
{% if p.billing_type == 'subscription' %}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||
Subscription
|
||||
{% else %}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z"/></svg>
|
||||
One-time
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if p.active_count > 0 %}
|
||||
<span class="active-count">{{ p.active_count }}</span>
|
||||
{% else %}
|
||||
<span class="active-count-zero">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="paddle-ids">
|
||||
<span title="Product ID">{{ p.paddle_product_id }}</span>
|
||||
<span title="Price ID">{{ p.paddle_price_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not categories %}
|
||||
<div class="card text-center" style="padding: 3rem;">
|
||||
<p class="text-slate">No products found in the database.</p>
|
||||
<p class="text-xs text-slate mt-2">Run <code>uv run python -m padelnomics.scripts.setup_paddle</code> to populate products.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user