diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index a5945fa..a7d9cad 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -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 # ============================================================================= diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 76bfcf8..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,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; } } @@ -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') -%} +
Read-only overview — manage products via setup_paddle.py and the Paddle dashboard.
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
+{{ cat.name }}
+ +No products found in the database.
+Run uv run python -m padelnomics.scripts.setup_paddle to populate products.