Add admin sidebar layout, feedback, waitlist, sitemap to BeanFlows web

- Admin sidebar layout (base_admin.html) with espresso/copper coffee theme,
  220px sidebar, responsive collapse, nav for Dashboard/Users/Tasks/Feedback/Waitlist
- Convert all admin templates to extend base_admin.html using Tailwind classes
- Feedback system: schema, public POST route (rate-limited), base.html widget
  with HTMX popover (coffee-themed), admin viewer with mark-read
- Waitlist mode: WAITLIST_MODE config, waitlist_gate decorator,
  capture_waitlist_email helper, auth route integration, confirmation pages,
  send_waitlist_confirmation worker task, admin table
- Sitemap.xml and robots.txt public routes
- Dashboard stats updated with waitlist_count, feedback_unread alongside
  existing commodity DuckDB analytics stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-20 02:27:26 +01:00
parent e80e262e25
commit 48bea5c198
17 changed files with 1305 additions and 298 deletions

View File

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

View File

@@ -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_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 = {"commodity_count": 0, "min_year": None, "max_year": None}
try:
@@ -88,6 +93,8 @@ async def get_dashboard_stats() -> dict:
"active_subscriptions": subs["count"] if subs else 0,
"tasks_pending": tasks_pending["count"] if tasks_pending 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),
"data_year_range": f"{analytics.get('min_year', '?')}{analytics.get('max_year', '?')}",
}
@@ -171,6 +178,28 @@ async def delete_task(task_id: int) -> bool:
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
# =============================================================================
@@ -348,3 +377,42 @@ async def task_delete(task_id: int):
else:
await flash("Could not delete task.", "error")
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 %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>Admin Dashboard</h1>
{% if session.get('admin_impersonating') %}
<mark>Currently impersonating a user</mark>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline" style="padding: 0.25rem 0.5rem;">Stop</button>
</form>
{% block admin_content %}
<!-- Stats Grid -->
<div class="grid-4 mb-8">
<div class="card text-center" style="margin-bottom:0;">
<p class="card-header">Total Users</p>
<p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.users_total }}</p>
<p class="text-xs text-stone mt-1">+{{ stats.users_today }} today &middot; +{{ stats.users_week }} this week</p>
</div>
<div class="card text-center" style="margin-bottom:0;">
<p class="card-header">Active Subscriptions</p>
<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 %}
</div>
<form method="post" action="{{ url_for('admin.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline">Logout</button>
</form>
</header>
<!-- 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 class="card text-center" style="margin-bottom:0;">
<p class="card-header">Coffee Data</p>
<p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.commodity_count }}</p>
<p class="text-xs text-stone mt-1">commodities &middot; {{ stats.data_year_range }}</p>
</div>
</div>
<!-- Quick Links -->
<div class="grid" style="margin-bottom: 2rem;">
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">All Users</a>
<a href="{{ url_for('admin.tasks') }}" role="button" class="secondary outline">Task Queue</a>
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">View as User</a>
<!-- Feedback/Waitlist row -->
<div class="grid-2 mb-8">
<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;">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 class="grid">
<div class="grid-2">
<!-- Recent Users -->
<section>
<h2>Recent Users</h2>
<article>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<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 %}
<table>
<table class="table">
<thead>
<tr>
<th>Email</th>
@@ -69,28 +73,34 @@
<tbody>
{% for u in recent_users %}
<tr>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></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>{{ u.plan or 'free' }}</td>
<td>{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.users') }}">View all →</a>
{% else %}
<p>No users yet.</p>
<p class="text-stone text-sm" style="padding:16px;">No users yet.</p>
{% endif %}
</article>
</div>
</section>
<!-- Failed Tasks -->
<section>
<h2>Failed Tasks</h2>
<article>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<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 %}
<table>
<table class="table">
<thead>
<tr>
<th>Task</th>
@@ -101,24 +111,22 @@
<tbody>
{% for task in failed_tasks[:5] %}
<tr>
<td>{{ task.task_name }}</td>
<td><small>{{ task.error[:50] }}...</small></td>
<td><code class="text-xs px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
<td><span class="text-xs text-stone">{{ (task.error or '')[:50] }}{% if task.error and task.error|length > 50 %}...{% endif %}</span></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() }}">
<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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.tasks') }}">View all →</a>
{% 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 %}
</article>
</div>
</section>
</div>
</main>
{% 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 %}
<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>
{% block admin_content %}
<!-- Failed Tasks -->
{% if failed_tasks %}
<section>
<h2 style="color: var(--del-color);">Failed Tasks ({{ failed_tasks | length }})</h2>
<article style="border-color: var(--del-color);">
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Error</th>
<th>Retries</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for task in failed_tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td>
<details>
<summary>{{ task.error[:40] if task.error else 'No error' }}...</summary>
<pre style="font-size: 0.75rem; white-space: pre-wrap;">{{ task.error }}</pre>
</details>
</td>
<td>{{ task.retries }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
</form>
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
<section class="mb-8">
<h2 class="text-xl mb-4" style="color:#EF4444;">Failed Tasks ({{ failed_tasks | length }})</h2>
<div class="card" style="padding:0;overflow:hidden;border-color:rgba(239,68,68,0.3);">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Error</th>
<th>Retries</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for task in failed_tasks %}
<tr>
<td class="mono text-sm">{{ task.id }}</td>
<td><code class="text-sm px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
<td>
<details>
<summary class="cursor-pointer text-xs text-stone">{{ task.error[:40] if task.error else 'No error' }}...</summary>
<pre class="text-xs mt-2 whitespace-pre-wrap text-stone-dark">{{ task.error }}</pre>
</details>
</td>
<td class="mono text-sm">{{ task.retries }}</td>
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
<td>
<div class="flex gap-2">
<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() }}">
<button type="submit" class="btn-outline btn-sm">Retry</button>
</form>
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" style="color:#EF4444;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
<!-- All Tasks -->
<section>
<h2>Recent Tasks</h2>
<article>
<h2 class="text-xl mb-4">Recent Tasks</h2>
<div class="card" style="padding:0;overflow:hidden;">
{% if tasks %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Status</th>
<th>Run At</th>
<th>Created</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td>
{% if task.status == 'complete' %}
<span style="color: var(--ins-color);">✓ complete</span>
{% elif task.status == 'failed' %}
<span style="color: var(--del-color);">✗ failed</span>
{% elif task.status == 'pending' %}
<span style="color: var(--mark-background-color);">○ pending</span>
{% else %}
{{ task.status }}
{% endif %}
</td>
<td>{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Status</th>
<th>Run At</th>
<th>Created</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td class="mono text-sm">{{ task.id }}</td>
<td><code class="text-sm px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
<td>
{% if task.status == 'complete' %}
<span class="badge-success">complete</span>
{% elif task.status == 'failed' %}
<span class="badge-danger">failed</span>
{% elif task.status == 'pending' %}
<span class="badge-warning">pending</span>
{% else %}
<span class="badge">{{ task.status }}</span>
{% endif %}
</td>
<td class="mono text-sm">{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
<td class="mono text-sm">{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No tasks in queue.</p>
<p class="text-stone text-sm" style="padding:16px;">No tasks in queue.</p>
{% endif %}
</article>
</div>
</section>
</main>
{% 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 %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>{{ user.email }}</h1>
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">← Users</a>
</header>
<div class="grid">
{% block admin_content %}
<div style="margin-bottom:16px;">
<a href="{{ url_for('admin.users') }}" class="text-sm">&larr; Back to Users</a>
<h2 class="text-xl mt-1">{{ user.email }}</h2>
</div>
<div class="grid-2 mb-6">
<!-- User Info -->
<article>
<header><h3>User Info</h3></header>
<dl>
<dt>ID</dt>
<dd>{{ user.id }}</dd>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Name</dt>
<dd>{{ user.name or '-' }}</dd>
<dt>Created</dt>
<dd>{{ user.created_at }}</dd>
<dt>Last Login</dt>
<dd>{{ user.last_login_at or 'Never' }}</dd>
<div class="card">
<h3 class="text-lg mb-4">User Info</h3>
<dl class="space-y-3 text-sm">
<div class="flex justify-between">
<dt class="text-stone">ID</dt>
<dd class="mono">{{ user.id }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-stone">Email</dt>
<dd>{{ user.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-stone">Name</dt>
<dd>{{ user.name or '-' }}</dd>
</div>
<div class="flex justify-between">
<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>
</article>
</div>
<!-- Subscription -->
<article>
<header><h3>Subscription</h3></header>
<dl>
<dt>Plan</dt>
<dd>
{% if user.plan %}
<mark>{{ user.plan }}</mark>
{% else %}
free
{% endif %}
</dd>
<dt>Status</dt>
<dd>{{ user.sub_status or 'N/A' }}</dd>
<div class="card">
<h3 class="text-lg mb-4">Subscription</h3>
<dl class="space-y-3 text-sm">
<div class="flex justify-between">
<dt class="text-stone">Plan</dt>
<dd>
{% if user.plan %}
<span class="badge">{{ user.plan }}</span>
{% else %}
<span class="text-stone">free</span>
{% endif %}
</dd>
</div>
<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 %}
<dt>Stripe Customer</dt>
<dd>
<a href="https://dashboard.stripe.com/customers/{{ user.stripe_customer_id }}" target="_blank">
{{ user.stripe_customer_id }}
</a>
</dd>
<div class="flex justify-between">
<dt class="text-stone">Paddle Customer</dt>
<dd class="mono">
<a href="https://vendors.paddle.com/customers/{{ user.stripe_customer_id }}" target="_blank">
{{ user.stripe_customer_id }}
</a>
</dd>
</div>
{% endif %}
</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>
</article>
</main>
</div>
<!-- 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 %}

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 %}
<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>
{% block admin_content %}
<!-- Search -->
<form method="get" style="margin-bottom: 2rem;">
<div class="grid">
<input
type="search"
name="search"
placeholder="Search by email..."
value="{{ search }}"
>
<button type="submit">Search</button>
<form method="get" class="mb-6">
<div class="flex gap-3 max-w-md">
<input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
<button type="submit" class="btn">Search</button>
</div>
</form>
<!-- User Table -->
<article>
<div class="card" style="padding:0;overflow:hidden;">
{% if users %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<mark>{{ u.plan }}</mark>
{% else %}
free
{% endif %}
</td>
<td>{{ u.created_at[:10] }}</td>
<td>{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">
Impersonate
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="mono text-sm">{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<span class="badge">{{ u.plan }}</span>
{% else %}
<span class="text-sm text-stone">free</span>
{% endif %}
</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 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 %}
<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 %}
<span>Page {{ page }}</span>
<span class="text-stone text-sm">Page {{ page }}</span>
{% 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 %}
</div>
{% else %}
<p>No users found.</p>
<p class="text-stone text-sm" style="padding:16px;">No users found.</p>
{% endif %}
</article>
</main>
</div>
{% 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 ..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
bp = Blueprint(
@@ -213,6 +213,7 @@ async def login():
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
async def signup():
"""Signup page - same as login but with different messaging."""
if g.get("user"):
@@ -225,6 +226,19 @@ async def signup():
email = form.get("email", "").strip().lower()
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:
await flash("Please enter a valid email address.", "error")
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)
@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")
async def dev_login():
"""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 datetime import datetime, timedelta
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
# 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_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 = {
"free": ["dashboard", "coffee_only", "limited_history"],
"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
# =============================================================================

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);
-- 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)
CREATE TABLE IF NOT EXISTS items (
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 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
bp = Blueprint(
@@ -43,3 +44,78 @@ async def privacy():
async def about():
"""About page."""
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 -->
<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 %}
</body>
</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")
async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens."""