Merge admin-upgrade: sidebar layout, feedback, waitlist, sitemap for BeanFlows

This commit is contained in:
Deeman
2026-02-20 15:26:13 +01:00
17 changed files with 1318 additions and 306 deletions

View File

@@ -29,3 +29,7 @@ PADDLE_PRICE_PRO=
# Rate limiting # Rate limiting
RATE_LIMIT_REQUESTS=100 RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=60 RATE_LIMIT_WINDOW=60
# Waitlist (set to true to enable waitlist gate on /auth/signup)
WAITLIST_MODE=false
RESEND_AUDIENCE_WAITLIST=

View File

@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from quart import Blueprint, flash, redirect, render_template, request, session, url_for from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
from ..core import config, csrf_protect, execute, fetch_all, fetch_one from ..core import config, csrf_protect, execute, fetch_all, fetch_one
@@ -60,6 +60,11 @@ async def get_dashboard_stats() -> dict:
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'") tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'") tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
waitlist_count = await fetch_one("SELECT COUNT(*) as count FROM waitlist")
feedback_unread = await fetch_one(
"SELECT COUNT(*) as count FROM feedback WHERE is_read = 0"
)
# Analytics stats (DuckDB) # Analytics stats (DuckDB)
analytics = {"commodity_count": 0, "min_year": None, "max_year": None} analytics = {"commodity_count": 0, "min_year": None, "max_year": None}
try: try:
@@ -88,6 +93,8 @@ async def get_dashboard_stats() -> dict:
"active_subscriptions": subs["count"] if subs else 0, "active_subscriptions": subs["count"] if subs else 0,
"tasks_pending": tasks_pending["count"] if tasks_pending else 0, "tasks_pending": tasks_pending["count"] if tasks_pending else 0,
"tasks_failed": tasks_failed["count"] if tasks_failed else 0, "tasks_failed": tasks_failed["count"] if tasks_failed else 0,
"waitlist_count": waitlist_count["count"] if waitlist_count else 0,
"feedback_unread": feedback_unread["count"] if feedback_unread else 0,
"commodity_count": analytics.get("commodity_count", 0), "commodity_count": analytics.get("commodity_count", 0),
"data_year_range": f"{analytics.get('min_year', '?')}{analytics.get('max_year', '?')}", "data_year_range": f"{analytics.get('min_year', '?')}{analytics.get('max_year', '?')}",
} }
@@ -124,7 +131,7 @@ async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID with subscription info.""" """Get user by ID with subscription info."""
return await fetch_one( return await fetch_one(
""" """
SELECT u.*, s.plan, s.status as sub_status, s.stripe_customer_id SELECT u.*, s.plan, s.status as sub_status
FROM users u FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE u.id = ? WHERE u.id = ?
@@ -171,15 +178,39 @@ async def delete_task(task_id: int) -> bool:
return result > 0 return result > 0
async def get_feedback(limit: int = 100) -> list[dict]:
"""Get feedback with user email joined."""
return await fetch_all(
"""
SELECT f.*, u.email as user_email
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
ORDER BY f.is_read ASC, f.created_at DESC
LIMIT ?
""",
(limit,)
)
async def get_waitlist(limit: int = 500) -> list[dict]:
"""Get waitlist entries."""
return await fetch_all(
"SELECT * FROM waitlist ORDER BY created_at DESC LIMIT ?",
(limit,)
)
# ============================================================================= # =============================================================================
# Decorators # Decorators
# ============================================================================= # =============================================================================
def admin_required(f): def admin_required(f):
"""Require admin authentication.""" """Require admin authentication via password (is_admin session flag) or admin role."""
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
if not session.get("is_admin"): is_password_admin = session.get("is_admin")
is_role_admin = "admin" in (g.get("user") or {}).get("roles", [])
if not is_password_admin and not is_role_admin:
return redirect(url_for("admin.login")) return redirect(url_for("admin.login"))
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
@@ -292,8 +323,8 @@ async def impersonate(user_id: int):
await flash("User not found.", "error") await flash("User not found.", "error")
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
# Store admin session so we can return # Store admin's user_id so we can restore it later
session["admin_impersonating"] = True session["admin_impersonating"] = session.get("user_id")
session["user_id"] = user_id session["user_id"] = user_id
await flash(f"Now impersonating {user['email']}. Return to admin to stop.", "warning") await flash(f"Now impersonating {user['email']}. Return to admin to stop.", "warning")
@@ -304,8 +335,11 @@ async def impersonate(user_id: int):
@csrf_protect @csrf_protect
async def stop_impersonating(): async def stop_impersonating():
"""Stop impersonating and return to admin.""" """Stop impersonating and return to admin."""
session.pop("user_id", None) admin_user_id = session.pop("admin_impersonating", None)
session.pop("admin_impersonating", None) if admin_user_id:
session["user_id"] = admin_user_id
else:
session.pop("user_id", None)
await flash("Stopped impersonating.", "info") await flash("Stopped impersonating.", "info")
return redirect(url_for("admin.index")) return redirect(url_for("admin.index"))
@@ -348,3 +382,42 @@ async def task_delete(task_id: int):
else: else:
await flash("Could not delete task.", "error") await flash("Could not delete task.", "error")
return redirect(url_for("admin.tasks")) return redirect(url_for("admin.tasks"))
@bp.route("/feedback")
@admin_required
async def feedback():
"""Feedback viewer."""
items = await get_feedback()
unread_count = sum(1 for f in items if not f["is_read"])
return await render_template(
"admin/feedback.html",
feedback_items=items,
unread_count=unread_count,
)
@bp.route("/feedback/<int:feedback_id>/mark-read", methods=["POST"])
@admin_required
@csrf_protect
async def feedback_mark_read(feedback_id: int):
"""Mark a feedback item as read."""
await execute(
"UPDATE feedback SET is_read = 1 WHERE id = ?",
(feedback_id,)
)
return redirect(url_for("admin.feedback"))
@bp.route("/waitlist")
@admin_required
async def waitlist():
"""Waitlist admin view."""
entries = await get_waitlist()
return await render_template(
"admin/waitlist.html",
entries=entries,
total=len(entries),
)

View File

@@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin - {{ config.APP_NAME }}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
<style>
/* ── Admin Shell Layout ── */
html, body { height: 100%; }
.admin-shell {
display: flex;
min-height: 100vh;
background: #F5F0EB;
}
/* ── Sidebar ── */
.admin-sidebar {
width: 220px;
min-width: 220px;
background: #2C1810;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 50;
overflow-y: auto;
}
.admin-sidebar-logo {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 20px 18px;
border-bottom: 1px solid rgba(255,255,255,0.08);
text-decoration: none;
}
.admin-sidebar-logo:hover { text-decoration: none; }
.admin-sidebar-logo-mark {
width: 28px;
height: 28px;
background: #B45309;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: #fff;
letter-spacing: -0.5px;
flex-shrink: 0;
}
.admin-sidebar-logo-text {
font-size: 13px;
font-weight: 600;
color: rgba(255,255,255,0.9);
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-sidebar-logo-badge {
font-size: 9px;
font-weight: 700;
color: #B45309;
background: rgba(180,83,9,0.2);
border: 1px solid rgba(180,83,9,0.4);
border-radius: 3px;
padding: 1px 5px;
letter-spacing: 0.05em;
text-transform: uppercase;
flex-shrink: 0;
}
.admin-nav {
flex: 1;
padding: 12px 10px;
}
.admin-nav-section {
margin-bottom: 6px;
}
.admin-nav-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255,255,255,0.3);
padding: 10px 10px 4px;
}
.admin-nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 6px;
font-size: 13.5px;
font-weight: 500;
color: rgba(255,255,255,0.6);
text-decoration: none;
transition: background 0.12s, color 0.12s;
position: relative;
}
.admin-nav-item:hover {
background: rgba(255,255,255,0.07);
color: rgba(255,255,255,0.9);
text-decoration: none;
}
.admin-nav-item.active {
background: rgba(180,83,9,0.2);
color: #fff;
font-weight: 600;
}
.admin-nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
background: #B45309;
border-radius: 0 2px 2px 0;
}
.admin-nav-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.7;
}
.admin-nav-item.active .admin-nav-icon { opacity: 1; }
.admin-nav-item:hover .admin-nav-icon { opacity: 0.9; }
.admin-nav-badge {
margin-left: auto;
font-size: 10px;
font-weight: 700;
background: #EF4444;
color: #fff;
border-radius: 10px;
padding: 1px 6px;
min-width: 18px;
text-align: center;
}
.admin-nav-badge.unread { background: #D97706; }
.admin-sidebar-footer {
padding: 12px 10px;
border-top: 1px solid rgba(255,255,255,0.08);
}
/* ── Main Content ── */
.admin-main {
flex: 1;
margin-left: 220px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.admin-topbar {
background: #fff;
border-bottom: 1px solid #E8DFD5;
padding: 0 28px;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 40;
}
.admin-topbar-title {
font-size: 14px;
font-weight: 600;
color: #2C1810;
}
.admin-topbar-right {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #78716C;
}
.admin-content {
flex: 1;
padding: 28px;
}
/* ── Mobile toggle ── */
.admin-mobile-toggle {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 6px;
color: #2C1810;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.admin-sidebar {
transform: translateX(-100%);
transition: transform 0.22s ease;
}
.admin-sidebar.open { transform: translateX(0); }
.admin-main { margin-left: 0; }
.admin-mobile-toggle { display: flex; }
.admin-content { padding: 16px; }
}
/* ── Overlay ── */
.admin-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 49;
}
.admin-overlay.active { display: block; }
/* ── Impersonation banner ── */
.impersonate-banner {
background: #FEF3C7;
border-bottom: 1px solid #D97706;
padding: 8px 28px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #92400E;
}
</style>
{% block admin_head %}{% endblock %}
</head>
<body>
<div class="admin-shell">
<!-- Sidebar -->
<aside class="admin-sidebar" id="adminSidebar">
<!-- Logo -->
<a href="{{ url_for('admin.index') }}" class="admin-sidebar-logo">
<div class="admin-sidebar-logo-mark">{{ config.APP_NAME[0] }}</div>
<span class="admin-sidebar-logo-text">{{ config.APP_NAME }}</span>
<span class="admin-sidebar-logo-badge">Admin</span>
</a>
<!-- Navigation -->
<nav class="admin-nav">
<div class="admin-nav-section">
<div class="admin-nav-label">Overview</div>
<a href="{{ url_for('admin.index') }}"
class="admin-nav-item {% if admin_page == 'dashboard' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
Dashboard
</a>
</div>
<div class="admin-nav-section">
<div class="admin-nav-label">Manage</div>
<a href="{{ url_for('admin.users') }}"
class="admin-nav-item {% if admin_page == 'users' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Users
</a>
<a href="{{ url_for('admin.tasks') }}"
class="admin-nav-item {% if admin_page == 'tasks' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="9 11 12 14 22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
Tasks
{% if stats is defined and stats.tasks_failed > 0 %}
<span class="admin-nav-badge">{{ stats.tasks_failed }}</span>
{% endif %}
</a>
</div>
<div class="admin-nav-section">
<div class="admin-nav-label">Engagement</div>
<a href="{{ url_for('admin.feedback') }}"
class="admin-nav-item {% if admin_page == 'feedback' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Feedback
{% if feedback_unread is defined and feedback_unread > 0 %}
<span class="admin-nav-badge unread">{{ feedback_unread }}</span>
{% endif %}
</a>
<a href="{{ url_for('admin.waitlist') }}"
class="admin-nav-item {% if admin_page == 'waitlist' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>
</svg>
Waitlist
</a>
</div>
</nav>
<!-- Footer links -->
<div class="admin-sidebar-footer">
<form method="post" action="{{ url_for('admin.logout') }}" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="admin-nav-item" style="width:100%;border:none;cursor:pointer;background:none;text-align:left;">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Logout
</button>
</form>
<a href="{{ url_for('dashboard.index') }}" class="admin-nav-item">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
Back to App
</a>
</div>
</aside>
<!-- Overlay for mobile -->
<div class="admin-overlay" id="adminOverlay" onclick="closeSidebar()"></div>
<!-- Main -->
<div class="admin-main">
<!-- Top bar -->
<header class="admin-topbar">
<div style="display:flex;align-items:center;gap:12px;">
<button class="admin-mobile-toggle" onclick="toggleSidebar()" aria-label="Toggle menu">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span class="admin-topbar-title">{% block topbar_title %}Admin{% endblock %}</span>
</div>
<div class="admin-topbar-right">
{% block topbar_right %}
<a href="{{ url_for('public.landing') }}" style="color:#78716C;text-decoration:none;font-size:12px;">
← {{ config.APP_NAME }}
</a>
{% endblock %}
</div>
</header>
<!-- Impersonation banner -->
{% if session.get('admin_impersonating') %}
<div class="impersonate-banner">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>You are currently impersonating a user.</span>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" style="background:none;border:1px solid #D97706;color:#92400E;border-radius:4px;padding:2px 10px;font-size:12px;font-weight:600;cursor:pointer;">
Stop
</button>
</form>
</div>
{% endif %}
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div style="padding:12px 28px 0;">
{% for category, message in messages %}
<div class="{% if category == 'error' %}flash-error{% elif category == 'success' %}flash-success{% elif category == 'warning' %}flash-warning{% else %}flash{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Page content -->
<main class="admin-content">
{% block admin_content %}{% endblock %}
</main>
</div>
</div>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
function toggleSidebar() {
document.getElementById('adminSidebar').classList.toggle('open');
document.getElementById('adminOverlay').classList.toggle('active');
}
function closeSidebar() {
document.getElementById('adminSidebar').classList.remove('open');
document.getElementById('adminOverlay').classList.remove('active');
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,75 @@
{% set admin_page = "feedback" %}
{% extends "admin/base_admin.html" %}
{% block title %}Feedback - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Feedback{% endblock %}
{% block admin_content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">User Feedback</h2>
<p class="text-sm text-stone" style="margin:4px 0 0;">
{{ feedback_items | length }} messages
{% if unread_count > 0 %}
&middot; <span style="color:#D97706;font-weight:600;">{{ unread_count }} unread</span>
{% endif %}
</p>
</div>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if feedback_items %}
<table class="table">
<thead>
<tr>
<th>Message</th>
<th>Page</th>
<th>User</th>
<th>Date</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for item in feedback_items %}
<tr{% if not item.is_read %} style="background:#FFFBF5;"{% endif %}>
<td style="max-width:320px;">
<p style="margin:0;font-size:13px;color:#2C1810;white-space:pre-wrap;word-break:break-word;">{{ item.message }}</p>
</td>
<td>
{% if item.page_url %}
<a href="{{ item.page_url }}" target="_blank" class="mono text-sm" style="max-width:180px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ item.page_url }}">
{{ item.page_url | replace(config.BASE_URL, '') or '/' }}
</a>
{% else %}
<span class="text-stone text-sm">&mdash;</span>
{% endif %}
</td>
<td>
{% if item.user_email %}
<a href="{{ url_for('admin.user_detail', user_id=item.user_id) }}" class="text-sm">{{ item.user_email }}</a>
{% else %}
<span class="text-stone text-sm">Anonymous</span>
{% endif %}
</td>
<td class="mono text-sm">{{ item.created_at[:16] }}</td>
<td>
{% if item.is_read %}
<span class="badge-success">read</span>
{% else %}
<form method="post" action="{{ url_for('admin.feedback_mark_read', feedback_id=item.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Mark read</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-stone text-sm">No feedback yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,64 +1,68 @@
{% extends "base.html" %} {% set admin_page = "dashboard" %}
{% extends "admin/base_admin.html" %}
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %} {% block title %}Dashboard - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Dashboard{% endblock %}
{% block content %} {% block admin_content %}
<main class="container"> <!-- Stats Grid -->
<header style="display: flex; justify-content: space-between; align-items: center;"> <div class="grid-4 mb-8">
<div> <div class="card text-center" style="margin-bottom:0;">
<h1>Admin Dashboard</h1> <p class="card-header">Total Users</p>
{% if session.get('admin_impersonating') %} <p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.users_total }}</p>
<mark>Currently impersonating a user</mark> <p class="text-xs text-stone mt-1">+{{ stats.users_today }} today &middot; +{{ stats.users_week }} this week</p>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" style="display: inline;"> </div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div class="card text-center" style="margin-bottom:0;">
<button type="submit" class="secondary outline" style="padding: 0.25rem 0.5rem;">Stop</button> <p class="card-header">Active Subscriptions</p>
</form> <p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.active_subscriptions }}</p>
<p class="text-xs text-stone mt-1">&nbsp;</p>
</div>
<div class="card text-center" style="margin-bottom:0;">
<p class="card-header">Task Queue</p>
<p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.tasks_pending }} <span class="text-base font-normal text-stone">pending</span></p>
{% if stats.tasks_failed > 0 %}
<p class="text-xs mt-1" style="color:#EF4444;">{{ stats.tasks_failed }} failed</p>
{% else %}
<p class="text-xs mt-1" style="color:#15803D;">All clear</p>
{% endif %} {% endif %}
</div> </div>
<form method="post" action="{{ url_for('admin.logout') }}"> <div class="card text-center" style="margin-bottom:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <p class="card-header">Coffee Data</p>
<button type="submit" class="secondary outline">Logout</button> <p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.commodity_count }}</p>
</form> <p class="text-xs text-stone mt-1">commodities &middot; {{ stats.data_year_range }}</p>
</header> </div>
<!-- Stats Grid -->
<div class="grid">
<article>
<header><small>Total Users</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.users_total }}</strong></p>
<small>+{{ stats.users_today }} today, +{{ stats.users_week }} this week</small>
</article>
<article>
<header><small>Active Subscriptions</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.active_subscriptions }}</strong></p>
</article>
<article>
<header><small>Task Queue</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.tasks_pending }}</strong> pending</p>
{% if stats.tasks_failed > 0 %}
<small style="color: var(--del-color);">{{ stats.tasks_failed }} failed</small>
{% else %}
<small style="color: var(--ins-color);">0 failed</small>
{% endif %}
</article>
</div> </div>
<!-- Quick Links --> <!-- Feedback/Waitlist row -->
<div class="grid" style="margin-bottom: 2rem;"> <div class="grid-2 mb-8">
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">All Users</a> <div class="card" style="margin-bottom:0;">
<a href="{{ url_for('admin.tasks') }}" role="button" class="secondary outline">Task Queue</a> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">View as User</a> <p class="card-header" style="margin:0;">Waitlist</p>
<a href="{{ url_for('admin.waitlist') }}" class="text-xs" style="color:#B45309;">View all &rarr;</a>
</div>
<p class="text-2xl font-bold metric" style="color:#2C1810;">{{ stats.waitlist_count }}</p>
<p class="text-xs text-stone mt-1">people waiting</p>
</div>
<div class="card" style="margin-bottom:0;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<p class="card-header" style="margin:0;">Feedback</p>
<a href="{{ url_for('admin.feedback') }}" class="text-xs" style="color:#B45309;">View all &rarr;</a>
</div>
<p class="text-2xl font-bold metric" style="color:#2C1810;">{{ stats.feedback_unread }}</p>
<p class="text-xs text-stone mt-1">unread messages</p>
</div>
</div> </div>
<div class="grid"> <div class="grid-2">
<!-- Recent Users --> <!-- Recent Users -->
<section> <section>
<h2>Recent Users</h2> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<article> <h2 class="text-xl" style="margin:0;">Recent Users</h2>
<a href="{{ url_for('admin.users') }}" class="text-sm">View all &rarr;</a>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if recent_users %} {% if recent_users %}
<table> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Email</th> <th>Email</th>
@@ -69,28 +73,34 @@
<tbody> <tbody>
{% for u in recent_users %} {% for u in recent_users %}
<tr> <tr>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td> <td>
<a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a> {% if u.plan %}
<span class="badge">{{ u.plan }}</span>
{% else %}
<span class="text-xs text-stone">free</span>
{% endif %}
</td> </td>
<td>{{ u.plan or 'free' }}</td> <td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td>{{ u.created_at[:10] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<a href="{{ url_for('admin.users') }}">View all →</a>
{% else %} {% else %}
<p>No users yet.</p> <p class="text-stone text-sm" style="padding:16px;">No users yet.</p>
{% endif %} {% endif %}
</article> </div>
</section> </section>
<!-- Failed Tasks --> <!-- Failed Tasks -->
<section> <section>
<h2>Failed Tasks</h2> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<article> <h2 class="text-xl" style="margin:0;">Failed Tasks</h2>
<a href="{{ url_for('admin.tasks') }}" class="text-sm">View all &rarr;</a>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if failed_tasks %} {% if failed_tasks %}
<table> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Task</th> <th>Task</th>
@@ -101,24 +111,22 @@
<tbody> <tbody>
{% for task in failed_tasks[:5] %} {% for task in failed_tasks[:5] %}
<tr> <tr>
<td>{{ task.task_name }}</td> <td><code class="text-xs px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
<td><small>{{ task.error[:50] }}...</small></td> <td><span class="text-xs text-stone">{{ (task.error or '')[:50] }}{% if task.error and task.error|length > 50 %}...{% endif %}</span></td>
<td> <td>
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;"> <form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button> <button type="submit" class="btn-outline btn-sm">Retry</button>
</form> </form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<a href="{{ url_for('admin.tasks') }}">View all →</a>
{% else %} {% else %}
<p style="color: var(--ins-color);">No failed tasks</p> <p class="text-sm" style="padding:16px;color:#15803D;">No failed tasks &mdash; all clear.</p>
{% endif %} {% endif %}
</article> </div>
</section> </section>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,106 +1,105 @@
{% extends "base.html" %} {% set admin_page = "tasks" %}
{% extends "admin/base_admin.html" %}
{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Tasks - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Task Queue{% endblock %}
{% block content %} {% block admin_content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Task Queue</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline">← Dashboard</a>
</header>
<!-- Failed Tasks --> <!-- Failed Tasks -->
{% if failed_tasks %} {% if failed_tasks %}
<section> <section class="mb-8">
<h2 style="color: var(--del-color);">Failed Tasks ({{ failed_tasks | length }})</h2> <h2 class="text-xl mb-4" style="color:#EF4444;">Failed Tasks ({{ failed_tasks | length }})</h2>
<article style="border-color: var(--del-color);"> <div class="card" style="padding:0;overflow:hidden;border-color:rgba(239,68,68,0.3);">
<table> <div class="overflow-x-auto">
<thead> <table class="table">
<tr> <thead>
<th>ID</th> <tr>
<th>Task</th> <th>ID</th>
<th>Error</th> <th>Task</th>
<th>Retries</th> <th>Error</th>
<th>Created</th> <th>Retries</th>
<th></th> <th>Created</th>
</tr> <th></th>
</thead> </tr>
<tbody> </thead>
{% for task in failed_tasks %} <tbody>
<tr> {% for task in failed_tasks %}
<td>{{ task.id }}</td> <tr>
<td><code>{{ task.task_name }}</code></td> <td class="mono text-sm">{{ task.id }}</td>
<td> <td><code class="text-sm px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
<details> <td>
<summary>{{ task.error[:40] if task.error else 'No error' }}...</summary> <details>
<pre style="font-size: 0.75rem; white-space: pre-wrap;">{{ task.error }}</pre> <summary class="cursor-pointer text-xs text-stone">{{ task.error[:40] if task.error else 'No error' }}...</summary>
</details> <pre class="text-xs mt-2 whitespace-pre-wrap text-stone-dark">{{ task.error }}</pre>
</td> </details>
<td>{{ task.retries }}</td> </td>
<td>{{ task.created_at[:16] }}</td> <td class="mono text-sm">{{ task.retries }}</td>
<td> <td class="mono text-sm">{{ task.created_at[:16] }}</td>
<div style="display: flex; gap: 0.5rem;"> <td>
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;"> <div class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" class="m-0">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form> <button type="submit" class="btn-outline btn-sm">Retry</button>
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" style="margin: 0;"> </form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" class="m-0">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">Delete</button> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form> <button type="submit" class="btn-outline btn-sm" style="color:#EF4444;">Delete</button>
</div> </form>
</td> </div>
</tr> </td>
{% endfor %} </tr>
</tbody> {% endfor %}
</table> </tbody>
</article> </table>
</div>
</div>
</section> </section>
{% endif %} {% endif %}
<!-- All Tasks --> <!-- All Tasks -->
<section> <section>
<h2>Recent Tasks</h2> <h2 class="text-xl mb-4">Recent Tasks</h2>
<article> <div class="card" style="padding:0;overflow:hidden;">
{% if tasks %} {% if tasks %}
<table> <div class="overflow-x-auto">
<thead> <table class="table">
<tr> <thead>
<th>ID</th> <tr>
<th>Task</th> <th>ID</th>
<th>Status</th> <th>Task</th>
<th>Run At</th> <th>Status</th>
<th>Created</th> <th>Run At</th>
<th>Completed</th> <th>Created</th>
</tr> <th>Completed</th>
</thead> </tr>
<tbody> </thead>
{% for task in tasks %} <tbody>
<tr> {% for task in tasks %}
<td>{{ task.id }}</td> <tr>
<td><code>{{ task.task_name }}</code></td> <td class="mono text-sm">{{ task.id }}</td>
<td> <td><code class="text-sm px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
{% if task.status == 'complete' %} <td>
<span style="color: var(--ins-color);">✓ complete</span> {% if task.status == 'complete' %}
{% elif task.status == 'failed' %} <span class="badge-success">complete</span>
<span style="color: var(--del-color);">✗ failed</span> {% elif task.status == 'failed' %}
{% elif task.status == 'pending' %} <span class="badge-danger">failed</span>
<span style="color: var(--mark-background-color);">○ pending</span> {% elif task.status == 'pending' %}
{% else %} <span class="badge-warning">pending</span>
{{ task.status }} {% else %}
{% endif %} <span class="badge">{{ task.status }}</span>
</td> {% endif %}
<td>{{ task.run_at[:16] if task.run_at else '-' }}</td> </td>
<td>{{ task.created_at[:16] }}</td> <td class="mono text-sm">{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td>{{ task.completed_at[:16] if task.completed_at else '-' }}</td> <td class="mono text-sm">{{ task.created_at[:16] }}</td>
</tr> <td class="mono text-sm">{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
{% endfor %} </tr>
</tbody> {% endfor %}
</table> </tbody>
</table>
</div>
{% else %} {% else %}
<p>No tasks in queue.</p> <p class="text-stone text-sm" style="padding:16px;">No tasks in queue.</p>
{% endif %} {% endif %}
</article> </div>
</section> </section>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,73 +1,81 @@
{% extends "base.html" %} {% set admin_page = "users" %}
{% extends "admin/base_admin.html" %}
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}User: {{ user.email }} - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}User Detail{% endblock %}
{% block content %} {% block admin_content %}
<main class="container"> <div style="margin-bottom:16px;">
<header style="display: flex; justify-content: space-between; align-items: center;"> <a href="{{ url_for('admin.users') }}" class="text-sm">&larr; Back to Users</a>
<h1>{{ user.email }}</h1> <h2 class="text-xl mt-1">{{ user.email }}</h2>
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">← Users</a> </div>
</header>
<div class="grid-2 mb-6">
<div class="grid">
<!-- User Info --> <!-- User Info -->
<article> <div class="card">
<header><h3>User Info</h3></header> <h3 class="text-lg mb-4">User Info</h3>
<dl> <dl class="space-y-3 text-sm">
<dt>ID</dt> <div class="flex justify-between">
<dd>{{ user.id }}</dd> <dt class="text-stone">ID</dt>
<dd class="mono">{{ user.id }}</dd>
<dt>Email</dt> </div>
<dd>{{ user.email }}</dd> <div class="flex justify-between">
<dt class="text-stone">Email</dt>
<dt>Name</dt> <dd>{{ user.email }}</dd>
<dd>{{ user.name or '-' }}</dd> </div>
<div class="flex justify-between">
<dt>Created</dt> <dt class="text-stone">Name</dt>
<dd>{{ user.created_at }}</dd> <dd>{{ user.name or '-' }}</dd>
</div>
<dt>Last Login</dt> <div class="flex justify-between">
<dd>{{ user.last_login_at or 'Never' }}</dd> <dt class="text-stone">Created</dt>
<dd class="mono">{{ user.created_at }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-stone">Last Login</dt>
<dd class="mono">{{ user.last_login_at or 'Never' }}</dd>
</div>
</dl> </dl>
</article> </div>
<!-- Subscription --> <!-- Subscription -->
<article> <div class="card">
<header><h3>Subscription</h3></header> <h3 class="text-lg mb-4">Subscription</h3>
<dl> <dl class="space-y-3 text-sm">
<dt>Plan</dt> <div class="flex justify-between">
<dd> <dt class="text-stone">Plan</dt>
{% if user.plan %} <dd>
<mark>{{ user.plan }}</mark> {% if user.plan %}
{% else %} <span class="badge">{{ user.plan }}</span>
free {% else %}
{% endif %} <span class="text-stone">free</span>
</dd> {% endif %}
</dd>
<dt>Status</dt> </div>
<dd>{{ user.sub_status or 'N/A' }}</dd> <div class="flex justify-between">
<dt class="text-stone">Status</dt>
<dd>{{ user.sub_status or 'N/A' }}</dd>
</div>
{% if user.stripe_customer_id %} {% if user.stripe_customer_id %}
<dt>Stripe Customer</dt> <div class="flex justify-between">
<dd> <dt class="text-stone">Paddle Customer</dt>
<a href="https://dashboard.stripe.com/customers/{{ user.stripe_customer_id }}" target="_blank"> <dd class="mono">
{{ user.stripe_customer_id }} <a href="https://vendors.paddle.com/customers/{{ user.stripe_customer_id }}" target="_blank">
</a> {{ user.stripe_customer_id }}
</dd> </a>
</dd>
</div>
{% endif %} {% endif %}
</dl> </dl>
</article>
</div>
<!-- Actions -->
<article>
<header><h3>Actions</h3></header>
<div style="display: flex; gap: 1rem;">
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Impersonate User</button>
</form>
</div> </div>
</article> </div>
</main>
<!-- Actions -->
<div class="card">
<h3 class="text-lg mb-4">Actions</h3>
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-secondary">Impersonate User</button>
</form>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,83 +1,73 @@
{% extends "base.html" %} {% set admin_page = "users" %}
{% extends "admin/base_admin.html" %}
{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Users - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Users{% endblock %}
{% block content %} {% block admin_content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Users</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline">← Dashboard</a>
</header>
<!-- Search --> <!-- Search -->
<form method="get" style="margin-bottom: 2rem;"> <form method="get" class="mb-6">
<div class="grid"> <div class="flex gap-3 max-w-md">
<input <input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
type="search" <button type="submit" class="btn">Search</button>
name="search"
placeholder="Search by email..."
value="{{ search }}"
>
<button type="submit">Search</button>
</div> </div>
</form> </form>
<!-- User Table --> <!-- User Table -->
<article> <div class="card" style="padding:0;overflow:hidden;">
{% if users %} {% if users %}
<table> <div class="overflow-x-auto">
<thead> <table class="table">
<tr> <thead>
<th>ID</th> <tr>
<th>Email</th> <th>ID</th>
<th>Name</th> <th>Email</th>
<th>Plan</th> <th>Name</th>
<th>Joined</th> <th>Plan</th>
<th>Last Login</th> <th>Joined</th>
<th></th> <th>Last Login</th>
</tr> <th></th>
</thead> </tr>
<tbody> </thead>
{% for u in users %} <tbody>
<tr> {% for u in users %}
<td>{{ u.id }}</td> <tr>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td> <td class="mono text-sm">{{ u.id }}</td>
<td>{{ u.name or '-' }}</td> <td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td> <td>{{ u.name or '-' }}</td>
{% if u.plan %} <td>
<mark>{{ u.plan }}</mark> {% if u.plan %}
{% else %} <span class="badge">{{ u.plan }}</span>
free {% else %}
{% endif %} <span class="text-sm text-stone">free</span>
</td> {% endif %}
<td>{{ u.created_at[:10] }}</td> </td>
<td>{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td> <td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td> <td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" style="margin: 0;"> <td>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
Impersonate <button type="submit" class="btn-outline btn-sm">Impersonate</button>
</button> </form>
</form> </td>
</td> </tr>
</tr> {% endfor %}
{% endfor %} </tbody>
</tbody> </table>
</table> </div>
<!-- Pagination --> <!-- Pagination -->
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;"> <div class="flex gap-4 justify-center" style="padding:16px;">
{% if page > 1 %} {% if page > 1 %}
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}"> Previous</a> <a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="text-sm">&larr; Previous</a>
{% endif %} {% endif %}
<span>Page {{ page }}</span> <span class="text-stone text-sm">Page {{ page }}</span>
{% if users | length == 50 %} {% if users | length == 50 %}
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next </a> <a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="text-sm">Next &rarr;</a>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<p>No users found.</p> <p class="text-stone text-sm" style="padding:16px;">No users found.</p>
{% endif %} {% endif %}
</article> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,63 @@
{% set admin_page = "waitlist" %}
{% extends "admin/base_admin.html" %}
{% block title %}Waitlist - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Waitlist{% endblock %}
{% block admin_content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">Waitlist</h2>
<p class="text-sm text-stone" style="margin:4px 0 0;">
{{ total }} {% if total == 1 %}person{% else %}people{% endif %} waiting
{% if not config.WAITLIST_MODE %}
&middot; <span style="color:#15803D;font-weight:600;">Waitlist mode is OFF</span>
{% else %}
&middot; <span style="color:#D97706;font-weight:600;">Waitlist mode is ACTIVE</span>
{% endif %}
</p>
</div>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if entries %}
<table class="table">
<thead>
<tr>
<th>Email</th>
<th>Intent</th>
<th>Plan</th>
<th>Source</th>
<th>IP</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.email }}</td>
<td><span class="badge">{{ entry.intent }}</span></td>
<td>
{% if entry.plan %}
<span class="badge-success">{{ entry.plan }}</span>
{% else %}
<span class="text-stone text-xs">&mdash;</span>
{% endif %}
</td>
<td class="text-sm text-stone">{{ entry.source or '&mdash;' }}</td>
<td class="mono text-sm text-stone">{{ entry.ip_address or '&mdash;' }}</td>
<td class="mono text-sm">{{ entry.created_at[:16] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-stone text-sm">No waitlist entries yet.</p>
{% if not config.WAITLIST_MODE %}
<p class="text-xs text-stone mt-2">Set <code>WAITLIST_MODE=true</code> to enable the waitlist gate on /auth/signup.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g
from ..core import config, fetch_one, fetch_all, execute, csrf_protect from ..core import config, fetch_one, fetch_all, execute, csrf_protect, waitlist_gate, capture_waitlist_email
# Blueprint with its own template folder # Blueprint with its own template folder
bp = Blueprint( bp = Blueprint(
@@ -213,6 +213,7 @@ async def login():
@bp.route("/signup", methods=["GET", "POST"]) @bp.route("/signup", methods=["GET", "POST"])
@csrf_protect @csrf_protect
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
async def signup(): async def signup():
"""Signup page - same as login but with different messaging.""" """Signup page - same as login but with different messaging."""
if g.get("user"): if g.get("user"):
@@ -225,6 +226,19 @@ async def signup():
email = form.get("email", "").strip().lower() email = form.get("email", "").strip().lower()
selected_plan = form.get("plan", "free") selected_plan = form.get("plan", "free")
# Waitlist mode: capture email and redirect to confirmed page
if config.WAITLIST_MODE:
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.signup", plan=selected_plan))
await capture_waitlist_email(
email,
intent="signup",
plan=selected_plan,
ip_address=request.remote_addr,
)
return redirect(url_for("auth.waitlist_confirmed"))
if not email or "@" not in email: if not email or "@" not in email:
await flash("Please enter a valid email address.", "error") await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.signup", plan=selected_plan)) return redirect(url_for("auth.signup", plan=selected_plan))
@@ -304,6 +318,12 @@ async def magic_link_sent():
return await render_template("magic_link_sent.html", email=email) return await render_template("magic_link_sent.html", email=email)
@bp.route("/waitlist-confirmed")
async def waitlist_confirmed():
"""Confirmation page after joining the waitlist."""
return await render_template("waitlist_confirmed.html")
@bp.route("/dev-login") @bp.route("/dev-login")
async def dev_login(): async def dev_login():
"""Instant login for development. Only works in DEBUG mode.""" """Instant login for development. Only works in DEBUG mode."""

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Join the Waitlist - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8">
<div style="text-align:center;margin-bottom:20px;">
<div style="width:48px;height:48px;background:#FEF3C7;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 12px;">
<svg width="22" height="22" fill="none" stroke="#B45309" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/>
<line x1="22" y1="11" x2="16" y2="11"/>
</svg>
</div>
<h1 class="text-2xl mb-1">Join the Waitlist</h1>
<p class="text-stone text-sm">Be the first to know when BeanFlows launches.</p>
</div>
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="plan" value="{{ plan or 'free' }}">
<div>
<label for="email" class="form-label">Email</label>
<input type="email" id="email" name="email" class="form-input"
placeholder="you@example.com" required autofocus>
</div>
{% if plan and plan != 'free' %}
<p class="form-hint">
We'll remember your interest in the <strong>{{ plan | title }}</strong> plan.
</p>
{% endif %}
<button type="submit" class="btn w-full">Get Early Access</button>
</form>
<p class="text-center text-xs text-stone mt-6">
Coffee market data, first. No spam.
</p>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}You're on the Waitlist! - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8 text-center">
<div style="width:56px;height:56px;background:#ECFDF5;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<svg width="26" height="26" fill="none" stroke="#15803D" stroke-width="2.5" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<h1 class="text-2xl mb-2">You're on the list!</h1>
<p class="text-stone mb-6">
Thanks for your interest in {{ config.APP_NAME }}. We'll send you an email
as soon as we're ready to welcome new coffee traders.
</p>
<div class="card" style="background:#F5F0EB;border-color:#E8DFD5;margin-bottom:0;">
<p class="text-sm font-semibold mb-2" style="color:#2C1810;">What happens next?</p>
<ul class="text-sm text-stone space-y-1 text-left" style="list-style:none;padding:0;margin:0;">
<li>&#10003;&nbsp; You'll get an email when we launch</li>
<li>&#10003;&nbsp; Early access members get priority onboarding</li>
<li>&#10003;&nbsp; No spam, ever — we promise</li>
</ul>
</div>
<p class="text-xs text-stone mt-6">
<a href="{{ url_for('public.landing') }}">&larr; Back to home</a>
</p>
</div>
</main>
{% endblock %}

View File

@@ -14,7 +14,7 @@ from pathlib import Path
from functools import wraps from functools import wraps
from datetime import datetime, timedelta from datetime import datetime, timedelta
from contextvars import ContextVar from contextvars import ContextVar
from quart import g, make_response, request, session from quart import g, make_response, render_template, request, session
from dotenv import load_dotenv from dotenv import load_dotenv
# web/.env is three levels up from web/src/beanflows/core.py # web/.env is three levels up from web/src/beanflows/core.py
@@ -58,6 +58,9 @@ class Config:
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
PLAN_FEATURES: dict = { PLAN_FEATURES: dict = {
"free": ["dashboard", "coffee_only", "limited_history"], "free": ["dashboard", "coffee_only", "limited_history"],
"starter": ["dashboard", "coffee_only", "full_history", "export", "api"], "starter": ["dashboard", "coffee_only", "full_history", "export", "api"],
@@ -354,6 +357,60 @@ async def purge_deleted(table: str, days: int = 30) -> int:
) )
# =============================================================================
# Waitlist
# =============================================================================
def waitlist_gate(template: str, **extra_context):
"""Intercept GET requests when WAITLIST_MODE=true and render the waitlist template."""
def decorator(f):
@wraps(f)
async def wrapper(*args, **kwargs):
if config.WAITLIST_MODE and request.method == "GET":
ctx = {}
for k, v in extra_context.items():
ctx[k] = v() if callable(v) else v
return await render_template(template, **ctx)
return await f(*args, **kwargs)
return wrapper
return decorator
async def capture_waitlist_email(
email: str,
intent: str = "signup",
source: str = None,
plan: str = None,
ip_address: str = None,
) -> bool:
"""Insert email into waitlist (INSERT OR IGNORE) and enqueue confirmation email."""
result = await execute(
"""
INSERT OR IGNORE INTO waitlist (email, intent, source, plan, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(email.lower(), intent, source, plan, ip_address, datetime.utcnow().isoformat()),
)
if result:
from .worker import enqueue
await enqueue("send_waitlist_confirmation", {"email": email})
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY:
try:
resend.api_key = config.RESEND_API_KEY
resend.Contacts.create({
"email": email,
"audience_id": config.RESEND_AUDIENCE_WAITLIST,
"unsubscribed": False,
})
except Exception as e:
print(f"[WAITLIST] Resend audience error: {e}")
return True
return False
# ============================================================================= # =============================================================================
# A/B Testing # A/B Testing
# ============================================================================= # =============================================================================

