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 limiting
|
||||||
RATE_LIMIT_REQUESTS=100
|
RATE_LIMIT_REQUESTS=100
|
||||||
RATE_LIMIT_WINDOW=60
|
RATE_LIMIT_WINDOW=60
|
||||||
|
|
||||||
|
# Waitlist (set to true to enable waitlist gate on /auth/signup)
|
||||||
|
WAITLIST_MODE=false
|
||||||
|
RESEND_AUDIENCE_WAITLIST=
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ async def get_dashboard_stats() -> dict:
|
|||||||
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
|
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
|
||||||
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
|
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
|
||||||
|
|
||||||
|
waitlist_count = await fetch_one("SELECT COUNT(*) as count FROM waitlist")
|
||||||
|
feedback_unread = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM feedback WHERE is_read = 0"
|
||||||
|
)
|
||||||
|
|
||||||
# Analytics stats (DuckDB)
|
# Analytics stats (DuckDB)
|
||||||
analytics = {"commodity_count": 0, "min_year": None, "max_year": None}
|
analytics = {"commodity_count": 0, "min_year": None, "max_year": None}
|
||||||
try:
|
try:
|
||||||
@@ -88,6 +93,8 @@ async def get_dashboard_stats() -> dict:
|
|||||||
"active_subscriptions": subs["count"] if subs else 0,
|
"active_subscriptions": subs["count"] if subs else 0,
|
||||||
"tasks_pending": tasks_pending["count"] if tasks_pending else 0,
|
"tasks_pending": tasks_pending["count"] if tasks_pending else 0,
|
||||||
"tasks_failed": tasks_failed["count"] if tasks_failed else 0,
|
"tasks_failed": tasks_failed["count"] if tasks_failed else 0,
|
||||||
|
"waitlist_count": waitlist_count["count"] if waitlist_count else 0,
|
||||||
|
"feedback_unread": feedback_unread["count"] if feedback_unread else 0,
|
||||||
"commodity_count": analytics.get("commodity_count", 0),
|
"commodity_count": analytics.get("commodity_count", 0),
|
||||||
"data_year_range": f"{analytics.get('min_year', '?')}–{analytics.get('max_year', '?')}",
|
"data_year_range": f"{analytics.get('min_year', '?')}–{analytics.get('max_year', '?')}",
|
||||||
}
|
}
|
||||||
@@ -171,6 +178,28 @@ async def delete_task(task_id: int) -> bool:
|
|||||||
return result > 0
|
return result > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_feedback(limit: int = 100) -> list[dict]:
|
||||||
|
"""Get feedback with user email joined."""
|
||||||
|
return await fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT f.*, u.email as user_email
|
||||||
|
FROM feedback f
|
||||||
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
ORDER BY f.is_read ASC, f.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_waitlist(limit: int = 500) -> list[dict]:
|
||||||
|
"""Get waitlist entries."""
|
||||||
|
return await fetch_all(
|
||||||
|
"SELECT * FROM waitlist ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(limit,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Decorators
|
# Decorators
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -348,3 +377,42 @@ async def task_delete(task_id: int):
|
|||||||
else:
|
else:
|
||||||
await flash("Could not delete task.", "error")
|
await flash("Could not delete task.", "error")
|
||||||
return redirect(url_for("admin.tasks"))
|
return redirect(url_for("admin.tasks"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/feedback")
|
||||||
|
@admin_required
|
||||||
|
async def feedback():
|
||||||
|
"""Feedback viewer."""
|
||||||
|
items = await get_feedback()
|
||||||
|
unread_count = sum(1 for f in items if not f["is_read"])
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/feedback.html",
|
||||||
|
feedback_items=items,
|
||||||
|
unread_count=unread_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/feedback/<int:feedback_id>/mark-read", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def feedback_mark_read(feedback_id: int):
|
||||||
|
"""Mark a feedback item as read."""
|
||||||
|
await execute(
|
||||||
|
"UPDATE feedback SET is_read = 1 WHERE id = ?",
|
||||||
|
(feedback_id,)
|
||||||
|
)
|
||||||
|
return redirect(url_for("admin.feedback"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/waitlist")
|
||||||
|
@admin_required
|
||||||
|
async def waitlist():
|
||||||
|
"""Waitlist admin view."""
|
||||||
|
entries = await get_waitlist()
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/waitlist.html",
|
||||||
|
entries=entries,
|
||||||
|
total=len(entries),
|
||||||
|
)
|
||||||
|
|||||||
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>
|
|
||||||
{% 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>
|
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid">
|
<div class="grid-4 mb-8">
|
||||||
<article>
|
<div class="card text-center" style="margin-bottom:0;">
|
||||||
<header><small>Total Users</small></header>
|
<p class="card-header">Total Users</p>
|
||||||
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.users_total }}</strong></p>
|
<p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.users_total }}</p>
|
||||||
<small>+{{ stats.users_today }} today, +{{ stats.users_week }} this week</small>
|
<p class="text-xs text-stone mt-1">+{{ stats.users_today }} today · +{{ stats.users_week }} this week</p>
|
||||||
</article>
|
</div>
|
||||||
|
<div class="card text-center" style="margin-bottom:0;">
|
||||||
<article>
|
<p class="card-header">Active Subscriptions</p>
|
||||||
<header><small>Active Subscriptions</small></header>
|
<p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.active_subscriptions }}</p>
|
||||||
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.active_subscriptions }}</strong></p>
|
<p class="text-xs text-stone mt-1"> </p>
|
||||||
</article>
|
</div>
|
||||||
|
<div class="card text-center" style="margin-bottom:0;">
|
||||||
<article>
|
<p class="card-header">Task Queue</p>
|
||||||
<header><small>Task Queue</small></header>
|
<p class="text-3xl font-bold metric" style="color:#2C1810;">{{ stats.tasks_pending }} <span class="text-base font-normal text-stone">pending</span></p>
|
||||||
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.tasks_pending }}</strong> pending</p>
|
|
||||||
{% if stats.tasks_failed > 0 %}
|
{% if stats.tasks_failed > 0 %}
|
||||||
<small style="color: var(--del-color);">{{ stats.tasks_failed }} failed</small>
|
<p class="text-xs mt-1" style="color:#EF4444;">{{ stats.tasks_failed }} failed</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<small style="color: var(--ins-color);">0 failed</small>
|
<p class="text-xs mt-1" style="color:#15803D;">All clear</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Feedback/Waitlist row -->
|
||||||
<div class="grid" style="margin-bottom: 2rem;">
|
<div class="grid-2 mb-8">
|
||||||
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">All Users</a>
|
<div class="card" style="margin-bottom:0;">
|
||||||
<a href="{{ url_for('admin.tasks') }}" role="button" class="secondary outline">Task Queue</a>
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||||
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">View as User</a>
|
<p class="card-header" style="margin:0;">Waitlist</p>
|
||||||
|
<a href="{{ url_for('admin.waitlist') }}" class="text-xs" style="color:#B45309;">View all →</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>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid-2">
|
||||||
<!-- Recent Users -->
|
<!-- Recent Users -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Recent Users</h2>
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
||||||
<article>
|
<h2 class="text-xl" style="margin:0;">Recent Users</h2>
|
||||||
|
<a href="{{ url_for('admin.users') }}" class="text-sm">View all →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
{% if recent_users %}
|
{% if recent_users %}
|
||||||
<table>
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
@@ -69,28 +73,34 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for u in recent_users %}
|
{% for u in recent_users %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a>
|
{% if u.plan %}
|
||||||
|
<span class="badge">{{ u.plan }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-stone">free</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ u.plan or 'free' }}</td>
|
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
|
||||||
<td>{{ u.created_at[:10] }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<a href="{{ url_for('admin.users') }}">View all →</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No users yet.</p>
|
<p class="text-stone text-sm" style="padding:16px;">No users yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Failed Tasks -->
|
<!-- Failed Tasks -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Failed Tasks</h2>
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
||||||
<article>
|
<h2 class="text-xl" style="margin:0;">Failed Tasks</h2>
|
||||||
|
<a href="{{ url_for('admin.tasks') }}" class="text-sm">View all →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
{% if failed_tasks %}
|
{% if failed_tasks %}
|
||||||
<table>
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Task</th>
|
<th>Task</th>
|
||||||
@@ -101,24 +111,22 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for task in failed_tasks[:5] %}
|
{% for task in failed_tasks[:5] %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ task.task_name }}</td>
|
<td><code class="text-xs px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
|
||||||
<td><small>{{ task.error[:50] }}...</small></td>
|
<td><span class="text-xs text-stone">{{ (task.error or '')[:50] }}{% if task.error and task.error|length > 50 %}...{% endif %}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
|
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" class="m-0">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
|
<button type="submit" class="btn-outline btn-sm">Retry</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<a href="{{ url_for('admin.tasks') }}">View all →</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="color: var(--ins-color);">✓ No failed tasks</p>
|
<p class="text-sm" style="padding:16px;color:#15803D;">No failed tasks — all clear.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
{% 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 -->
|
<!-- Failed Tasks -->
|
||||||
{% if failed_tasks %}
|
{% if failed_tasks %}
|
||||||
<section>
|
<section class="mb-8">
|
||||||
<h2 style="color: var(--del-color);">Failed Tasks ({{ failed_tasks | length }})</h2>
|
<h2 class="text-xl mb-4" style="color:#EF4444;">Failed Tasks ({{ failed_tasks | length }})</h2>
|
||||||
<article style="border-color: var(--del-color);">
|
<div class="card" style="padding:0;overflow:hidden;border-color:rgba(239,68,68,0.3);">
|
||||||
<table>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -28,25 +25,25 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for task in failed_tasks %}
|
{% for task in failed_tasks %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ task.id }}</td>
|
<td class="mono text-sm">{{ task.id }}</td>
|
||||||
<td><code>{{ task.task_name }}</code></td>
|
<td><code class="text-sm px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
<details>
|
<details>
|
||||||
<summary>{{ task.error[:40] if task.error else 'No error' }}...</summary>
|
<summary class="cursor-pointer text-xs text-stone">{{ task.error[:40] if task.error else 'No error' }}...</summary>
|
||||||
<pre style="font-size: 0.75rem; white-space: pre-wrap;">{{ task.error }}</pre>
|
<pre class="text-xs mt-2 whitespace-pre-wrap text-stone-dark">{{ task.error }}</pre>
|
||||||
</details>
|
</details>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ task.retries }}</td>
|
<td class="mono text-sm">{{ task.retries }}</td>
|
||||||
<td>{{ task.created_at[:16] }}</td>
|
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div class="flex gap-2">
|
||||||
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
|
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" class="m-0">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
|
<button type="submit" class="btn-outline btn-sm">Retry</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" style="margin: 0;">
|
<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() }}">
|
<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>
|
<button type="submit" class="btn-outline btn-sm" style="color:#EF4444;">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -54,16 +51,18 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- All Tasks -->
|
<!-- All Tasks -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Recent Tasks</h2>
|
<h2 class="text-xl mb-4">Recent Tasks</h2>
|
||||||
<article>
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<table>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -77,30 +76,30 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ task.id }}</td>
|
<td class="mono text-sm">{{ task.id }}</td>
|
||||||
<td><code>{{ task.task_name }}</code></td>
|
<td><code class="text-sm px-1 rounded" style="background:#F5F0EB;">{{ task.task_name }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
{% if task.status == 'complete' %}
|
{% if task.status == 'complete' %}
|
||||||
<span style="color: var(--ins-color);">✓ complete</span>
|
<span class="badge-success">complete</span>
|
||||||
{% elif task.status == 'failed' %}
|
{% elif task.status == 'failed' %}
|
||||||
<span style="color: var(--del-color);">✗ failed</span>
|
<span class="badge-danger">failed</span>
|
||||||
{% elif task.status == 'pending' %}
|
{% elif task.status == 'pending' %}
|
||||||
<span style="color: var(--mark-background-color);">○ pending</span>
|
<span class="badge-warning">pending</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ task.status }}
|
<span class="badge">{{ task.status }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ task.run_at[:16] if task.run_at else '-' }}</td>
|
<td class="mono text-sm">{{ task.run_at[:16] if task.run_at else '-' }}</td>
|
||||||
<td>{{ task.created_at[:16] }}</td>
|
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
|
||||||
<td>{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
|
<td class="mono text-sm">{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No tasks in queue.</p>
|
<p class="text-stone text-sm" style="padding:16px;">No tasks in queue.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,73 +1,81 @@
|
|||||||
{% extends "base.html" %}
|
{% set admin_page = "users" %}
|
||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
|
||||||
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}User: {{ user.email }} - {{ config.APP_NAME }} Admin{% endblock %}
|
||||||
|
{% block topbar_title %}User Detail{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<main class="container">
|
<div style="margin-bottom:16px;">
|
||||||
<header style="display: flex; justify-content: space-between; align-items: center;">
|
<a href="{{ url_for('admin.users') }}" class="text-sm">← Back to Users</a>
|
||||||
<h1>{{ user.email }}</h1>
|
<h2 class="text-xl mt-1">{{ user.email }}</h2>
|
||||||
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">← Users</a>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid-2 mb-6">
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
<article>
|
<div class="card">
|
||||||
<header><h3>User Info</h3></header>
|
<h3 class="text-lg mb-4">User Info</h3>
|
||||||
<dl>
|
<dl class="space-y-3 text-sm">
|
||||||
<dt>ID</dt>
|
<div class="flex justify-between">
|
||||||
<dd>{{ user.id }}</dd>
|
<dt class="text-stone">ID</dt>
|
||||||
|
<dd class="mono">{{ user.id }}</dd>
|
||||||
<dt>Email</dt>
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-stone">Email</dt>
|
||||||
<dd>{{ user.email }}</dd>
|
<dd>{{ user.email }}</dd>
|
||||||
|
</div>
|
||||||
<dt>Name</dt>
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-stone">Name</dt>
|
||||||
<dd>{{ user.name or '-' }}</dd>
|
<dd>{{ user.name or '-' }}</dd>
|
||||||
|
</div>
|
||||||
<dt>Created</dt>
|
<div class="flex justify-between">
|
||||||
<dd>{{ user.created_at }}</dd>
|
<dt class="text-stone">Created</dt>
|
||||||
|
<dd class="mono">{{ user.created_at }}</dd>
|
||||||
<dt>Last Login</dt>
|
</div>
|
||||||
<dd>{{ user.last_login_at or 'Never' }}</dd>
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-stone">Last Login</dt>
|
||||||
|
<dd class="mono">{{ user.last_login_at or 'Never' }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</div>
|
||||||
|
|
||||||
<!-- Subscription -->
|
<!-- Subscription -->
|
||||||
<article>
|
<div class="card">
|
||||||
<header><h3>Subscription</h3></header>
|
<h3 class="text-lg mb-4">Subscription</h3>
|
||||||
<dl>
|
<dl class="space-y-3 text-sm">
|
||||||
<dt>Plan</dt>
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-stone">Plan</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{% if user.plan %}
|
{% if user.plan %}
|
||||||
<mark>{{ user.plan }}</mark>
|
<span class="badge">{{ user.plan }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
free
|
<span class="text-stone">free</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
</div>
|
||||||
<dt>Status</dt>
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-stone">Status</dt>
|
||||||
<dd>{{ user.sub_status or 'N/A' }}</dd>
|
<dd>{{ user.sub_status or 'N/A' }}</dd>
|
||||||
|
</div>
|
||||||
{% if user.stripe_customer_id %}
|
{% if user.stripe_customer_id %}
|
||||||
<dt>Stripe Customer</dt>
|
<div class="flex justify-between">
|
||||||
<dd>
|
<dt class="text-stone">Paddle Customer</dt>
|
||||||
<a href="https://dashboard.stripe.com/customers/{{ user.stripe_customer_id }}" target="_blank">
|
<dd class="mono">
|
||||||
|
<a href="https://vendors.paddle.com/customers/{{ user.stripe_customer_id }}" target="_blank">
|
||||||
{{ user.stripe_customer_id }}
|
{{ user.stripe_customer_id }}
|
||||||
</a>
|
</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<article>
|
<div class="card">
|
||||||
<header><h3>Actions</h3></header>
|
<h3 class="text-lg mb-4">Actions</h3>
|
||||||
<div style="display: flex; gap: 1rem;">
|
|
||||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
|
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="secondary">Impersonate User</button>
|
<button type="submit" class="btn-secondary">Impersonate User</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,31 +1,23 @@
|
|||||||
{% 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 -->
|
<!-- Search -->
|
||||||
<form method="get" style="margin-bottom: 2rem;">
|
<form method="get" class="mb-6">
|
||||||
<div class="grid">
|
<div class="flex gap-3 max-w-md">
|
||||||
<input
|
<input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
|
||||||
type="search"
|
<button type="submit" class="btn">Search</button>
|
||||||
name="search"
|
|
||||||
placeholder="Search by email..."
|
|
||||||
value="{{ search }}"
|
|
||||||
>
|
|
||||||
<button type="submit">Search</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- User Table -->
|
<!-- User Table -->
|
||||||
<article>
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
{% if users %}
|
{% if users %}
|
||||||
<table>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -40,44 +32,42 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ u.id }}</td>
|
<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><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
|
||||||
<td>{{ u.name or '-' }}</td>
|
<td>{{ u.name or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if u.plan %}
|
{% if u.plan %}
|
||||||
<mark>{{ u.plan }}</mark>
|
<span class="badge">{{ u.plan }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
free
|
<span class="text-sm text-stone">free</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ u.created_at[:10] }}</td>
|
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
|
||||||
<td>{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
|
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" style="margin: 0;">
|
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">
|
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
|
||||||
Impersonate
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;">
|
<div class="flex gap-4 justify-center" style="padding:16px;">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">← Previous</a>
|
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="text-sm">← Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>Page {{ page }}</span>
|
<span class="text-stone text-sm">Page {{ page }}</span>
|
||||||
{% if users | length == 50 %}
|
{% if users | length == 50 %}
|
||||||
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next →</a>
|
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="text-sm">Next →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No users found.</p>
|
<p class="text-stone text-sm" style="padding:16px;">No users found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</div>
|
||||||
</main>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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 quart import Blueprint, render_template, request, redirect, url_for, session, flash, g
|
||||||
|
|
||||||
from ..core import config, fetch_one, fetch_all, execute, csrf_protect
|
from ..core import config, fetch_one, fetch_all, execute, csrf_protect, waitlist_gate, capture_waitlist_email
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -213,6 +213,7 @@ async def login():
|
|||||||
|
|
||||||
@bp.route("/signup", methods=["GET", "POST"])
|
@bp.route("/signup", methods=["GET", "POST"])
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
|
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||||
async def signup():
|
async def signup():
|
||||||
"""Signup page - same as login but with different messaging."""
|
"""Signup page - same as login but with different messaging."""
|
||||||
if g.get("user"):
|
if g.get("user"):
|
||||||
@@ -225,6 +226,19 @@ async def signup():
|
|||||||
email = form.get("email", "").strip().lower()
|
email = form.get("email", "").strip().lower()
|
||||||
selected_plan = form.get("plan", "free")
|
selected_plan = form.get("plan", "free")
|
||||||
|
|
||||||
|
# Waitlist mode: capture email and redirect to confirmed page
|
||||||
|
if config.WAITLIST_MODE:
|
||||||
|
if not email or "@" not in email:
|
||||||
|
await flash("Please enter a valid email address.", "error")
|
||||||
|
return redirect(url_for("auth.signup", plan=selected_plan))
|
||||||
|
await capture_waitlist_email(
|
||||||
|
email,
|
||||||
|
intent="signup",
|
||||||
|
plan=selected_plan,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
)
|
||||||
|
return redirect(url_for("auth.waitlist_confirmed"))
|
||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
await flash("Please enter a valid email address.", "error")
|
await flash("Please enter a valid email address.", "error")
|
||||||
return redirect(url_for("auth.signup", plan=selected_plan))
|
return redirect(url_for("auth.signup", plan=selected_plan))
|
||||||
@@ -304,6 +318,12 @@ async def magic_link_sent():
|
|||||||
return await render_template("magic_link_sent.html", email=email)
|
return await render_template("magic_link_sent.html", email=email)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/waitlist-confirmed")
|
||||||
|
async def waitlist_confirmed():
|
||||||
|
"""Confirmation page after joining the waitlist."""
|
||||||
|
return await render_template("waitlist_confirmed.html")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/dev-login")
|
@bp.route("/dev-login")
|
||||||
async def dev_login():
|
async def dev_login():
|
||||||
"""Instant login for development. Only works in DEBUG mode."""
|
"""Instant login for development. Only works in DEBUG mode."""
|
||||||
|
|||||||
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 functools import wraps
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from quart import g, make_response, request, session
|
from quart import g, make_response, render_template, request, session
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# web/.env is three levels up from web/src/beanflows/core.py
|
# web/.env is three levels up from web/src/beanflows/core.py
|
||||||
@@ -58,6 +58,9 @@ class Config:
|
|||||||
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
|
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
||||||
|
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
|
||||||
|
|
||||||
PLAN_FEATURES: dict = {
|
PLAN_FEATURES: dict = {
|
||||||
"free": ["dashboard", "coffee_only", "limited_history"],
|
"free": ["dashboard", "coffee_only", "limited_history"],
|
||||||
"starter": ["dashboard", "coffee_only", "full_history", "export", "api"],
|
"starter": ["dashboard", "coffee_only", "full_history", "export", "api"],
|
||||||
@@ -354,6 +357,60 @@ async def purge_deleted(table: str, days: int = 30) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Waitlist
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def waitlist_gate(template: str, **extra_context):
|
||||||
|
"""Intercept GET requests when WAITLIST_MODE=true and render the waitlist template."""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if config.WAITLIST_MODE and request.method == "GET":
|
||||||
|
ctx = {}
|
||||||
|
for k, v in extra_context.items():
|
||||||
|
ctx[k] = v() if callable(v) else v
|
||||||
|
return await render_template(template, **ctx)
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_waitlist_email(
|
||||||
|
email: str,
|
||||||
|
intent: str = "signup",
|
||||||
|
source: str = None,
|
||||||
|
plan: str = None,
|
||||||
|
ip_address: str = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Insert email into waitlist (INSERT OR IGNORE) and enqueue confirmation email."""
|
||||||
|
result = await execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO waitlist (email, intent, source, plan, ip_address, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(email.lower(), intent, source, plan, ip_address, datetime.utcnow().isoformat()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
from .worker import enqueue
|
||||||
|
await enqueue("send_waitlist_confirmation", {"email": email})
|
||||||
|
|
||||||
|
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY:
|
||||||
|
try:
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
resend.Contacts.create({
|
||||||
|
"email": email,
|
||||||
|
"audience_id": config.RESEND_AUDIENCE_WAITLIST,
|
||||||
|
"unsubscribed": False,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WAITLIST] Resend audience error: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# A/B Testing
|
# A/B Testing
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -140,6 +140,34 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at);
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at);
|
||||||
|
|
||||||
|
-- Feedback
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
page_url TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_is_read ON feedback(is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);
|
||||||
|
|
||||||
|
-- Waitlist
|
||||||
|
CREATE TABLE IF NOT EXISTS waitlist (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
intent TEXT NOT NULL DEFAULT 'signup',
|
||||||
|
source TEXT,
|
||||||
|
plan TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(email, intent)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_waitlist_intent ON waitlist(intent);
|
||||||
|
|
||||||
-- Items (example domain entity - replace with your domain)
|
-- Items (example domain entity - replace with your domain)
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Public domain: landing page, marketing pages, legal pages.
|
Public domain: landing page, marketing pages, legal pages, feedback, sitemap.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from quart import Blueprint, render_template
|
from quart import Blueprint, render_template, request, g, make_response
|
||||||
|
|
||||||
from ..core import config
|
from ..core import config, execute, check_rate_limit, csrf_protect
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -43,3 +44,78 @@ async def privacy():
|
|||||||
async def about():
|
async def about():
|
||||||
"""About page."""
|
"""About page."""
|
||||||
return await render_template("about.html")
|
return await render_template("about.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/feedback", methods=["POST"])
|
||||||
|
@csrf_protect
|
||||||
|
async def feedback():
|
||||||
|
"""Submit feedback. Rate-limited to 5/hour per IP. Returns HTMX snippet."""
|
||||||
|
ip = request.remote_addr or "unknown"
|
||||||
|
allowed, _ = await check_rate_limit(f"feedback:{ip}", limit=5, window=3600)
|
||||||
|
if not allowed:
|
||||||
|
return '<p style="color:#EF4444;font-size:13px;">Too many submissions. Try again later.</p>', 429
|
||||||
|
|
||||||
|
form = await request.form
|
||||||
|
message = (form.get("message") or "").strip()
|
||||||
|
page_url = (form.get("page_url") or "").strip()[:500]
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return '<p style="color:#EF4444;font-size:13px;">Message cannot be empty.</p>', 400
|
||||||
|
if len(message) > 2000:
|
||||||
|
return '<p style="color:#EF4444;font-size:13px;">Message too long (max 2000 characters).</p>', 400
|
||||||
|
|
||||||
|
user_id = g.user["id"] if g.get("user") else None
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO feedback (user_id, page_url, message, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, page_url, message, datetime.utcnow().isoformat()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return '<p style="color:#15803D;font-size:13px;font-weight:500;">Thanks for your feedback!</p>'
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/robots.txt")
|
||||||
|
async def robots_txt():
|
||||||
|
"""robots.txt for search engines."""
|
||||||
|
body = "User-agent: *\n"
|
||||||
|
body += "Disallow: /admin/\n"
|
||||||
|
body += "Disallow: /auth/\n"
|
||||||
|
body += "Disallow: /dashboard/\n"
|
||||||
|
body += "Disallow: /billing/\n"
|
||||||
|
body += f"Sitemap: {config.BASE_URL.rstrip('/')}/sitemap.xml\n"
|
||||||
|
response = await make_response(body)
|
||||||
|
response.headers["Content-Type"] = "text/plain"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/sitemap.xml")
|
||||||
|
async def sitemap_xml():
|
||||||
|
"""XML sitemap for search engines."""
|
||||||
|
base = config.BASE_URL.rstrip("/")
|
||||||
|
|
||||||
|
def url_entry(loc: str, priority: str = "0.5", changefreq: str = "weekly") -> str:
|
||||||
|
return (
|
||||||
|
f" <url>\n"
|
||||||
|
f" <loc>{loc}</loc>\n"
|
||||||
|
f" <changefreq>{changefreq}</changefreq>\n"
|
||||||
|
f" <priority>{priority}</priority>\n"
|
||||||
|
f" </url>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
xml += url_entry(f"{base}/", priority="1.0", changefreq="daily")
|
||||||
|
xml += url_entry(f"{base}/features", priority="0.8")
|
||||||
|
xml += url_entry(f"{base}/about", priority="0.6")
|
||||||
|
xml += url_entry(f"{base}/pricing", priority="0.8")
|
||||||
|
xml += url_entry(f"{base}/terms", priority="0.3", changefreq="yearly")
|
||||||
|
xml += url_entry(f"{base}/privacy", priority="0.3", changefreq="yearly")
|
||||||
|
# Add dynamic BeanFlows entries here (e.g. public commodity pages)
|
||||||
|
xml += "</urlset>"
|
||||||
|
|
||||||
|
response = await make_response(xml)
|
||||||
|
response.headers["Content-Type"] = "application/xml"
|
||||||
|
return response
|
||||||
|
|||||||
@@ -96,6 +96,93 @@
|
|||||||
<!-- HTMX -->
|
<!-- HTMX -->
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
|
||||||
|
<!-- Feedback Widget -->
|
||||||
|
<div id="feedback-widget" style="position:fixed;bottom:20px;right:20px;z-index:100;">
|
||||||
|
<button
|
||||||
|
id="feedback-btn"
|
||||||
|
onclick="document.getElementById('feedback-popover').classList.toggle('open')"
|
||||||
|
style="
|
||||||
|
background:#2C1810;color:#fff;border:none;border-radius:20px;
|
||||||
|
padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;
|
||||||
|
box-shadow:0 4px 12px rgba(0,0,0,0.2);display:flex;align-items:center;gap:6px;
|
||||||
|
transition:background 0.15s;
|
||||||
|
"
|
||||||
|
onmouseover="this.style.background='#4A2C1A'"
|
||||||
|
onmouseout="this.style.background='#2C1810'"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Feedback
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="feedback-popover"
|
||||||
|
style="
|
||||||
|
display:none;position:absolute;bottom:44px;right:0;
|
||||||
|
width:300px;background:#fff;border:1px solid #E8DFD5;
|
||||||
|
border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,0.12);
|
||||||
|
padding:16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p style="font-size:14px;font-weight:600;color:#2C1810;margin:0 0 4px;">Share feedback</p>
|
||||||
|
<p style="font-size:12px;color:#78716C;margin:0 0 12px;">What's on your mind?</p>
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('public.feedback') }}"
|
||||||
|
hx-target="#feedback-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="this.reset()"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="page_url" id="feedback-page-url" value="">
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
placeholder="Tell us what you think..."
|
||||||
|
required
|
||||||
|
maxlength="2000"
|
||||||
|
style="
|
||||||
|
width:100%;box-sizing:border-box;resize:vertical;min-height:80px;
|
||||||
|
border:1px solid #E8DFD5;border-radius:6px;padding:8px 10px;
|
||||||
|
font-size:13px;color:#2C1810;outline:none;font-family:inherit;
|
||||||
|
transition:border-color 0.15s;
|
||||||
|
"
|
||||||
|
onfocus="this.style.borderColor='#B45309'"
|
||||||
|
onblur="this.style.borderColor='#E8DFD5'"
|
||||||
|
></textarea>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||||
|
<div id="feedback-result" style="font-size:12px;"></div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style="
|
||||||
|
background:#B45309;color:#fff;border:none;border-radius:6px;
|
||||||
|
padding:6px 14px;font-size:13px;font-weight:600;cursor:pointer;
|
||||||
|
transition:background 0.15s;flex-shrink:0;
|
||||||
|
"
|
||||||
|
onmouseover="this.style.background='#92400E'"
|
||||||
|
onmouseout="this.style.background='#B45309'"
|
||||||
|
>Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#feedback-popover.open { display: block; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var urlInput = document.getElementById('feedback-page-url');
|
||||||
|
if (urlInput) urlInput.value = window.location.href;
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var widget = document.getElementById('feedback-widget');
|
||||||
|
if (widget && !widget.contains(e.target)) {
|
||||||
|
var popover = document.getElementById('feedback-popover');
|
||||||
|
if (popover) popover.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ async def handle_send_welcome(payload: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task("send_waitlist_confirmation")
|
||||||
|
async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
||||||
|
"""Send waitlist confirmation email."""
|
||||||
|
body = (
|
||||||
|
f'<h2 style="margin:0 0 16px;color:#2C1810;font-size:20px;">You\'re on the {config.APP_NAME} waitlist!</h2>'
|
||||||
|
f"<p>Thanks for your interest in BeanFlows. We'll send you an email the moment we're ready to welcome new coffee traders.</p>"
|
||||||
|
f'<p style="margin-top:16px;">No spam — just the launch announcement when it\'s time.</p>'
|
||||||
|
f'<p style="margin-top:24px;font-size:13px;color:#78716C;">The {config.APP_NAME} team</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
await send_email(
|
||||||
|
to=payload["email"],
|
||||||
|
subject=f"You're on the {config.APP_NAME} waitlist",
|
||||||
|
html=_email_wrap(body),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@task("cleanup_expired_tokens")
|
@task("cleanup_expired_tokens")
|
||||||
async def handle_cleanup_tokens(payload: dict) -> None:
|
async def handle_cleanup_tokens(payload: dict) -> None:
|
||||||
"""Clean up expired auth tokens."""
|
"""Clean up expired auth tokens."""
|
||||||
|
|||||||
Reference in New Issue
Block a user