From 48bea5c198220e6ffbe86584dc6caa3f05a1bd51 Mon Sep 17 00:00:00 2001 From: Deeman Date: Fri, 20 Feb 2026 02:27:26 +0100 Subject: [PATCH] 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 --- web/.env.example | 4 + web/src/beanflows/admin/routes.py | 68 +++ .../admin/templates/admin/base_admin.html | 428 ++++++++++++++++++ .../admin/templates/admin/feedback.html | 75 +++ .../admin/templates/admin/index.html | 148 +++--- .../admin/templates/admin/tasks.html | 185 ++++---- .../admin/templates/admin/user_detail.html | 134 +++--- .../admin/templates/admin/users.html | 124 +++-- .../admin/templates/admin/waitlist.html | 63 +++ web/src/beanflows/auth/routes.py | 22 +- .../beanflows/auth/templates/waitlist.html | 45 ++ .../auth/templates/waitlist_confirmed.html | 34 ++ web/src/beanflows/core.py | 59 ++- web/src/beanflows/migrations/schema.sql | 28 ++ web/src/beanflows/public/routes.py | 82 +++- web/src/beanflows/templates/base.html | 87 ++++ web/src/beanflows/worker.py | 17 + 17 files changed, 1305 insertions(+), 298 deletions(-) create mode 100644 web/src/beanflows/admin/templates/admin/base_admin.html create mode 100644 web/src/beanflows/admin/templates/admin/feedback.html create mode 100644 web/src/beanflows/admin/templates/admin/waitlist.html create mode 100644 web/src/beanflows/auth/templates/waitlist.html create mode 100644 web/src/beanflows/auth/templates/waitlist_confirmed.html diff --git a/web/.env.example b/web/.env.example index ddde86e..2dec00a 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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= diff --git a/web/src/beanflows/admin/routes.py b/web/src/beanflows/admin/routes.py index d66ed1f..2b13619 100644 --- a/web/src/beanflows/admin/routes.py +++ b/web/src/beanflows/admin/routes.py @@ -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//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), + ) diff --git a/web/src/beanflows/admin/templates/admin/base_admin.html b/web/src/beanflows/admin/templates/admin/base_admin.html new file mode 100644 index 0000000..57248d5 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/base_admin.html @@ -0,0 +1,428 @@ + + + + + + {% block title %}Admin - {{ config.APP_NAME }}{% endblock %} + + + + + + + + + + + + + {% block admin_head %}{% endblock %} + + +
+ + + + + +
+ + +
+ + +
+
+ + {% block topbar_title %}Admin{% endblock %} +
+
+ {% block topbar_right %} + + ← {{ config.APP_NAME }} + + {% endblock %} +
+
+ + + {% if session.get('admin_impersonating') %} +
+ + + + You are currently impersonating a user. +
+ + +
+
+ {% endif %} + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block admin_content %}{% endblock %} +
+ +
+
+ + + + + + +{% block scripts %}{% endblock %} + + diff --git a/web/src/beanflows/admin/templates/admin/feedback.html b/web/src/beanflows/admin/templates/admin/feedback.html new file mode 100644 index 0000000..8b1371f --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/feedback.html @@ -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 %} +
+
+

User Feedback

+

+ {{ feedback_items | length }} messages + {% if unread_count > 0 %} + · {{ unread_count }} unread + {% endif %} +

+
+
+ +
+ {% if feedback_items %} + + + + + + + + + + + + {% for item in feedback_items %} + + + + + + + + {% endfor %} + +
MessagePageUserDateStatus
+

{{ item.message }}

+
+ {% if item.page_url %} + + {{ item.page_url | replace(config.BASE_URL, '') or '/' }} + + {% else %} + + {% endif %} + + {% if item.user_email %} + {{ item.user_email }} + {% else %} + Anonymous + {% endif %} + {{ item.created_at[:16] }} + {% if item.is_read %} + read + {% else %} +
+ + +
+ {% endif %} +
+ {% else %} +
+

No feedback yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/index.html b/web/src/beanflows/admin/templates/admin/index.html index 8c25d87..7f38163 100644 --- a/web/src/beanflows/admin/templates/admin/index.html +++ b/web/src/beanflows/admin/templates/admin/index.html @@ -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 %} -
-
-
-

Admin Dashboard

- {% if session.get('admin_impersonating') %} - Currently impersonating a user -
- - -
+{% block admin_content %} + +
+
+

Total Users

+

