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:
@@ -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=
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
428
web/src/beanflows/admin/templates/admin/base_admin.html
Normal file
428
web/src/beanflows/admin/templates/admin/base_admin.html
Normal 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>
|
||||
75
web/src/beanflows/admin/templates/admin/feedback.html
Normal file
75
web/src/beanflows/admin/templates/admin/feedback.html
Normal 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 %}
|
||||
· <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">—</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 %}
|
||||
@@ -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 · +{{ 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"> </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 · {{ 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 →</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 →</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 →</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 →</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 — all clear.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">← 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 %}
|
||||
|
||||
@@ -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">← 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 →</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 %}
|
||||
|
||||
63
web/src/beanflows/admin/templates/admin/waitlist.html
Normal file
63
web/src/beanflows/admin/templates/admin/waitlist.html
Normal 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 %}
|
||||
· <span style="color:#15803D;font-weight:600;">Waitlist mode is OFF</span>
|
||||
{% else %}
|
||||
· <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">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm text-stone">{{ entry.source or '—' }}</td>
|
||||
<td class="mono text-sm text-stone">{{ entry.ip_address or '—' }}</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 %}
|
||||
@@ -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."""
|
||||
|
||||
45
web/src/beanflows/auth/templates/waitlist.html
Normal file
45
web/src/beanflows/auth/templates/waitlist.html
Normal 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 %}
|
||||
34
web/src/beanflows/auth/templates/waitlist_confirmed.html
Normal file
34
web/src/beanflows/auth/templates/waitlist_confirmed.html
Normal 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>✓ You'll get an email when we launch</li>
|
||||
<li>✓ Early access members get priority onboarding</li>
|
||||
<li>✓ No spam, ever — we promise</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-stone mt-6">
|
||||
<a href="{{ url_for('public.landing') }}">← Back to home</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user