# 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 ```bash # Add to .env WAITLIST_MODE=true RESEND_API_KEY=re_xyz123 # Optional: audiences created automatically per blueprint ``` ### 2. Run migration (if not already done) ```bash uv run python -m padelnomics.migrations.migrate ``` This creates the `waitlist` table with the schema: ```sql 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: - http://localhost:5000/auth/signup → entrepreneur waitlist - http://localhost:5000/suppliers/signup → supplier waitlist - http://localhost:5000/planner/export → business plan export waitlist Submit an email → see confirmation page → check the `waitlist` table: ```bash 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 ```bash # 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. ```python 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. ```python 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) ```python 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 ```python from ..core import capture_waitlist_email, waitlist_gate ``` ### Step 2: Add the decorator ```python @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 ```python 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`) ```html {% extends "base.html" %} {% block content %}

Join the Waitlist

We're launching soon! Enter your email to get early access.

{% endblock %} ``` 2. **Confirmation page** (`your_confirmation.html`) ```html {% extends "base.html" %} {% block content %}

You're on the list!

We've sent a confirmation to {{ email }}.

We'll notify you when we launch. In the meantime, follow us on social media for updates.

{% 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`: ```python 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: ```bash RESEND_API_KEY=re_xyz123 ``` ### How it works ```python # 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`: ```bash 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:** ```bash # In .env WAITLIST_MODE=true ``` 2. **Visit gated routes:** - http://localhost:5000/auth/signup?plan=starter - http://localhost:5000/suppliers/signup?plan=supplier_pro - http://localhost:5000/planner/export 3. **Submit emails:** - Valid email → confirmation page - Invalid email → error message - Duplicate email → confirmation page (no error) 4. **Check database:** ```bash 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:** ```bash # In .env WAITLIST_MODE=false ``` - Restart app - Visit routes → should see normal signup/checkout flows --- ## Database Schema ### `waitlist` table ```sql 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:** ```sql SELECT COUNT(*) FROM waitlist; ``` **Count by intent:** ```sql SELECT intent, COUNT(*) as count FROM waitlist GROUP BY intent ORDER BY count DESC; ``` **Recent signups:** ```sql SELECT * FROM waitlist ORDER BY created_at DESC LIMIT 50; ``` **Duplicates (same email, different intents):** ```sql SELECT email, COUNT(*) as count FROM waitlist GROUP BY email HAVING count > 1; ``` **Export for Resend:** ```sql 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 ```bash # .env WAITLIST_MODE=true RESEND_API_KEY=re_xyz123 # No audience IDs needed — created automatically on first signup ``` ### Config Access ```python 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 ```sql -- 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 ```sql -- 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 ```sql -- 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:** ```bash # 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:** ```bash # 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:** ```bash # 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:** ```bash # 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:** ```python # 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) ```python @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) ```python 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: ```python @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:** ```bash 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: ```python # 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: ```python # 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.py` — `waitlist_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