Files
padelnomics/docs/WAITLIST.md
Deeman 4ae00b35d1 refactor: flatten padelnomics/padelnomics/ → repo root
git mv all tracked files from the nested padelnomics/ workspace
directory to the git repo root. Merged .gitignore files.
No code changes — pure path rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 00:44:40 +01:00

25 KiB

Waitlist Mode

Waitlist mode allows you to validate market demand before building features. Set WAITLIST_MODE=true and selected routes will show waitlist signup forms instead of the normal flow. Emails are captured to a database table and automatically segmented into per-blueprint Resend audiences for targeted launch campaigns.

Use Cases

  • Pre-launch: Gauge interest before going live
  • Feature validation: Test new features (e.g., business plan export) before implementation
  • Market segment testing: Validate demand for supplier tiers or new markets
  • Lean startup smoke test: Capture leads without building full functionality

Quick Start

1. Enable waitlist mode

# Add to .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123  # Optional: audiences created automatically per blueprint

2. Run migration (if not already done)

uv run python -m padelnomics.migrations.migrate

This creates the waitlist table with the schema:

CREATE TABLE waitlist (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL,
    intent TEXT NOT NULL,           -- e.g., "signup", "supplier", "free", "pro"
    source TEXT,                    -- Optional: campaign source
    plan TEXT,                      -- Optional: plan name
    ip_address TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(email, intent)           -- Same email can sign up for different intents
);
CREATE INDEX idx_waitlist_email ON waitlist(email);

3. Test it

Visit any gated route:

Submit an email → see confirmation page → check the waitlist table:

uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT * FROM waitlist ORDER BY created_at DESC LIMIT 10').fetchall()
for row in rows:
    print(row)
"

4. Turn off waitlist mode

# In .env
WAITLIST_MODE=false

Restart the app. All routes revert to normal signup/checkout flows.


Architecture

Two Abstractions

  1. @waitlist_gate(template, **context) decorator — intercepts GET requests
  2. capture_waitlist_email(email, intent, plan, email_intent) helper — handles DB + email + Resend

How It Works

GET Requests (Decorator)

The @waitlist_gate decorator intercepts GET requests when WAITLIST_MODE=true and renders a waitlist template instead of the normal page.

from padelnomics.core import waitlist_gate

@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
async def signup():
    """Signup page."""
    # This code only runs when WAITLIST_MODE=false or for POST requests
    ...

How it works:

  • Checks config.WAITLIST_MODE and request.method
  • If mode is enabled AND method is GET → renders the waitlist template
  • Otherwise → passes through to the wrapped function
  • Context variables can be callables (evaluated at request time) or static values

Why POST passes through:

  • Routes need to handle waitlist form submissions
  • Each route controls its own POST logic (validation, error handling, success template)

POST Requests (Helper)

The capture_waitlist_email() helper handles the database insertion, email queueing, and Resend integration.

from padelnomics.core import capture_waitlist_email

async def signup():
    if config.WAITLIST_MODE and request.method == "POST":
        form = await request.form
        email = form.get("email", "").strip().lower()

        if not email or "@" not in email:
            await flash("Please enter a valid email address.", "error")
            return redirect(url_for("auth.signup"))

        await capture_waitlist_email(email, intent="signup")
        return await render_template("waitlist_confirmed.html", email=email)

    # Normal signup flow below...

What it does:

  1. Inserts email into waitlist table with INSERT OR IGNORE (idempotent)
  2. Returns True if new row inserted, False if duplicate
  3. Enqueues send_waitlist_confirmation email task (only for new signups)
  4. Adds email to Resend audience if RESEND_AUDIENCE_WAITLIST is set
  5. All errors are handled silently — user always sees success page

Parameters:

  • email (str) — Email address to capture
  • intent (str) — Intent value stored in database (e.g., "signup", "supplier")
  • plan (str, optional) — Plan name stored in database (e.g., "supplier_pro")
  • email_intent (str, optional) — Intent value for email confirmation (defaults to intent)

Why email_intent?

Suppliers need different intent values for DB vs email:

  • Database: intent="supplier" (all suppliers grouped together)
  • Email: intent="supplier_pro" (plan-specific messaging)
await capture_waitlist_email(
    email,
    intent="supplier",           # DB: all suppliers
    plan="supplier_pro",          # DB: plan name
    email_intent="supplier_pro"   # Email: plan-specific
)

Adding Waitlist to New Routes

Step 1: Import the abstractions