{{ stats.users_total }}

+

+{{ stats.users_today }} today · +{{ stats.users_week }} this week

+
+
+

Active Subscriptions

+

{{ stats.active_subscriptions }}

+

 

+
+
+

Task Queue

+

{{ stats.tasks_pending }} pending

+ {% if stats.tasks_failed > 0 %} +

{{ stats.tasks_failed }} failed

+ {% else %} +

All clear

{% endif %}
-
- - -
-
- - -
-
-
Total Users
-

{{ stats.users_total }}

- +{{ stats.users_today }} today, +{{ stats.users_week }} this week -
- -
-
Active Subscriptions
-

{{ stats.active_subscriptions }}

-
- -
-
Task Queue
-

{{ stats.tasks_pending }} pending

- {% if stats.tasks_failed > 0 %} - {{ stats.tasks_failed }} failed - {% else %} - 0 failed - {% endif %} -
+
+

Coffee Data

+

{{ stats.commodity_count }}

+

commodities · {{ stats.data_year_range }}

+
- - -
- All Users - Task Queue - View as User + + +
+
+
+

Waitlist

+ View all → +
+

{{ stats.waitlist_count }}

+

people waiting

+
+
+
+

Feedback

+ View all → +
+

{{ stats.feedback_unread }}

+

unread messages

+
- -
+ +
-

Recent Users

-
+
+

Recent Users

+ View all → +
+
{% if recent_users %} - +
@@ -69,28 +73,34 @@ {% for u in recent_users %} + - - + {% endfor %}
Email
{{ u.email }} - {{ u.email }} + {% if u.plan %} + {{ u.plan }} + {% else %} + free + {% endif %} {{ u.plan or 'free' }}{{ u.created_at[:10] }}{{ u.created_at[:10] }}
- View all → {% else %} -

No users yet.

+

No users yet.

{% endif %} -
+
- +
-

Failed Tasks

-
+
+

Failed Tasks

+ View all → +
+
{% if failed_tasks %} - +
@@ -101,24 +111,22 @@ {% for task in failed_tasks[:5] %} - - + + {% endfor %}
Task
{{ task.task_name }}{{ task.error[:50] }}...{{ task.task_name }}{{ (task.error or '')[:50] }}{% if task.error and task.error|length > 50 %}...{% endif %} -
+ - +
- View all → {% else %} -

✓ No failed tasks

+

No failed tasks — all clear.

{% endif %} -
+
-
{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/tasks.html b/web/src/beanflows/admin/templates/admin/tasks.html index e72af3f..c20d5c8 100644 --- a/web/src/beanflows/admin/templates/admin/tasks.html +++ b/web/src/beanflows/admin/templates/admin/tasks.html @@ -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 %} -
-
-

Task Queue

- ← Dashboard -
- +{% block admin_content %} {% if failed_tasks %} -
-

Failed Tasks ({{ failed_tasks | length }})

-
- - - - - - - - - - - - - {% for task in failed_tasks %} - - - - - - - - - {% endfor %} - -
IDTaskErrorRetriesCreated
{{ task.id }}{{ task.task_name }} -
- {{ task.error[:40] if task.error else 'No error' }}... -
{{ task.error }}
-
-
{{ task.retries }}{{ task.created_at[:16] }} -
-
- - -
-
- - -
-
-
-
+
+

Failed Tasks ({{ failed_tasks | length }})

+
+
+ + + + + + + + + + + + + {% for task in failed_tasks %} + + + + + + + + + {% endfor %} + +
IDTaskErrorRetriesCreated
{{ task.id }}{{ task.task_name }} +
+ {{ task.error[:40] if task.error else 'No error' }}... +
{{ task.error }}
+
+
{{ task.retries }}{{ task.created_at[:16] }} +
+
+ + +
+
+ + +
+
+
+
+
{% endif %} - +
-

Recent Tasks

-
+

Recent Tasks

+
{% if tasks %} - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - {% endfor %} - -
IDTaskStatusRun AtCreatedCompleted
{{ task.id }}{{ task.task_name }} - {% if task.status == 'complete' %} - ✓ complete - {% elif task.status == 'failed' %} - ✗ failed - {% elif task.status == 'pending' %} - ○ pending - {% else %} - {{ task.status }} - {% endif %} - {{ task.run_at[:16] if task.run_at else '-' }}{{ task.created_at[:16] }}{{ task.completed_at[:16] if task.completed_at else '-' }}
+
+ + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
IDTaskStatusRun AtCreatedCompleted
{{ task.id }}{{ task.task_name }} + {% if task.status == 'complete' %} + complete + {% elif task.status == 'failed' %} + failed + {% elif task.status == 'pending' %} + pending + {% else %} + {{ task.status }} + {% endif %} + {{ task.run_at[:16] if task.run_at else '-' }}{{ task.created_at[:16] }}{{ task.completed_at[:16] if task.completed_at else '-' }}
+
{% else %} -

