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>
This commit is contained in:
916
docs/WAITLIST.md
Normal file
916
docs/WAITLIST.md
Normal file
@@ -0,0 +1,916 @@
|
||||
# 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 %}
|
||||
<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 %}
|
||||
```
|
||||
|
||||
2. **Confirmation page** (`your_confirmation.html`)
|
||||
|
||||
```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`:
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user