From 82591514cd7be0edacc2784ed3e5d4c666365584 Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 19:49:46 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20collapsible=20admin=20sidebar=20?= =?UTF-8?q?=E2=80=94=20groups,=20section-map,=20localStorage=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces flat 20-link sidebar with collapsible section groups: - Multi-item sections (Marketplace, Content, Email, System) are collapsible with animated chevron; active section always expands - Single-item sections (Dashboard, Suppliers, Billing, Analytics, Pipeline) render as direct links — no toggle overhead - pSEO merged into Content; Users moved into System; new Billing slot - Unread badge surfaces on Email group header when collapsed - localStorage persists per-section open/closed state (key: admin_sidebar_v1) - Mobile: group headers hidden, all items shown in horizontal scroll (preserves existing mobile behavior exactly) - section_map Jinja dict derives active_section from existing admin_page — no route changes needed Co-Authored-By: Claude Sonnet 4.6 --- .../admin/templates/admin/base_admin.html | 315 ++++++++++++++---- 1 file changed, 246 insertions(+), 69 deletions(-) diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index a153220..3559bf6 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -9,24 +9,71 @@ } .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); + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; + } + #confirm-dialog::backdrop { background: rgba(15,23,42,0.45); backdrop-filter: blur(3px); } + #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 { @@ -34,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; } } @@ -44,85 +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') -%} + + + +

+
+ + +
+
+ {% endblock %} From a028184a85fe262b833d261fbe866e2a02b42ada Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 19:49:55 +0100 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20admin=20billing=20products=20page?= =?UTF-8?q?=20=E2=80=94=20/admin/billing/products?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only overview of all Paddle products with live metrics: - Stats cards: active subscriptions, estimated MRR (yearly÷12), active boosts, completed business plan exports - Products grouped by category: Supplier Plans, Planner Plans, Boosts (sub + one-time), Credit Packs, One-time Products - Per-product: name, key, price, type badge, active count, Paddle IDs - Empty-state message when paddle_products table is unpopulated - PRODUCT_CATEGORIES constant in routes.py defines grouping + ordering Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 89 +++++++++++- .../templates/admin/billing_products.html | 137 ++++++++++++++++++ 2 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 web/src/padelnomics/admin/templates/admin/billing_products.html diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 89336cc..78c9bf4 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -233,7 +233,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, @@ -250,9 +250,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, @@ -269,7 +269,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) @@ -282,7 +282,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 @@ -313,7 +313,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, @@ -893,6 +893,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 # ============================================================================= diff --git a/web/src/padelnomics/admin/templates/admin/billing_products.html b/web/src/padelnomics/admin/templates/admin/billing_products.html new file mode 100644 index 0000000..d089f87 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/billing_products.html @@ -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 %} + +{% endblock %} + +{% block admin_content %} +
+
+

Billing & Products

+

Read-only overview — manage products via setup_paddle.py and the Paddle dashboard.

+
+
+ +{# Stats cards #} +
+
+

Active Subscriptions

+

{{ stats.active_subs }}

+
+
+

Est. MRR

+

€{{ "%.0f"|format(stats.mrr_cents / 100) }}

+

approx (yearly ÷ 12)

+
+
+

Active Boosts

+

{{ stats.active_boosts }}

+
+
+

BP Exports

+

{{ stats.bp_exports }}

+

completed

+
+
+ +{# Product categories #} +{% for cat in categories %} +
+

{{ cat.name }}

+
+ + + + + + + + + + + + {% for p in cat.products %} + + + + + + + + {% endfor %} + +
ProductPriceTypeActivePaddle IDs
+ {{ p.name }} +
{{ p.key }} +
€{{ "%.2f"|format(p.price_cents / 100) }} + + {% if p.billing_type == 'subscription' %} + + Subscription + {% else %} + + One-time + {% endif %} + + + {% if p.active_count > 0 %} + {{ p.active_count }} + {% else %} + + {% endif %} + +
+ {{ p.paddle_product_id }} + {{ p.paddle_price_id }} +
+
+
+
+{% endfor %} + +{% if not categories %} +
+

No products found in the database.

+

Run uv run python -m padelnomics.scripts.setup_paddle to populate products.

+
+{% endif %} +{% endblock %}