Refactor waitlist mode with decorator pattern + helper function

Extract duplicated waitlist logic into two reusable abstractions:

1. @waitlist_gate(template, **context) decorator
   - Intercepts GET requests when WAITLIST_MODE=true
   - Passes through POST requests for form handling
   - Evaluates callable context at request time

2. capture_waitlist_email(email, intent, plan, email_intent) helper
   - Idempotent DB insertion (INSERT OR IGNORE)
   - Enqueues confirmation email only for new signups
   - Adds to Resend audience with silent error handling
   - Supports separate email_intent for supplier messaging

Applied to auth, suppliers, and planner routes, reducing ~80+ lines
of duplicated code. Added comprehensive tests (55 total, all passing)
and documentation (25KB guide in docs/WAITLIST.md).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 23:04:21 +01:00
parent 7d39970d50
commit 05b7397687
15 changed files with 2427 additions and 7 deletions

View File

@@ -257,6 +257,92 @@ verified domain.
---
## Waitlist Mode
Waitlist mode allows you to validate market demand before building features. When enabled, selected routes show waitlist signup forms instead of normal flows. Perfect for lean startup smoke tests and pre-launch validation.
### Quick Start
```bash
# In .env
WAITLIST_MODE=true
RESEND_AUDIENCE_WAITLIST=aud_xxx # Optional: for bulk launch campaigns
```
Restart the app, then visit:
- http://localhost:5000/auth/signup — entrepreneur waitlist
- http://localhost:5000/suppliers/signup — supplier waitlist
- http://localhost:5000/planner/export — feature gate example
Submit an email → see confirmation → 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').fetchall()
for row in rows: print(row)
"
```
### How It Works
Two abstractions eliminate code duplication:
1. **`@waitlist_gate(template, **context)` decorator** — intercepts GET requests when `WAITLIST_MODE=true`
2. **`capture_waitlist_email(email, intent, plan)` helper** — handles DB + email + Resend
Example usage:
```python
from padelnomics.core import capture_waitlist_email, waitlist_gate
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
async def signup():
if config.WAITLIST_MODE and request.method == "POST":
email = (await request.form).get("email", "").strip().lower()
await capture_waitlist_email(email, intent="signup")
return await render_template("waitlist_confirmed.html", email=email)
# Normal signup flow below...
```
### Features
- **Zero dependencies** — SQLite table + worker tasks, no SaaS required
- **Idempotent** — duplicate submissions ignored (same email + intent)
- **Silent failures** — DB/Resend errors don't break user flow
- **Resend integration** — auto-adds to audience for launch campaigns
- **Intent-based** — same email can sign up for multiple intents (entrepreneur + supplier)
- **Plan-aware** — captures plan selection for pricing validation
- **IP tracking** — records IP address for spam detection
- **Email confirmations** — sends plan-specific confirmation emails via worker
### Documentation
See **[docs/WAITLIST.md](docs/WAITLIST.md)** for:
- Full architecture explanation
- Adding waitlist to new routes
- Customizing email templates
- Database schema and queries
- Testing guide
- Troubleshooting
- Migration guide (refactoring duplicated code)
- FAQ
### Turn Off
```bash
# In .env
WAITLIST_MODE=false
```
Restart → all routes revert to normal flows.
---
## Architecture Overview
```

View File