from ..core import capture_waitlist_email, waitlist_gate

Step 2: Add the decorator

@bp.route("/your-route", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("your_waitlist_template.html", custom_var=lambda: some_value())
async def your_route():
    ...

Step 3: Handle POST in your route

async def your_route():
    if config.WAITLIST_MODE and request.method == "POST":
        form = await request.form
        email = form.get("email", "").strip().lower()

        # Validate email
        if not email or "@" not in email:
            await flash("Please enter a valid email address.", "error")
            return redirect(url_for("your_blueprint.your_route"))

        # Capture to waitlist
        await capture_waitlist_email(email, intent="your_intent")

        # Show confirmation
        return await render_template("your_confirmation.html", email=email)

    # Normal flow below...
    ...

Step 4: Create templates

  1. Waitlist form (your_waitlist_template.html)
{% extends "base.html" %}

{% block content %}
<div class="max-w-md mx-auto mt-12">
  <h1 class="text-3xl font-bold mb-4">Join the Waitlist</h1>
  <p class="text-gray-600 mb-8">
    We're launching soon! Enter your email to get early access.
  </p>

  <form method="post" class="space-y-4">
    <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">

    <div>
      <label for="email" class="block text-sm font-medium mb-2">Email</label>
      <input
        type="email"
        name="email"
        id="email"
        required
        class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        placeholder="you@example.com"
      >
    </div>

    <button
      type="submit"
      class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700"
    >
      Join Waitlist
    </button>
  </form>
</div>
{% endblock %}
  1. Confirmation page (your_confirmation.html)
{% extends "base.html" %}

{% block content %}
<div class="max-w-md mx-auto mt-12 text-center">
  <div class="mb-6">
    <svg class="w-16 h-16 mx-auto text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
    </svg>
  </div>

  <h1 class="text-3xl font-bold mb-4">You're on the list!</h1>

  <p class="text-gray-600 mb-4">
    We've sent a confirmation to <strong>{{ email }}</strong>.
  </p>

  <p class="text-gray-600">
    We'll notify you when we launch. In the meantime, follow us on social media for updates.
  </p>
</div>
{% endblock %}

Email Confirmations

Waitlist confirmations are sent via the send_waitlist_confirmation worker task.

How it works

  1. Route calls capture_waitlist_email()
  2. Helper enqueues: await enqueue("send_waitlist_confirmation", {"email": email, "intent": intent})
  3. Worker picks up task and calls handle_send_waitlist_confirmation()
  4. Email sent via Resend (or printed to console in dev)

Email content

Defined in src/padelnomics/worker.py:

async def handle_send_waitlist_confirmation(data):
    email = data["email"]
    intent = data.get("intent", "signup")

    # Intent-specific messaging
    if intent == "signup" or intent.startswith("free") or intent.startswith("starter"):
        subject = "Thanks for joining the Padelnomics waitlist!"
        body = "We're launching soon..."
    elif intent.startswith("supplier"):
        plan_name = intent.replace("supplier_", "").title()
        subject = f"You're on the list for Padelnomics {plan_name}"
        body = f"Thanks for your interest in the {plan_name} plan..."
    else:
        subject = "You're on the Padelnomics waitlist"
        body = "We'll notify you when we launch..."

    await send_email(
        to=email,
        subject=subject,
        html=body,
        from_addr=EMAIL_ADDRESSES["transactional"]
    )

Customizing emails

Edit handle_send_waitlist_confirmation() in worker.py to:

  • Add new intent-specific messaging
  • Include plan details or pricing
  • Add CTA buttons or links
  • Personalize based on source/plan

Resend Integration

Audiences are created automatically per blueprint — no configuration needed beyond RESEND_API_KEY. The first signup from each blueprint creates a named audience in Resend and caches its ID in the resend_audiences database table. Subsequent signups from the same blueprint skip the API call and use the cached ID.

Audience names

Route Blueprint Resend audience
/auth/signup auth waitlist-auth
/suppliers/signup/waitlist suppliers waitlist-suppliers
/planner/export planner waitlist-planner

Adding @waitlist_gate to any new blueprint automatically creates its own audience on first signup.

Setup

Set RESEND_API_KEY in .env — no audience IDs needed:

RESEND_API_KEY=re_xyz123

How it works

# Derives audience name from current request's blueprint
blueprint = request.blueprints[0] if request.blueprints else "default"
audience_name = f"waitlist-{blueprint}"

# Lazy-creates audience on first use, caches ID in resend_audiences table
audience_id = await _get_or_create_resend_audience(audience_name)
if audience_id:
    resend.Contacts.create({"email": email, "audience_id": audience_id})

Silent failures:

  • Audiences.create error → audience_id is None, contact skipped
  • Contacts.create error → ignored
  • Duplicate emails (already in audience) → ignored
  • API rate limits → ignored

Waitlist signups always succeed even if Resend is down.

Launch campaign

When ready to launch:

  1. Go to Resend dashboard → Audiences → select the segment (e.g., waitlist-suppliers)
  2. Create a broadcast email: "We're live! Here's your early access..."
  3. Send to the audience

Testing

Unit Tests

Tests are in tests/test_waitlist.py:

uv run pytest tests/test_waitlist.py -v

Test coverage:

  • Configuration: WAITLIST_MODE flag exists and can be toggled
  • Migration: waitlist table schema, constraints, indexes
  • Worker: Email confirmation task content and addressing
  • Auth routes: GET shows waitlist, POST captures email
  • Supplier routes: Waitlist form, email capture, plan-specific messaging
  • Planner routes: Export waitlist gate
  • Decorator: GET intercept, POST passthrough, callable context
  • Helper: DB operations, email queueing, Resend integration, error handling
  • Edge cases: Duplicate emails, invalid emails, DB errors, Resend errors
  • Integration: Full flows from GET → POST → DB → email

Manual Testing

  1. Enable waitlist mode:

    # In .env
    WAITLIST_MODE=true
    
  2. Visit gated routes:

  3. Submit emails:

    • Valid email → confirmation page
    • Invalid email → error message
    • Duplicate email → confirmation page (no error)
  4. Check database:

    uv run python -c "
    import sqlite3
    conn = sqlite3.connect('data/app.db')
    rows = conn.execute('SELECT * FROM waitlist ORDER BY created_at DESC').fetchall()
    for row in rows:
        print(row)
    "
    
  5. Check worker logs:

    • Start worker: uv run python -m padelnomics.worker
    • Submit email via form
    • Look for: [TASK] Processing send_waitlist_confirmation...
    • Email should print to console (if no Resend key) or send via Resend
  6. Disable waitlist mode:

    # In .env
    WAITLIST_MODE=false
    
    • Restart app
    • Visit routes → should see normal signup/checkout flows

Database Schema

waitlist table

CREATE TABLE waitlist (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL,
    intent TEXT NOT NULL,
    source TEXT,
    plan TEXT,
    ip_address TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(email, intent)
);

CREATE INDEX idx_waitlist_email ON waitlist(email);

Columns:

  • id — Auto-incrementing primary key
  • email — Email address (lowercase, trimmed)
  • intent — Signup intent (e.g., "signup", "supplier", "free", "pro")
  • source — Optional campaign source (e.g., "facebook_ad", "twitter")
  • plan — Optional plan name (e.g., "supplier_pro", "business_plan")
  • ip_address — Request IP address (for spam detection)
  • created_at — Timestamp (ISO 8601)

Constraints:

  • UNIQUE(email, intent) — Same email can sign up for multiple intents
    • Example: user@example.com can be on both "signup" and "supplier" waitlists
    • Duplicate submissions for same email+intent are ignored (idempotent)

Indexes:

  • idx_waitlist_email — Fast lookups by email

Queries

Count total signups:

SELECT COUNT(*) FROM waitlist;

Count by intent:

SELECT intent, COUNT(*) as count
FROM waitlist
GROUP BY intent
ORDER BY count DESC;

Recent signups:

SELECT * FROM waitlist
ORDER BY created_at DESC
LIMIT 50;

Duplicates (same email, different intents):

SELECT email, COUNT(*) as count
FROM waitlist
GROUP BY email
HAVING count > 1;

Export for Resend:

SELECT DISTINCT email FROM waitlist;

Configuration Reference

Environment Variables

Variable Type Default Description
WAITLIST_MODE bool false Enable waitlist gates on routes
RESEND_API_KEY string "" Resend API key — audiences created automatically per blueprint

Usage

# .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123
# No audience IDs needed — created automatically on first signup

Config Access

from padelnomics.core import config

if config.WAITLIST_MODE:
    # Show waitlist
else:
    # Normal flow

Best Practices

1. Keep waitlist templates simple

  • Single form field (email)
  • Clear value proposition
  • No extra fields (friction kills conversions)
  • Mobile-friendly

2. Always show confirmation page

  • Don't redirect to homepage
  • Show success message with email address
  • Set expectations (when will they hear from you?)
  • Add social proof or testimonials

3. Test with real emails

  • Use your own email to verify full flow
  • Check spam folder for confirmations
  • Test with different email providers (Gmail, Outlook, ProtonMail)

4. Monitor signups

-- Daily signups
SELECT DATE(created_at) as date, COUNT(*) as signups
FROM waitlist
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30;

-- Conversion rate (if tracking sources)
SELECT source, COUNT(*) as signups
FROM waitlist
GROUP BY source
ORDER BY signups DESC;

5. Plan your launch

  • Set a signup goal (e.g., "100 signups in 30 days")
  • Promote waitlist via social media, ads, content
  • Send weekly updates to waitlist (build excitement)
  • Launch when you hit goal or deadline

6. Export before launch

-- Export all emails for Resend broadcast
SELECT DISTINCT email FROM waitlist;

Save as CSV, upload to Resend audience, send launch announcement.

7. Clean up after launch

-- Archive waitlist signups (optional)
CREATE TABLE waitlist_archive AS SELECT * FROM waitlist;

-- Clear table (optional - only if you want to reuse for another feature)
DELETE FROM waitlist;

Troubleshooting

Waitlist page not showing

Symptoms: Visit /auth/signup → see normal signup form

Causes:

  1. WAITLIST_MODE not set to true in .env
  2. .env changes not loaded (need to restart app)
  3. Using wrong environment (production vs dev)

Fix:

# Check config
uv run python -c "from padelnomics.core import config; print(config.WAITLIST_MODE)"

# Should print: True

# If False, check .env and restart:
# WAITLIST_MODE=true

Emails not captured in database

Symptoms: Submit form → confirmation page shows, but no row in waitlist table

Causes:

  1. Database migration not run
  2. DB connection error
  3. Email validation rejecting input

Fix:

# Check if table exists
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
tables = [r[0] for r in conn.execute(\"SELECT name FROM sqlite_master WHERE type='table'\").fetchall()]
print('waitlist' in tables)
"

# If False, run migration:
uv run python -m padelnomics.migrations.migrate

Confirmation emails not sending

Symptoms: Email captured in DB, but no confirmation email sent/printed

Causes:

  1. Worker not running
  2. Task queue error
  3. Resend API error

Fix:

# Check worker is running
ps aux | grep "python -m padelnomics.worker"

# Start if not running
uv run python -m padelnomics.worker

# Check worker logs for errors
# Look for: [TASK] Processing send_waitlist_confirmation...

Resend audience not adding contacts

Symptoms: Emails captured, confirmations sent, but not appearing in Resend audience

Causes:

  1. RESEND_API_KEY not set
  2. Audiences.create API error on first signup (check logs)
  3. Resend API rate limit
  4. Contacts already in audience (silent duplicate)

Fix:

# Check config
uv run python -c "
from padelnomics.core import config
print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
"

# Check resend_audiences table for cached IDs
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT * FROM resend_audiences').fetchall()
for row in rows:
    print(row)
"

Decorator not intercepting GET requests

Symptoms: Waitlist mode enabled, but route shows normal page

Causes:

  1. Decorator applied in wrong order
  2. Missing import
  3. Decorator syntax error

Fix:

# Correct order:
@bp.route("/route", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("template.html")
async def route():
    ...

# NOT:
@waitlist_gate("template.html")
@csrf_protect  # <- csrf must come after route decorator
@bp.route("/route", methods=["GET", "POST"])

Migration Guide

If you already have waitlist logic copy-pasted across routes, here's how to migrate:

Before (duplicated code)

@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
async def signup():
    if config.WAITLIST_MODE:
        if request.method == "GET":
            return await render_template("waitlist.html")

        # POST - capture email
        form = await request.form
        email = form.get("email", "").strip().lower()

        if not email or "@" not in email:
            await flash("Invalid email", "error")
            return redirect(url_for("auth.signup"))

        # Insert into DB
        await execute(
            "INSERT OR IGNORE INTO waitlist (email, intent, ip_address) VALUES (?, ?, ?)",
            (email, "signup", request.remote_addr)
        )

        # Enqueue email
        await enqueue("send_waitlist_confirmation", {"email": email, "intent": "signup"})

        # Add to Resend (old: manual audience ID required)
        if config.RESEND_AUDIENCE_WAITLIST:
            try:
                resend.Contacts.create(...)
            except:
                pass

        return await render_template("waitlist_confirmed.html", email=email)

    # Normal flow...

After (using decorator + helper)

from ..core import capture_waitlist_email, waitlist_gate

@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html")
async def signup():
    if config.WAITLIST_MODE and request.method == "POST":
        form = await request.form
        email = form.get("email", "").strip().lower()

        if not email or "@" not in email:
            await flash("Invalid email", "error")
            return redirect(url_for("auth.signup"))

        await capture_waitlist_email(email, intent="signup")
        return await render_template("waitlist_confirmed.html", email=email)

    # Normal flow...

Lines saved: 40+ → 10 (75% reduction)


FAQ

Q: Can I use waitlist mode in production?

A: Yes! That's the point. Enable it when you want to validate demand before building features.

Q: What happens to existing users when I enable waitlist mode?

A: Logged-in users can still access the app normally. Waitlist gates only apply to unauthenticated routes (signup, supplier signup, export). Consider adding a "coming soon" banner for logged-in users if gating features they can see.

Q: Can I customize the waitlist email template?

A: Yes. Edit handle_send_waitlist_confirmation() in src/padelnomics/worker.py. You can change subject, body, sender, and add HTML formatting.

Q: Can I add custom fields to the waitlist form?

A: Yes, but keep it minimal (email + 1-2 fields max). Add columns to the waitlist table via a migration, then update capture_waitlist_email() and your route to capture them.

Q: How do I prevent spam signups?

A: Current measures:

  • CSRF token required
  • Email validation (must contain @)
  • IP address captured (for manual review)
  • Rate limiting (inherited from app-wide rate limits)

Additional measures:

  • Add honeypot field to form
  • Add Cloudflare Turnstile captcha
  • Add email verification (double opt-in)

Q: Can I A/B test waitlist messaging?

A: Yes! Use the existing @ab_test decorator:

@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@ab_test("waitlist_messaging", variants=("short", "long"))
@waitlist_gate("waitlist.html", variant=lambda: g.ab_variant)
async def signup():
    ...

Then create two template versions based on variant.

Q: How do I export the waitlist?

A:

uv run python -c "
import sqlite3, csv
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT email, intent, created_at FROM waitlist ORDER BY created_at').fetchall()

with open('waitlist_export.csv', 'w', newline='') as f:
    writer = csv.writer(f)
    writer.writerow(['email', 'intent', 'created_at'])
    writer.writerows(rows)

print('Exported', len(rows), 'emails to waitlist_export.csv')
"

Q: Can I use waitlist mode for some routes but not others?

A: Yes. WAITLIST_MODE is a global flag, but you can add conditional logic:

# Only gate this route if WAITLIST_MODE is enabled
@waitlist_gate("waitlist.html") if config.WAITLIST_MODE else lambda f: f
async def route():
    ...

Or create a feature-specific flag:

# core.py
class Config:
    WAITLIST_MODE_EXPORT: bool = os.getenv("WAITLIST_MODE_EXPORT", "false").lower() == "true"

# routes.py
@waitlist_gate("export_waitlist.html") if config.WAITLIST_MODE_EXPORT else lambda f: f
async def export():
    ...

Summary

Waitlist mode is a lean, no-frills feature validation tool. Enable it with a single env var, capture emails with two abstractions, and disable it when you're ready to launch. No dependencies, no SaaS subscriptions, no complexity.

Key files:

  • src/padelnomics/core.pywaitlist_gate() decorator + capture_waitlist_email() helper
  • src/padelnomics/auth/routes.py — Example: entrepreneur signup
  • src/padelnomics/suppliers/routes.py — Example: supplier signup with plan-specific messaging
  • src/padelnomics/planner/routes.py — Example: feature gate (export)
  • src/padelnomics/worker.py — Email confirmation task handler
  • tests/test_waitlist.py — Comprehensive test coverage

Architecture:

  • SQLite table with email, intent, plan, ip_address
  • Decorator intercepts GET requests, routes handle POST
  • Helper handles DB + email + Resend (all silent failures)
  • Worker sends confirmations via send_waitlist_confirmation task

Usage:

  1. Set WAITLIST_MODE=true
  2. Add @waitlist_gate("template.html") to routes
  3. Call await capture_waitlist_email(email, intent) in POST handler
  4. Create waitlist + confirmation templates
  5. Test, promote, monitor signups
  6. Export emails, launch, disable waitlist mode