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:
@@ -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
|
## Architecture Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
906
padelnomics/docs/WAITLIST.md
Normal file
906
padelnomics/docs/WAITLIST.md
Normal 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
|
||||||
@@ -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 %}
|
||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
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
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -213,11 +213,26 @@ async def login():
|
|||||||
|
|
||||||
@bp.route("/signup", methods=["GET", "POST"])
|
@bp.route("/signup", methods=["GET", "POST"])
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
|
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||||
async def signup():
|
async def signup():
|
||||||
"""Signup page - same as login but with different messaging."""
|
"""Signup page - same as login but with different messaging."""
|
||||||
if g.get("user"):
|
if g.get("user"):
|
||||||
return redirect(url_for("dashboard.index"))
|
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")
|
plan = request.args.get("plan", "free")
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|||||||
38
padelnomics/src/padelnomics/auth/templates/waitlist.html
Normal file
38
padelnomics/src/padelnomics/auth/templates/waitlist.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
import resend
|
import resend
|
||||||
from dotenv import load_dotenv
|
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()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ class Config:
|
|||||||
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
|
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
|
||||||
]
|
]
|
||||||
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
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_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
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}")
|
print(f"[EMAIL] Error sending to {to}: {e}")
|
||||||
return False
|
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
|
# CSRF Protection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -415,3 +464,36 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
|||||||
return response
|
return response
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from quart import Blueprint, Response, g, jsonify, redirect, render_template, request, url_for
|
from quart import Blueprint, Response, g, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
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
|
from .calculator import calc, validate_state
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -175,6 +175,7 @@ async def set_default(scenario_id: int):
|
|||||||
|
|
||||||
@bp.route("/export")
|
@bp.route("/export")
|
||||||
@login_required
|
@login_required
|
||||||
|
@waitlist_gate("export_waitlist.html")
|
||||||
async def export():
|
async def export():
|
||||||
"""Export options page — language, scenario picker, pricing."""
|
"""Export options page — language, scenario picker, pricing."""
|
||||||
scenarios = await get_scenarios(g.user["id"])
|
scenarios = await get_scenarios(g.user["id"])
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
|
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
|
||||||
from werkzeug.utils import secure_filename
|
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(
|
bp = Blueprint(
|
||||||
"suppliers",
|
"suppliers",
|
||||||
@@ -172,8 +172,14 @@ def _lead_tier_required(f):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@bp.route("/signup")
|
@bp.route("/signup")
|
||||||
|
@waitlist_gate(
|
||||||
|
"suppliers/waitlist.html",
|
||||||
|
plan=lambda: request.args.get("plan", "supplier_growth"),
|
||||||
|
plans=lambda: PLAN_FEATURES,
|
||||||
|
)
|
||||||
async def signup():
|
async def signup():
|
||||||
"""Render signup wizard shell with step 1."""
|
"""Render signup wizard shell with step 1."""
|
||||||
|
# Normal signup wizard flow
|
||||||
claim_slug = request.args.get("claim", "")
|
claim_slug = request.args.get("claim", "")
|
||||||
prefill = {}
|
prefill = {}
|
||||||
if claim_slug:
|
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"])
|
@bp.route("/signup/step/<int:step>", methods=["POST"])
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def signup_step(step: int):
|
async def signup_step(step: int):
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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")
|
@task("cleanup_expired_tokens")
|
||||||
async def handle_cleanup_tokens(payload: dict) -> None:
|
async def handle_cleanup_tokens(payload: dict) -> None:
|
||||||
"""Clean up expired auth tokens."""
|
"""Clean up expired auth tokens."""
|
||||||
|
|||||||
922
padelnomics/tests/test_waitlist.py
Normal file
922
padelnomics/tests/test_waitlist.py
Normal 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
|
||||||
Reference in New Issue
Block a user