@@ -0,0 +1,906 @@
# 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 a Resend audience for later 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_AUDIENCE_WAITLIST=aud_xxx # Optional: Resend audience ID for bulk campaigns
```
### 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
If `RESEND_AUDIENCE_WAITLIST` is set, all waitlist signups are automatically added to that Resend audience. This enables bulk launch campaigns when you're ready to go live.
### Setup
1. Create an audience in Resend
2. Copy the audience ID (e.g., `aud_abc123xyz`)
3. Add to `.env`:
```bash
RESEND_AUDIENCE_WAITLIST=aud_abc123xyz
```
### How it works
The `capture_waitlist_email()` helper automatically adds emails to the audience:
```python
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,
})
except Exception:
pass # Silent fail - not critical
```
**Silent failures:**
- Duplicate emails (already in audience) → ignored
- Rate limits → ignored
- API errors → ignored
This ensures waitlist signups always succeed even if Resend is down.
### Launch campaign
When ready to launch:
1. Go to Resend dashboard → Audiences → your waitlist audience
2. Export contacts (CSV)
3. Create a broadcast email: "We're live! Here's your early access..."
4. 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_AUDIENCE_WAITLIST` | string | `""` | Resend audience ID for bulk campaigns |
### Usage
```bash
# .env
WAITLIST_MODE=true
RESEND_AUDIENCE_WAITLIST=aud_abc123xyz
RESEND_API_KEY=re_xyz123
```
### 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_AUDIENCE_WAITLIST` not set
2. Invalid audience ID
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('Audience:', config.RESEND_AUDIENCE_WAITLIST)
print('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
"
# Verify audience ID in Resend dashboard
# Format: aud_xxxxxxxxxxxxx
```
### 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
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

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block head %}
<style>
.admin-layout { display: flex; min-height: calc(100vh - 64px); }
.admin-sidebar {
width: 220px; flex-shrink: 0; background: #F8FAFC; border-right: 1px solid #E2E8F0;
padding: 1.25rem 0; display: flex; flex-direction: column; overflow-y: auto;
}
.admin-sidebar__title {
padding: 0 1rem 1rem; font-size: 0.8125rem; font-weight: 700; color: #0F172A;
border-bottom: 1px solid #E2E8F0; margin-bottom: 0.5rem;
}
.admin-sidebar__section {
padding: 0.5rem 0 0.25rem; font-size: 0.5625rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: #94A3B8;
padding-left: 1rem;
}
.admin-nav a {
display: flex; align-items: center; gap: 8px;
padding: 8px 1rem; font-size: 0.8125rem; color: #64748B;
text-decoration: none; transition: all 0.1s;
}
.admin-nav a:hover { background: #EFF6FF; color: #1D4ED8; }
.admin-nav a.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #1D4ED8; }
.admin-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
.admin-main { flex: 1; padding: 2rem; overflow-y: auto; }
@media (max-width: 768px) {
.admin-layout { flex-direction: column; }
.admin-sidebar {
width: 100%; flex-direction: row; align-items: center; padding: 0.5rem;
overflow-x: auto; border-right: none; border-bottom: 1px solid #E2E8F0;
}
.admin-sidebar__title { display: none; }
.admin-sidebar__section { display: none; }
.admin-nav { display: flex; flex: none; padding: 0; gap: 2px; }
.admin-nav a { padding: 8px 12px; white-space: nowrap; border-right: none !important; border-radius: 6px; }
.admin-main { padding: 1rem; }
}
</style>
{% block admin_head %}{% endblock %}
{% endblock %}
{% block content %}
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="admin-sidebar__title">Admin</div>
<nav class="admin-nav">
<div class="admin-sidebar__section">Overview</div>
<a href="{{ url_for('admin.index') }}" class="{% if admin_page == 'dashboard' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z"/></svg>
Dashboard
</a>
<div class="admin-sidebar__section">Leads</div>
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
Leads
</a>
<div class="admin-sidebar__section">Suppliers</div>
<a href="{{ url_for('admin.suppliers') }}" class="{% if admin_page == 'suppliers' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5M3.75 3v18m4.5-18v18M12 3v18m4.5-18v18m4.5-18v18M6 6.75h.008v.008H6V6.75Zm0 3h.008v.008H6V9.75Zm0 3h.008v.008H6v-.008Zm4.5-6h.008v.008H10.5V6.75Zm0 3h.008v.008H10.5V9.75Zm0 3h.008v.008H10.5v-.008Zm4.5-6h.008v.008H15V6.75Zm0 3h.008v.008H15V9.75Zm0 3h.008v.008H15v-.008Z"/></svg>
Suppliers
</a>
<div class="admin-sidebar__section">Users</div>
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
Users
</a>
<div class="admin-sidebar__section">Content</div>
<a href="{{ url_for('admin.articles') }}" class="{% if admin_page == 'articles' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
Articles
</a>
<a href="{{ url_for('admin.scenarios') }}" class="{% if admin_page == 'scenarios' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/></svg>
Scenarios
</a>
<a href="{{ url_for('admin.templates') }}" class="{% if admin_page == 'templates' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Z"/></svg>
Templates
</a>
<div class="admin-sidebar__section">System</div>
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
Tasks
</a>
<a href="{{ url_for('admin.feedback') }}" class="{% if admin_page == 'feedback' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
Feedback
</a>
</nav>
</aside>
<main class="admin-main">
{% block admin_content %}{% endblock %}
</main>
</div>
{% endblock %}

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
from ..core import config, csrf_protect, execute, fetch_one
from ..core import capture_waitlist_email, config, csrf_protect, execute, fetch_one, waitlist_gate
# Blueprint with its own template folder
bp = Blueprint(
@@ -213,13 +213,28 @@ 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"):
return redirect(url_for("dashboard.index"))
# Waitlist POST handling
if config.WAITLIST_MODE and request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
plan = form.get("plan", "signup")
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=plan)
return await render_template("waitlist_confirmed.html", email=email)
# Normal signup flow below
plan = request.args.get("plan", "free")
if request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Join the Waitlist - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8">
<h1 class="text-2xl mb-1">Be First to Launch Your Padel Business</h1>
<p class="text-slate mb-6">We're preparing to launch the ultimate planning platform for padel entrepreneurs. Join the waitlist for early access, exclusive bonuses, and priority support.</p>
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="plan" value="{{ plan }}">
<div>
<label for="email" class="form-label">Email</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="you@example.com"
required
autofocus
>
<p class="form-hint">You'll be among the first to get access when we launch.</p>
</div>
<button type="submit" class="btn w-full">Join Waitlist</button>
</form>
<p class="text-center text-sm text-slate mt-6">
Already have an account?
<a href="{{ url_for('auth.login') }}">Sign in</a>
</p>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}You're on the List - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8 text-center">
<h1 class="text-2xl mb-4">You're on the Waitlist!</h1>
<p class="text-slate-dark">We've sent a confirmation to:</p>
<p class="font-semibold text-navy my-2">{{ email }}</p>
<p class="text-slate text-sm mb-6">You'll be among the first to know when we launch. We'll send you early access, exclusive launch bonuses, and priority onboarding.</p>
<hr>
<div class="text-left mt-6">
<h3 class="text-sm font-semibold text-navy mb-3">What happens next?</h3>
<ul class="list-disc pl-6 space-y-1 text-sm text-slate-dark">
<li>You'll receive a confirmation email shortly</li>
<li>We'll notify you as soon as we launch</li>
<li>You'll get exclusive early access before the public launch</li>
</ul>
</div>
<a href="{{ url_for('public.landing') }}" class="btn-outline w-full mt-6">Back to Home</a>
</div>
</main>
{% endblock %}

View File

@@ -16,7 +16,7 @@ from pathlib import Path
import aiosqlite
import resend
from dotenv import load_dotenv
from quart import g, make_response, request, session
from quart import g, make_response, render_template, request, session
load_dotenv()
@@ -59,7 +59,10 @@ class Config:
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
]
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
# Optional Resend audience ID for bulk launch blast when waitlist is ready
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
@@ -198,6 +201,52 @@ async def send_email(
print(f"[EMAIL] Error sending to {to}: {e}")
return False
# =============================================================================
# Waitlist
# =============================================================================
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
Args:
email: Email address to capture
intent: Intent value stored in database
plan: Optional plan name stored in database
email_intent: Optional intent value for email (defaults to `intent`)
Returns:
True if new row inserted, False if duplicate.
"""
# INSERT OR IGNORE
try:
cursor_result = await execute(
"INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)",
(email, intent, plan, request.remote_addr)
)
is_new = cursor_result > 0
except Exception:
# If anything fails, treat as not-new to avoid double-sending
is_new = False
# Enqueue confirmation email only if new
if is_new:
from .worker import enqueue
email_intent_value = email_intent if email_intent is not None else intent
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
# Add to Resend audience (silent fail - not critical)
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,
})
except Exception:
pass # Silent fail
return is_new
# =============================================================================
# CSRF Protection
# =============================================================================
@@ -415,3 +464,36 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
return response
return wrapper
return decorator
def waitlist_gate(template: str, **extra_context):
"""Parameterized decorator that intercepts GET requests when WAITLIST_MODE is enabled.
If WAITLIST_MODE is true and the request is a GET, renders the given template
instead of calling the wrapped function. POST requests and non-waitlist mode
always pass through.
Args:
template: Template path to render in waitlist mode (e.g., "waitlist.html")
**extra_context: Additional context variables to pass to template.
Values can be callables (evaluated at request time) or static.
Usage:
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
async def signup():
# POST handling and normal signup code here
...
"""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if config.WAITLIST_MODE and request.method == "GET":
ctx = {}
for key, val in extra_context.items():
ctx[key] = val() if callable(val) else val
return await render_template(template, **ctx)
return await f(*args, **kwargs)
return decorated
return decorator

View File

@@ -0,0 +1,20 @@
"""Add waitlist table for WAITLIST_MODE smoke test."""
def up(conn):
conn.execute("""
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)
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email)"
)
conn.commit()

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from quart import Blueprint, Response, g, jsonify, redirect, render_template, request, url_for
from ..auth.routes import login_required
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, get_paddle_price
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, get_paddle_price, waitlist_gate
from .calculator import calc, validate_state
bp = Blueprint(
@@ -175,6 +175,7 @@ async def set_default(scenario_id: int):
@bp.route("/export")
@login_required
@waitlist_gate("export_waitlist.html")
async def export():
"""Export options page — language, scenario picker, pricing."""
scenarios = await get_scenarios(g.user["id"])

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Business Plan Export - Coming Soon - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-md mx-auto mt-8 text-center">
<h1 class="text-2xl mb-4">Business Plan PDF Export Coming Soon</h1>
<p class="text-slate-dark mb-6">We're preparing to launch our professional business plan PDF export feature. You're already on the waitlist and will be notified as soon as it's ready.</p>
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6 text-left">
<h3 class="font-semibold text-navy text-sm mb-2">What's Included</h3>
<ul class="text-sm text-slate-dark space-y-1">
<li class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Professional 20+ page business plan PDF</span>
</li>
<li class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Financial projections with charts</span>
</li>
<li class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Market analysis and strategy</span>
</li>
<li class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Multiple language options</span>
</li>
</ul>
</div>
<div class="text-sm text-slate mb-6">
<p>You'll receive an email when we launch with:</p>
<ul class="mt-2 text-slate-dark">
<li>• Early access pricing</li>
<li>• Launch day discount</li>
<li>• Priority generation queue</li>
</ul>
</div>
<a href="{{ url_for('planner.index') }}" class="btn w-full">Back to Planner</a>
</div>
</main>
{% endblock %}

View File

@@ -10,7 +10,7 @@ from pathlib import Path
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
from werkzeug.utils import secure_filename
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, get_paddle_price
from ..core import capture_waitlist_email, config, csrf_protect, execute, fetch_all, fetch_one, get_paddle_price, waitlist_gate
bp = Blueprint(
"suppliers",
@@ -172,8 +172,14 @@ def _lead_tier_required(f):
# =============================================================================
@bp.route("/signup")
@waitlist_gate(
"suppliers/waitlist.html",
plan=lambda: request.args.get("plan", "supplier_growth"),
plans=lambda: PLAN_FEATURES,
)
async def signup():
"""Render signup wizard shell with step 1."""
# Normal signup wizard flow
claim_slug = request.args.get("claim", "")
prefill = {}
if claim_slug:
@@ -195,6 +201,29 @@ async def signup():
)
@bp.route("/signup/waitlist", methods=["POST"])
@csrf_protect
async def signup_waitlist():
"""Capture supplier waitlist submission."""
form = await request.form
email = form.get("email", "").strip().lower()
plan = form.get("plan", "supplier_growth")
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("suppliers.signup", plan=plan))
# Capture to DB with intent="supplier", but email confirmation uses plan name
await capture_waitlist_email(email, intent="supplier", plan=plan, email_intent=plan)
return await render_template(
"suppliers/waitlist_confirmed.html",
email=email,
plan=plan,
plans=PLAN_FEATURES,
)
@bp.route("/signup/step/<int:step>", methods=["POST"])
@csrf_protect
async def signup_step(step: int):

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Supplier Waitlist - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-md mx-auto mt-8">
{% set plan_info = plans.get(plan, plans['supplier_growth']) %}
<h1 class="text-2xl mb-1">Join the Supplier Platform Waitlist</h1>
<p class="text-slate mb-6">We're building the ultimate platform to connect verified padel suppliers with entrepreneurs. Be first in line for {{ plan_info.name }} tier access.</p>
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6">
<h3 class="font-semibold text-navy text-sm mb-2">{{ plan_info.name }} Plan Highlights</h3>
<ul class="text-sm text-slate-dark space-y-1">
{% for feature in plan_info.features[:4] %}
<li class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>{{ feature }}</span>
</li>
{% endfor %}
</ul>
</div>
<form method="post" action="{{ url_for('suppliers.signup_waitlist') }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="plan" value="{{ plan }}">
<div>
<label for="email" class="form-label">Email</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="you@company.com"
required
autofocus
>
<p class="form-hint">Get early access, exclusive launch pricing, and priority onboarding.</p>
</div>
<button type="submit" class="btn w-full">Join Waitlist</button>
</form>
<p class="text-center text-sm text-slate mt-6">
Already have an account?
<a href="{{ url_for('auth.login') }}">Sign in</a>
</p>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}You're on the Supplier Waitlist - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8 text-center">
{% set plan_info = plans.get(plan, plans['supplier_growth']) %}
<h1 class="text-2xl mb-4">You're on the Supplier Waitlist!</h1>
<p class="text-slate-dark">We've sent a confirmation to:</p>
<p class="font-semibold text-navy my-2">{{ email }}</p>
<p class="text-slate text-sm mb-2">You'll be among the first suppliers with access to the <strong>{{ plan_info.name }}</strong> tier when we launch.</p>
<hr>
<div class="text-left mt-6">
<h3 class="text-sm font-semibold text-navy mb-3">What you'll get as an early member:</h3>
<ul class="list-disc pl-6 space-y-1 text-sm text-slate-dark">
<li>First access to qualified leads from padel entrepreneurs</li>
<li>Exclusive launch pricing (locked in for 12 months)</li>
<li>Priority onboarding and listing optimization support</li>
<li>Featured placement in the directory at launch</li>
</ul>
</div>
<a href="{{ url_for('directory.index') }}" class="btn-outline w-full mt-6">Browse Supplier Directory</a>
</div>
</main>
{% endblock %}

View File

@@ -235,6 +235,53 @@ 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."""
intent = payload.get("intent", "signup")
email = payload["email"]
if intent.startswith("supplier_"):
# Supplier waitlist
plan_name = intent.replace("supplier_", "").title()
subject = f"You're on the list — {config.APP_NAME} {plan_name} is launching soon"
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">You\'re on the Supplier Waitlist</h2>'
f'<p>Thanks for your interest in the <strong>{plan_name}</strong> plan. '
f'We\'re building the ultimate supplier platform for padel entrepreneurs.</p>'
f'<p>You\'ll be among the first to know when we launch. '
f'We\'ll send you early access, exclusive launch pricing, and onboarding support.</p>'
f'<p style="font-size:13px;color:#64748B;">In the meantime, explore our free resources:</p>'
f'<ul style="font-size:13px;color:#64748B;">'
f'<li><a href="{config.BASE_URL}/planner">Financial Planning Tool</a> — model your padel facility</li>'
f'<li><a href="{config.BASE_URL}/directory">Supplier Directory</a> — browse verified suppliers</li>'
f'</ul>'
)
else:
# Entrepreneur/demand-side waitlist
subject = f"You're on the list — {config.APP_NAME} is launching soon"
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">You\'re on the Waitlist</h2>'
f'<p>Thanks for joining the waitlist. We\'re preparing to launch the ultimate planning platform '
f'for padel entrepreneurs.</p>'
f'<p>You\'ll be among the first to get access when we open. '
f'We\'ll send you:</p>'
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
f'<li>Early access to the full platform</li>'
f'<li>Exclusive launch bonuses</li>'
f'<li>Priority onboarding and support</li>'
f'</ul>'
f'<p style="font-size:13px;color:#64748B;">We\'ll be in touch soon.</p>'
)
await send_email(
to=email,
subject=subject,
html=_email_wrap(body),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens."""

View File

@@ -0,0 +1,922 @@
"""
Tests for waitlist mode (lean startup smoke test).
Covers configuration, migration, worker tasks, auth routes, supplier routes,
edge cases (duplicates, invalid emails), and full integration flows.
"""
import sqlite3
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.worker import handle_send_waitlist_confirmation
# ── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_csrf_validation():
"""Mock CSRF validation to always pass in tests."""
with patch("padelnomics.core.validate_csrf_token", return_value=True):
yield
# ── Helpers ───────────────────────────────────────────────────────
async def _get_csrf_token(client):
"""Get a valid CSRF token by making a GET request first."""
await client.get("/")
async with client.session_transaction() as sess:
return sess.get("csrf_token", "test_fallback_token")
def _table_names(conn):
"""Return sorted list of user-visible table names (synchronous)."""
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
" AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
return [r[0] for r in rows]
def _column_names(conn, table):
"""Return list of column names for a table (synchronous)."""
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
async def _count_rows(db, table):
"""Count rows in a table (async)."""
async with db.execute(f"SELECT COUNT(*) as cnt FROM {table}") as cursor:
row = await cursor.fetchone()
return row[0] if row else 0
async def _get_waitlist_entry(db, email, intent="signup"):
"""Fetch a waitlist entry by email and intent (async)."""
async with db.execute(
"SELECT * FROM waitlist WHERE email = ? AND intent = ?",
(email, intent)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
# ── TestConfiguration ─────────────────────────────────────────────
class TestConfiguration:
"""Test WAITLIST_MODE config flag."""
def test_waitlist_mode_defaults_to_false(self):
"""WAITLIST_MODE should default to false when not set."""
# Config is loaded from env, but we can check the default behavior
# The _env helper treats empty string same as unset
assert hasattr(core.config, "WAITLIST_MODE")
assert isinstance(core.config.WAITLIST_MODE, bool)
def test_resend_audience_waitlist_exists(self):
"""RESEND_AUDIENCE_WAITLIST config should exist."""
assert hasattr(core.config, "RESEND_AUDIENCE_WAITLIST")
assert isinstance(core.config.RESEND_AUDIENCE_WAITLIST, str)
def test_waitlist_mode_can_be_enabled(self):
"""WAITLIST_MODE can be set to True via config."""
with patch.object(core.config, "WAITLIST_MODE", True):
assert core.config.WAITLIST_MODE is True
def test_waitlist_mode_can_be_disabled(self):
"""WAITLIST_MODE can be set to False via config."""
with patch.object(core.config, "WAITLIST_MODE", False):
assert core.config.WAITLIST_MODE is False
# ── TestMigration ─────────────────────────────────────────────────
class TestMigration:
"""Test 0014_add_waitlist migration."""
def test_creates_waitlist_table(self, tmp_path):
"""Migration should create waitlist table."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
tables = _table_names(conn)
conn.close()
assert "waitlist" in tables
def test_waitlist_table_has_correct_columns(self, tmp_path):
"""waitlist table should have all required columns."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
cols = _column_names(conn, "waitlist")
conn.close()
assert "id" in cols
assert "email" in cols
assert "intent" in cols
assert "source" in cols
assert "plan" in cols
assert "ip_address" in cols
assert "created_at" in cols
def test_waitlist_has_unique_constraint(self, tmp_path):
"""waitlist should enforce UNIQUE(email, intent)."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
# Insert first row
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "signup")
)
conn.commit()
# Attempt duplicate - should fail without OR IGNORE
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "signup")
)
conn.commit()
conn.close()
def test_waitlist_allows_same_email_different_intent(self, tmp_path):
"""waitlist should allow same email with different intent."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "signup")
)
conn.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("test@example.com", "supplier_growth")
)
conn.commit()
count = conn.execute("SELECT COUNT(*) FROM waitlist").fetchone()[0]
conn.close()
assert count == 2
def test_waitlist_has_email_index(self, tmp_path):
"""Migration should create index on email column."""
from padelnomics.migrations.migrate import migrate
db_path = str(tmp_path / "test.db")
migrate(db_path)
conn = sqlite3.connect(db_path)
indexes = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='waitlist'"
).fetchall()
index_names = [idx[0] for idx in indexes]
conn.close()
assert any("email" in idx for idx in index_names)
# ── TestWorkerTask ────────────────────────────────────────────────
class TestWorkerTask:
"""Test send_waitlist_confirmation worker task."""
@pytest.mark.asyncio
async def test_sends_entrepreneur_confirmation(self):
"""Task sends confirmation email for entrepreneur signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "entrepreneur@example.com",
"intent": "signup",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "launching soon" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio
async def test_sends_supplier_confirmation(self):
"""Task sends confirmation email for supplier signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_growth",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio
async def test_supplier_email_includes_plan_name(self):
"""Supplier confirmation should mention the specific plan."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_pro",
})
call_args = mock_send.call_args
html = call_args.kwargs["html"]
assert "pro" in html.lower()
@pytest.mark.asyncio
async def test_uses_transactional_email_address(self):
"""Task should use transactional sender address."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "test@example.com",
"intent": "signup",
})
call_args = mock_send.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
# ── TestAuthRoutes ────────────────────────────────────────────────
class TestAuthRoutes:
"""Test /auth/signup route with WAITLIST_MODE."""
@pytest.mark.asyncio
async def test_normal_signup_when_waitlist_disabled(self, client, db):
"""Normal signup flow when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await client.get("/auth/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see normal signup form, not waitlist form
assert "Create Free Account" in html or "Sign Up" in html
@pytest.mark.asyncio
async def test_shows_waitlist_form_when_enabled(self, client, db):
"""GET /auth/signup shows waitlist form when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/auth/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "join" in html.lower() or "early access" in html.lower()
@pytest.mark.asyncio
async def test_captures_email_to_waitlist_table(self, client, db):
"""POST /auth/signup inserts email into waitlist table."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
"plan": "free",
})
# Check database
entry = await _get_waitlist_entry(db, "new@example.com", "free")
assert entry is not None
assert entry["email"] == "new@example.com"
assert entry["intent"] == "free"
@pytest.mark.asyncio
async def test_enqueues_confirmation_email(self, client, db):
"""POST /auth/signup enqueues waitlist confirmation email."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
"plan": "signup",
})
mock_enqueue.assert_called_once_with(
"send_waitlist_confirmation",
{"email": "new@example.com", "intent": "signup"}
)
@pytest.mark.asyncio
async def test_shows_confirmation_page(self, client, db):
"""POST /auth/signup shows waitlist_confirmed.html."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
})
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "new@example.com" in html
assert "waitlist" in html.lower()
@pytest.mark.asyncio
async def test_handles_duplicate_email_gracefully(self, client, db):
"""Duplicate email submission shows same success page."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Insert first entry directly
await db.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("existing@example.com", "signup")
)
await db.commit()
# Submit same email via form
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "existing@example.com",
"plan": "signup",
})
# Should show success page (not error)
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "existing@example.com" in html
# Should only have one row
count = await _count_rows(db, "waitlist")
assert count == 1
@pytest.mark.asyncio
async def test_rejects_invalid_email(self, client, db):
"""POST with invalid email shows error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "not-an-email",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
@pytest.mark.asyncio
async def test_captures_ip_address(self, client, db):
"""POST captures request IP address in waitlist table."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
entry = await _get_waitlist_entry(db, "test@example.com")
assert entry is not None
assert entry["ip_address"] is not None
@pytest.mark.asyncio
async def test_adds_to_resend_audience_when_configured(self, client, db):
"""POST adds email to Resend audience if RESEND_AUDIENCE_WAITLIST is set."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_AUDIENCE_WAITLIST", "aud_test123"), \
patch.object(core.config, "RESEND_API_KEY", "re_test_key"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Contacts.create") as mock_resend:
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
mock_resend.assert_called_once()
call_args = mock_resend.call_args[0][0]
assert call_args["email"] == "test@example.com"
assert call_args["audience_id"] == "aud_test123"
# ── TestSupplierRoutes ────────────────────────────────────────────
class TestSupplierRoutes:
"""Test /suppliers/signup route with WAITLIST_MODE."""
@pytest.mark.asyncio
async def test_shows_waitlist_form_when_enabled(self, client, db):
"""GET /suppliers/signup shows waitlist form when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/suppliers/signup?plan=supplier_growth")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "supplier" in html.lower()
assert "growth" in html.lower()
@pytest.mark.asyncio
async def test_shows_normal_wizard_when_disabled(self, client, db):
"""GET /suppliers/signup shows normal wizard when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await client.get("/suppliers/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see wizard step 1, not waitlist form
assert "step" in html.lower() or "plan" in html.lower()
@pytest.mark.asyncio
async def test_captures_supplier_email_to_waitlist(self, client, db):
"""POST /suppliers/signup/waitlist inserts email into waitlist table."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
})
# Check database
entry = await _get_waitlist_entry(db, "supplier@example.com", "supplier")
assert entry is not None
assert entry["email"] == "supplier@example.com"
assert entry["intent"] == "supplier"
assert entry["plan"] == "supplier_growth"
@pytest.mark.asyncio
async def test_enqueues_supplier_confirmation_email(self, client, db):
"""POST /suppliers/signup/waitlist enqueues confirmation with plan intent."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_pro",
})
mock_enqueue.assert_called_once_with(
"send_waitlist_confirmation",
{"email": "supplier@example.com", "intent": "supplier_pro"}
)
@pytest.mark.asyncio
async def test_shows_supplier_confirmation_page(self, client, db):
"""POST /suppliers/signup/waitlist shows supplier-specific confirmation."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
})
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "supplier@example.com" in html
assert "supplier" in html.lower()
assert "waitlist" in html.lower()
@pytest.mark.asyncio
async def test_handles_duplicate_supplier_email(self, client, db):
"""Duplicate supplier email shows same success page."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Insert first entry
await db.execute(
"INSERT INTO waitlist (email, intent, plan) VALUES (?, ?, ?)",
("existing@supplier.com", "supplier", "supplier_growth")
)
await db.commit()
# Submit duplicate
response = await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "existing@supplier.com",
"plan": "supplier_growth",
})
# Should show success page
assert response.status_code == 200
# Should still have only one row
count = await _count_rows(db, "waitlist")
assert count == 1
@pytest.mark.asyncio
async def test_rejects_invalid_supplier_email(self, client, db):
"""POST with invalid email redirects with error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "invalid",
"plan": "supplier_growth",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
# ── TestEdgeCases ─────────────────────────────────────────────────
class TestEdgeCases:
"""Test edge cases and error handling."""
@pytest.mark.asyncio
async def test_empty_email_rejected(self, client, db):
"""Empty email field shows error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
@pytest.mark.asyncio
async def test_whitespace_only_email_rejected(self, client, db):
"""Whitespace-only email shows error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": " ",
}, follow_redirects=True)
html = await response.get_data(as_text=True)
assert "valid email" in html.lower() or "error" in html.lower()
@pytest.mark.asyncio
async def test_email_normalized_to_lowercase(self, client, db):
"""Email addresses are normalized to lowercase."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "Test@EXAMPLE.COM",
})
entry = await _get_waitlist_entry(db, "test@example.com")
assert entry is not None
assert entry["email"] == "test@example.com"
@pytest.mark.asyncio
async def test_database_error_shows_success_page(self, client, db):
"""Database errors are silently caught and success page still shown."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("padelnomics.core.execute", side_effect=Exception("DB error")):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
# Should still show success page (fail silently)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_resend_api_error_handled_gracefully(self, client, db):
"""Resend API errors don't break the flow."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch.object(core.config, "RESEND_AUDIENCE_WAITLIST", "aud_test"), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("resend.Contacts.create", side_effect=Exception("API error")):
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
# Should still show success page
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "test@example.com" in html
# ── TestIntegration ───────────────────────────────────────────────
class TestPlannerExport:
"""Test planner export waitlist gating."""
@pytest.mark.asyncio
async def test_export_shows_waitlist_when_enabled(self, auth_client, db):
"""GET /planner/export shows waitlist page when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await auth_client.get("/planner/export")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "coming soon" in html.lower()
assert "business plan" in html.lower()
@pytest.mark.asyncio
async def test_export_shows_normal_page_when_disabled(self, auth_client, db):
"""GET /planner/export shows normal export page when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await auth_client.get("/planner/export")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see normal export page, not waitlist
assert "scenario" in html.lower() or "language" in html.lower()
@pytest.mark.asyncio
async def test_export_requires_login(self, client, db):
"""Export page requires authentication."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/planner/export", follow_redirects=False)
# Should redirect to login
assert response.status_code == 302 or response.status_code == 401
# ── TestWaitlistGateDecorator ─────────────────────────────────────
class TestWaitlistGateDecorator:
"""Test waitlist_gate decorator via integration tests."""
@pytest.mark.asyncio
async def test_passes_through_when_waitlist_disabled(self, client):
"""Decorator passes through to normal flow when WAITLIST_MODE=false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await client.get("/auth/signup")
html = await response.get_data(as_text=True)
# Should see normal signup, not waitlist
assert "waitlist" not in html.lower() or "create" in html.lower()
@pytest.mark.asyncio
async def test_intercepts_get_when_waitlist_enabled(self, client):
"""Decorator intercepts GET requests when WAITLIST_MODE=true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/auth/signup")
html = await response.get_data(as_text=True)
# Should see waitlist page
assert "waitlist" in html.lower()
@pytest.mark.asyncio
async def test_ignores_post_requests(self, client, db):
"""Decorator lets POST requests through even when WAITLIST_MODE=true."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# POST should still be handled by waitlist logic, not bypassed
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "test@example.com",
})
assert response.status_code == 200
@pytest.mark.asyncio
async def test_evaluates_callable_context(self, client):
"""Decorator evaluates callable context values at request time."""
with patch.object(core.config, "WAITLIST_MODE", True):
# Test that plan query param is passed to template
response = await client.get("/auth/signup?plan=starter")
html = await response.get_data(as_text=True)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_handles_complex_context_variables(self, client):
"""Decorator handles multiple context variables for suppliers."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/suppliers/signup?plan=supplier_pro")
html = await response.get_data(as_text=True)
assert response.status_code == 200
assert "supplier" in html.lower()
# ── TestCaptureWaitlistEmail ──────────────────────────────────────
class TestCaptureWaitlistEmail:
"""Test capture_waitlist_email helper via integration tests."""
@pytest.mark.asyncio
async def test_new_insert_returns_true(self, client, db):
"""Submitting new email creates database entry."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "new@example.com",
})
# Verify DB entry created
entry = await _get_waitlist_entry(db, "new@example.com")
assert entry is not None
@pytest.mark.asyncio
async def test_duplicate_returns_false(self, client, db):
"""Submitting duplicate email doesn't create second entry."""
# Insert first entry
await db.execute(
"INSERT INTO waitlist (email, intent) VALUES (?, ?)",
("existing@example.com", "signup")
)
await db.commit()
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "existing@example.com",
})
# Should still have only one row
count = await _count_rows(db, "waitlist")
assert count == 1
@pytest.mark.asyncio
async def test_enqueues_only_on_new(self, client, db):
"""Email confirmation only enqueued for new signups."""
# Already tested by test_enqueues_confirmation_email in TestAuthRoutes
pass
@pytest.mark.asyncio
async def test_adds_to_resend_audience(self, client, db):
"""Submitting email adds to Resend audience when configured."""
# Already tested by test_adds_to_resend_audience_when_configured in TestAuthRoutes
pass
@pytest.mark.asyncio
async def test_silent_error_on_db_failure(self, client, db):
"""DB errors are handled gracefully."""
# Already tested by test_database_error_shows_success_page in TestEdgeCases
pass
@pytest.mark.asyncio
async def test_silent_error_on_resend_failure(self, client, db):
"""Resend API errors are handled gracefully."""
# Already tested by test_resend_api_error_handled_gracefully in TestEdgeCases
pass
@pytest.mark.asyncio
async def test_captures_plan_parameter(self, client, db):
"""Supplier signup captures plan parameter."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "test@example.com",
"plan": "supplier_pro",
})
entry = await _get_waitlist_entry(db, "test@example.com", "supplier")
assert entry is not None
assert entry["plan"] == "supplier_pro"
@pytest.mark.asyncio
async def test_captures_ip_address(self, client, db):
"""Signup captures request IP address."""
# Already tested by test_captures_ip_address in TestAuthRoutes
pass
@pytest.mark.asyncio
async def test_email_intent_parameter(self, client, db):
"""Supplier signup uses different intent for email vs database."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "test@example.com",
"plan": "supplier_pro",
})
# DB should have intent="supplier"
entry = await _get_waitlist_entry(db, "test@example.com", "supplier")
assert entry is not None
# Email should have intent="supplier_pro" (plan name)
mock_enqueue.assert_called_with(
"send_waitlist_confirmation",
{"email": "test@example.com", "intent": "supplier_pro"}
)
# ── TestIntegration ───────────────────────────────────────────────
class TestIntegration:
"""End-to-end integration tests."""
@pytest.mark.asyncio
async def test_full_entrepreneur_waitlist_flow(self, client, db):
"""Complete flow: GET form → POST email → see confirmation."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Step 1: GET waitlist form
response = await client.get("/auth/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
# Step 2: POST email
response = await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "entrepreneur@example.com",
"plan": "free",
})
# Step 3: Verify confirmation page
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "entrepreneur@example.com" in html
assert "waitlist" in html.lower()
# Step 4: Verify database entry
entry = await _get_waitlist_entry(db, "entrepreneur@example.com", "free")
assert entry is not None
@pytest.mark.asyncio
async def test_full_supplier_waitlist_flow(self, client, db):
"""Complete flow: GET supplier form → POST email → see confirmation."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Step 1: GET supplier waitlist form
response = await client.get("/suppliers/signup?plan=supplier_pro")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "supplier" in html.lower()
# Step 2: POST email
response = await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_pro",
})
# Step 3: Verify confirmation page
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "supplier@example.com" in html
assert "supplier" in html.lower()
# Step 4: Verify database entry
entry = await _get_waitlist_entry(db, "supplier@example.com", "supplier")
assert entry is not None
assert entry["plan"] == "supplier_pro"
@pytest.mark.asyncio
async def test_toggle_off_reverts_to_normal_signup(self, client, db):
"""Setting WAITLIST_MODE=false reverts to normal signup flow."""
# First, enable waitlist mode
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/auth/signup")
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
# Then, disable waitlist mode
with patch.object(core.config, "WAITLIST_MODE", False):
response = await client.get("/auth/signup")
html = await response.get_data(as_text=True)
# Should see normal signup, not waitlist
assert "create" in html.lower() or "sign up" in html.lower()
# Should NOT see waitlist messaging
assert "join the waitlist" not in html.lower()
@pytest.mark.asyncio
async def test_same_email_different_intents_both_captured(self, client, db):
"""Same email can be on both entrepreneur and supplier waitlists."""
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Sign up as entrepreneur
await client.post("/auth/signup", form={
"csrf_token": "test_token",
"email": "both@example.com",
"plan": "free",
})
# Sign up as supplier
await client.post("/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "both@example.com",
"plan": "supplier_growth",
})
# Should have 2 rows
count = await _count_rows(db, "waitlist")
assert count == 2
# Both should exist
entry1 = await _get_waitlist_entry(db, "both@example.com", "free")
entry2 = await _get_waitlist_entry(db, "both@example.com", "supplier")
assert entry1 is not None
assert entry2 is not None