No tasks in queue.

+

No tasks in queue.

{% endif %} -
+
-
{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/user_detail.html b/web/src/beanflows/admin/templates/admin/user_detail.html index 694caac..0657b48 100644 --- a/web/src/beanflows/admin/templates/admin/user_detail.html +++ b/web/src/beanflows/admin/templates/admin/user_detail.html @@ -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 %} -
-
-

{{ user.email }}

- ← Users -
- -
+{% block admin_content %} +
+ ← Back to Users +

{{ user.email }}

+
+ +
-
-

User Info

-
-
ID
-
{{ user.id }}
- -
Email
-
{{ user.email }}
- -
Name
-
{{ user.name or '-' }}
- -
Created
-
{{ user.created_at }}
- -
Last Login
-
{{ user.last_login_at or 'Never' }}
+
+

User Info

+
+
+
ID
+
{{ user.id }}
+
+
+
Email
+
{{ user.email }}
+
+
+
Name
+
{{ user.name or '-' }}
+
+
+
Created
+
{{ user.created_at }}
+
+
+
Last Login
+
{{ user.last_login_at or 'Never' }}
+
-
- +
+ -
-

Subscription

-
-
Plan
-
- {% if user.plan %} - {{ user.plan }} - {% else %} - free - {% endif %} -
- -
Status
-
{{ user.sub_status or 'N/A' }}
- +
+

Subscription

+
+
+
Plan
+
+ {% if user.plan %} + {{ user.plan }} + {% else %} + free + {% endif %} +
+
+
+
Status
+
{{ user.sub_status or 'N/A' }}
+
{% if user.stripe_customer_id %} -
Stripe Customer
-
- - {{ user.stripe_customer_id }} - -
+
+
Paddle Customer
+
+ + {{ user.stripe_customer_id }} + +
+
{% endif %}
-
-
- - -
-

Actions

-
-
- - -
-
-
+ + + +
+

Actions

+
+ + +
+
{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/users.html b/web/src/beanflows/admin/templates/admin/users.html index 15cd27b..769e64b 100644 --- a/web/src/beanflows/admin/templates/admin/users.html +++ b/web/src/beanflows/admin/templates/admin/users.html @@ -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 %} -
-
-

Users

- ← Dashboard -
- +{% block admin_content %} -
-
- - + +
+ +
- + -
+
{% if users %} - - - - - - - - - - - - - - {% for u in users %} - - - - - - - - - - {% endfor %} - -
IDEmailNamePlanJoinedLast Login
{{ u.id }}{{ u.email }}{{ u.name or '-' }} - {% if u.plan %} - {{ u.plan }} - {% else %} - free - {% endif %} - {{ u.created_at[:10] }}{{ u.last_login_at[:10] if u.last_login_at else 'Never' }} -
- - -
-
- +
+ + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
IDEmailNamePlanJoinedLast Login
{{ u.id }}{{ u.email }}{{ u.name or '-' }} + {% if u.plan %} + {{ u.plan }} + {% else %} + free + {% endif %} + {{ u.created_at[:10] }}{{ u.last_login_at[:10] if u.last_login_at else 'Never' }} +
+ + +
+
+
+ -
+
{% if page > 1 %} - ← Previous + ← Previous {% endif %} - Page {{ page }} + Page {{ page }} {% if users | length == 50 %} - Next → + Next → {% endif %}
{% else %} -

No users found.

+

No users found.

{% endif %} -
-
+ {% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/waitlist.html b/web/src/beanflows/admin/templates/admin/waitlist.html new file mode 100644 index 0000000..d70b599 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/waitlist.html @@ -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 %} +
+
+

Waitlist

+

+ {{ total }} {% if total == 1 %}person{% else %}people{% endif %} waiting + {% if not config.WAITLIST_MODE %} + · Waitlist mode is OFF + {% else %} + · Waitlist mode is ACTIVE + {% endif %} +