View File

@@ -140,6 +140,34 @@ CREATE TABLE IF NOT EXISTS tasks (
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at);
-- Feedback
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
page_url TEXT,
message TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_feedback_is_read ON feedback(is_read);
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);
-- Waitlist
CREATE TABLE IF NOT EXISTS waitlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
intent TEXT NOT NULL DEFAULT 'signup',
source TEXT,
plan TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(email, intent)
);
CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email);
CREATE INDEX IF NOT EXISTS idx_waitlist_intent ON waitlist(intent);
-- Items (example domain entity - replace with your domain) -- Items (example domain entity - replace with your domain)
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -1,11 +1,12 @@
""" """
Public domain: landing page, marketing pages, legal pages. Public domain: landing page, marketing pages, legal pages, feedback, sitemap.
""" """
from pathlib import Path from pathlib import Path
from datetime import datetime
from quart import Blueprint, render_template from quart import Blueprint, render_template, request, g, make_response
from ..core import config from ..core import config, execute, check_rate_limit, csrf_protect
# Blueprint with its own template folder # Blueprint with its own template folder
bp = Blueprint( bp = Blueprint(
@@ -43,3 +44,78 @@ async def privacy():
async def about(): async def about():
"""About page.""" """About page."""
return await render_template("about.html") return await render_template("about.html")
@bp.route("/feedback", methods=["POST"])
@csrf_protect
async def feedback():
"""Submit feedback. Rate-limited to 5/hour per IP. Returns HTMX snippet."""
ip = request.remote_addr or "unknown"
allowed, _ = await check_rate_limit(f"feedback:{ip}", limit=5, window=3600)
if not allowed:
return '<p style="color:#EF4444;font-size:13px;">Too many submissions. Try again later.</p>', 429
form = await request.form
message = (form.get("message") or "").strip()
page_url = (form.get("page_url") or "").strip()[:500]
if not message:
return '<p style="color:#EF4444;font-size:13px;">Message cannot be empty.</p>', 400
if len(message) > 2000:
return '<p style="color:#EF4444;font-size:13px;">Message too long (max 2000 characters).</p>', 400
user_id = g.user["id"] if g.get("user") else None
await execute(
"""
INSERT INTO feedback (user_id, page_url, message, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, page_url, message, datetime.utcnow().isoformat()),
)
return '<p style="color:#15803D;font-size:13px;font-weight:500;">Thanks for your feedback!</p>'
@bp.route("/robots.txt")
async def robots_txt():
"""robots.txt for search engines."""
body = "User-agent: *\n"
body += "Disallow: /admin/\n"
body += "Disallow: /auth/\n"
body += "Disallow: /dashboard/\n"
body += "Disallow: /billing/\n"
body += f"Sitemap: {config.BASE_URL.rstrip('/')}/sitemap.xml\n"
response = await make_response(body)
response.headers["Content-Type"] = "text/plain"
return response
@bp.route("/sitemap.xml")
async def sitemap_xml():
"""XML sitemap for search engines."""
base = config.BASE_URL.rstrip("/")
def url_entry(loc: str, priority: str = "0.5", changefreq: str = "weekly") -> str:
return (
f" <url>\n"
f" <loc>{loc}</loc>\n"
f" <changefreq>{changefreq}</changefreq>\n"
f" <priority>{priority}</priority>\n"
f" </url>\n"
)
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
xml += url_entry(f"{base}/", priority="1.0", changefreq="daily")
xml += url_entry(f"{base}/features", priority="0.8")
xml += url_entry(f"{base}/about", priority="0.6")
xml += url_entry(f"{base}/pricing", priority="0.8")
xml += url_entry(f"{base}/terms", priority="0.3", changefreq="yearly")
xml += url_entry(f"{base}/privacy", priority="0.3", changefreq="yearly")
# Add dynamic BeanFlows entries here (e.g. public commodity pages)
xml += "</urlset>"
response = await make_response(xml)
response.headers["Content-Type"] = "application/xml"
return response

View File

@@ -96,6 +96,93 @@
<!-- HTMX --> <!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- Feedback Widget -->
<div id="feedback-widget" style="position:fixed;bottom:20px;right:20px;z-index:100;">
<button
id="feedback-btn"
onclick="document.getElementById('feedback-popover').classList.toggle('open')"
style="
background:#2C1810;color:#fff;border:none;border-radius:20px;
padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;
box-shadow:0 4px 12px rgba(0,0,0,0.2);display:flex;align-items:center;gap:6px;
transition:background 0.15s;
"
onmouseover="this.style.background='#4A2C1A'"
onmouseout="this.style.background='#2C1810'"
>
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Feedback
</button>
<div
id="feedback-popover"
style="
display:none;position:absolute;bottom:44px;right:0;
width:300px;background:#fff;border:1px solid #E8DFD5;
border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,0.12);
padding:16px;
"
>
<p style="font-size:14px;font-weight:600;color:#2C1810;margin:0 0 4px;">Share feedback</p>
<p style="font-size:12px;color:#78716C;margin:0 0 12px;">What's on your mind?</p>
<form
hx-post="{{ url_for('public.feedback') }}"
hx-target="#feedback-result"
hx-swap="innerHTML"
hx-on::after-request="this.reset()"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="page_url" id="feedback-page-url" value="">
<textarea
name="message"
placeholder="Tell us what you think..."
required
maxlength="2000"
style="
width:100%;box-sizing:border-box;resize:vertical;min-height:80px;
border:1px solid #E8DFD5;border-radius:6px;padding:8px 10px;
font-size:13px;color:#2C1810;outline:none;font-family:inherit;
transition:border-color 0.15s;
"
onfocus="this.style.borderColor='#B45309'"
onblur="this.style.borderColor='#E8DFD5'"
></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<div id="feedback-result" style="font-size:12px;"></div>
<button
type="submit"
style="
background:#B45309;color:#fff;border:none;border-radius:6px;
padding:6px 14px;font-size:13px;font-weight:600;cursor:pointer;
transition:background 0.15s;flex-shrink:0;
"
onmouseover="this.style.background='#92400E'"
onmouseout="this.style.background='#B45309'"
>Send</button>
</div>
</form>
</div>
</div>
<style>
#feedback-popover.open { display: block; }
</style>
<script>
var urlInput = document.getElementById('feedback-page-url');
if (urlInput) urlInput.value = window.location.href;
document.addEventListener('click', function(e) {
var widget = document.getElementById('feedback-widget');
if (widget && !widget.contains(e.target)) {
var popover = document.getElementById('feedback-popover');
if (popover) popover.classList.remove('open');
}
});
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -181,6 +181,23 @@ async def handle_send_welcome(payload: dict) -> None:
) )
@task("send_waitlist_confirmation")
async def handle_send_waitlist_confirmation(payload: dict) -> None:
"""Send waitlist confirmation email."""
body = (
f'<h2 style="margin:0 0 16px;color:#2C1810;font-size:20px;">You\'re on the {config.APP_NAME} waitlist!</h2>'
f"<p>Thanks for your interest in BeanFlows. We'll send you an email the moment we're ready to welcome new coffee traders.</p>"
f'<p style="margin-top:16px;">No spam — just the launch announcement when it\'s time.</p>'
f'<p style="margin-top:24px;font-size:13px;color:#78716C;">The {config.APP_NAME} team</p>'
)
await send_email(
to=payload["email"],
subject=f"You're on the {config.APP_NAME} waitlist",
html=_email_wrap(body),
)
@task("cleanup_expired_tokens") @task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None: async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens.""" """Clean up expired auth tokens."""