+
+
+ +
+ {% if entries %} + + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + {% endfor %} + +
EmailIntentPlanSourceIPJoined
{{ entry.email }}{{ entry.intent }} + {% if entry.plan %} + {{ entry.plan }} + {% else %} + + {% endif %} + {{ entry.source or '—' }}{{ entry.ip_address or '—' }}{{ entry.created_at[:16] }}
+ {% else %} +
+

No waitlist entries yet.

+ {% if not config.WAITLIST_MODE %} +

Set WAITLIST_MODE=true to enable the waitlist gate on /auth/signup.

+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/auth/routes.py b/web/src/beanflows/auth/routes.py index dc7fc98..d6b25c7 100644 --- a/web/src/beanflows/auth/routes.py +++ b/web/src/beanflows/auth/routes.py @@ -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.""" diff --git a/web/src/beanflows/auth/templates/waitlist.html b/web/src/beanflows/auth/templates/waitlist.html new file mode 100644 index 0000000..6638e62 --- /dev/null +++ b/web/src/beanflows/auth/templates/waitlist.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Join the Waitlist - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+
+ + + + + + +
+

Join the Waitlist

+

Be the first to know when BeanFlows launches.

+
+ +
+ + + +
+ + +
+ + {% if plan and plan != 'free' %} +

+ We'll remember your interest in the {{ plan | title }} plan. +

+ {% endif %} + + +
+ +

+ Coffee market data, first. No spam. +

+
+
+{% endblock %} diff --git a/web/src/beanflows/auth/templates/waitlist_confirmed.html b/web/src/beanflows/auth/templates/waitlist_confirmed.html new file mode 100644 index 0000000..a207e75 --- /dev/null +++ b/web/src/beanflows/auth/templates/waitlist_confirmed.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}You're on the Waitlist! - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+ + + +
+ +

You're on the list!

+

+ 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. +

+ +
+

What happens next?

+
    +
  • ✓  You'll get an email when we launch
  • +
  • ✓  Early access members get priority onboarding
  • +
  • ✓  No spam, ever — we promise
  • +
+
+ +

+ ← Back to home +

+
+
+{% endblock %} diff --git a/web/src/beanflows/core.py b/web/src/beanflows/core.py index 62e9d1f..2f0b753 100644 --- a/web/src/beanflows/core.py +++ b/web/src/beanflows/core.py @@ -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 # ============================================================================= diff --git a/web/src/beanflows/migrations/schema.sql b/web/src/beanflows/migrations/schema.sql index f3c44f8..c04f8b1 100644 --- a/web/src/beanflows/migrations/schema.sql +++ b/web/src/beanflows/migrations/schema.sql @@ -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, diff --git a/web/src/beanflows/public/routes.py b/web/src/beanflows/public/routes.py index bb014c7..e353eb2 100644 --- a/web/src/beanflows/public/routes.py +++ b/web/src/beanflows/public/routes.py @@ -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 '

Too many submissions. Try again later.

', 429 + + form = await request.form + message = (form.get("message") or "").strip() + page_url = (form.get("page_url") or "").strip()[:500] + + if not message: + return '

Message cannot be empty.

', 400 + if len(message) > 2000: + return '

Message too long (max 2000 characters).

', 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 '

Thanks for your feedback!

' + + +@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" \n" + f" {loc}\n" + f" {changefreq}\n" + f" {priority}\n" + f" \n" + ) + + xml = '\n' + xml += '\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 += "" + + response = await make_response(xml) + response.headers["Content-Type"] = "application/xml" + return response diff --git a/web/src/beanflows/templates/base.html b/web/src/beanflows/templates/base.html index b688d80..4fa2fb5 100644 --- a/web/src/beanflows/templates/base.html +++ b/web/src/beanflows/templates/base.html @@ -96,6 +96,93 @@ + +
+ + +
+

Share feedback

+

What's on your mind?

+
+ + + +
+
+ +
+
+
+
+ + + + + {% block scripts %}{% endblock %} diff --git a/web/src/beanflows/worker.py b/web/src/beanflows/worker.py index bf8d066..75c3734 100644 --- a/web/src/beanflows/worker.py +++ b/web/src/beanflows/worker.py @@ -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'

You\'re on the {config.APP_NAME} waitlist!

' + f"

Thanks for your interest in BeanFlows. We'll send you an email the moment we're ready to welcome new coffee traders.

" + f'

No spam — just the launch announcement when it\'s time.

' + f'

The {config.APP_NAME} team

' + ) + + 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."""