Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Padelnomics
Plan, finance, and build your padel business.
Quick Start
./scripts/dev_setup.sh # one-time: deps, .env, migrations, seed data, CSS
./scripts/dev_run.sh # start app + worker + CSS watcher
Then open:
- App: http://localhost:5000
- Dev login: http://localhost:5000/auth/dev-login?email=dev@localhost
- Admin: http://localhost:5000/admin (password:
admin)
Local Development Setup
Prerequisites
- Python 3.12+
- uv (Python package manager)
- WeasyPrint system dependencies (for PDF export):
- Fedora/RHEL:
sudo dnf install pango gdk-pixbuf2 cairo - Debian/Ubuntu:
sudo apt install libpango-1.0-0 libgdk-pixbuf2.0-0 libcairo2 - macOS:
brew install pango gdk-pixbuf cairo
- Fedora/RHEL:
1. Install dependencies
cd padelnomics
uv sync
2. Configure environment
cp .env.example .env
# Edit .env if you want to change defaults — it works out of the box for dev
Key defaults for local dev:
DEBUG=true— enables dev login, console email output, verbose errorsADMIN_PASSWORD=admin— admin panel login at/adminDATABASE_PATH=data/app.db— SQLite file, auto-created- Paddle/Resend keys left blank — checkout overlay and email sending disabled, magic links print to console
3. Run database migrations
uv run python -m padelnomics.migrations.migrate
This creates data/app.db with all tables. Fresh databases get the full
schema in one shot; existing databases get incremental migrations applied.
4. (Optional) Set up Paddle sandbox products
Only needed if you want to test actual checkout flows:
# Add your Paddle sandbox keys to .env first:
# PADDLE_API_KEY=test_xxx
# PADDLE_CLIENT_TOKEN=test_xxx
# PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx
uv run python -m padelnomics.scripts.setup_paddle
This creates all products/prices in Paddle sandbox and writes the IDs to the
paddle_products table. Without this, checkout buttons will show an error
("Invalid plan selected") but everything else works.
5. Start the app
uv run python -m padelnomics.app
App runs at http://localhost:5000.
6. Start the background worker (separate terminal)
uv run python -m padelnomics.worker
The worker processes background tasks: emails, lead forwarding, PDF generation, credit refills. Without it, queued tasks stay pending but the app still works.
7. CSS (Tailwind)
make css-watch # rebuild on file changes (dev)
make css-build # one-off minified build (CI/Docker)
First run downloads the Tailwind standalone CLI to bin/tailwindcss.
Edit src/padelnomics/static/css/input.css for theme tokens, base styles,
and component classes.
Testing Each Feature Locally
Authentication
- Go to http://localhost:5000/auth/login
- Dev shortcut: http://localhost:5000/auth/dev-login?email=test@example.com — instant login, no email needed (DEBUG mode only)
- Magic links: submit any email → link prints to console (no Resend key needed)
Financial Planner
- Log in → http://localhost:5000/planner/
- Create a scenario, fill in parameters, click Calculate
- View results across tabs (Investment, Revenue, Cash Flow, Metrics)
Business Plan PDF Export
- Create at least one scenario with calculated results
- Click "Export Business Plan (PDF)" in the planner sidebar, or go to http://localhost:5000/planner/export
- Without Paddle: checkout will fail (no products in DB). To test the
PDF generation directly, you can:
- Insert a test export record in the DB:
uv run python -c " import sqlite3 conn = sqlite3.connect('data/app.db') conn.execute('''INSERT INTO business_plan_exports (user_id, scenario_id, language, status) VALUES (1, 1, 'en', 'pending')''') conn.commit() print('Export record created with id:', conn.execute('SELECT last_insert_rowid()').fetchone()[0]) " - Then visit http://localhost:5000/planner/export (the generating page will show). The worker generates the PDF if running.
- Insert a test export record in the DB:
- With Paddle sandbox: complete checkout → webhook triggers worker → PDF generated → download link appears
Quote Request (Lead Submission)
- Go to http://localhost:5000/leads/quote
- Walk through the 9-step wizard
- If logged in: lead submitted directly
- If guest: verification email prints to console → click the link → lead goes live
Supplier Directory
- http://localhost:5000/directory/ — full-text search, tier-based ordering
- Click a supplier → profile page at
/directory/<slug> - Click tracking routes:
/directory/<slug>/websiteand/directory/<slug>/quoteredirect to supplier website / quote form
Supplier Signup + Dashboard
- Visit http://localhost:5000/suppliers — the "For Suppliers" landing page
- Click "Get Started" on a plan → redirects to
/suppliers/signup - Walk through the 4-step HTMX wizard (plan → boosts → credits → checkout)
- Without Paddle: the final checkout step will fail. To simulate a
signed-up supplier, insert test data:
uv run python -c " import sqlite3 conn = sqlite3.connect('data/app.db') # Update an existing supplier to be claimed by user 1 with growth tier conn.execute('''UPDATE suppliers SET claimed_by = 1, tier = 'growth', credit_balance = 30 WHERE id = 1''') conn.commit() print('Supplier 1 claimed by user 1 with growth tier') " - Log in as that user → http://localhost:5000/suppliers/dashboard
- Browse tabs: Overview, Lead Feed, My Listing, Boost & Upsells
- Lead Feed: unlock leads (costs credits), filter by heat/country/timeline
- My Listing: edit profile fields, upload logo
- Boosts: purchase boosts and credit packs (needs Paddle for actual checkout)
Admin Panel
- Go to http://localhost:5000/admin
- Log in with password:
admin(default in dev) - Dashboard: user stats, lead funnel, supplier stats, task queue health
- Leads (
/admin/leads): filter by status/heat/country, view detail, update status, forward to supplier - Suppliers (
/admin/suppliers): filter by tier/country/name, view detail with credit ledger + boost history, adjust credits, change tier - Feedback (
/admin/feedback): view all feedback submissions - Users (
/admin/users): search, impersonate users - Tasks (
/admin/tasks): view/retry/delete background tasks
Feedback Widget
- Click the "Feedback" button in the top navbar (visible on every page)
- Type a message → Send
- View submissions at http://localhost:5000/admin/feedback
Seeding Test Data
The database starts empty. To populate it with test data for development:
uv run python -c "
import sqlite3, json
conn = sqlite3.connect('data/app.db')
# Create a test supplier
conn.execute('''INSERT OR IGNORE INTO suppliers
(name, slug, category, country_code, city, tier, credit_balance,
short_description, website, contact_name, contact_email)
VALUES
('Padel Pro Courts', 'padel-pro-courts', 'court_manufacturer', 'DE', 'Munich',
'free', 0, 'Premium padel court manufacturer', 'https://example.com',
'Max Mueller', 'max@example.com')''')
# Create a test lead
conn.execute('''INSERT OR IGNORE INTO lead_requests
(lead_type, facility_type, court_count, glass_type, country, location,
timeline, budget_estimate, heat_score, credit_cost, status,
contact_name, contact_email, contact_phone, stakeholder_type,
verified_at, build_context, financing_status)
VALUES
('quote', 'indoor_rent', 6, 'panoramic', 'Germany', 'Berlin',
'3-6 months', 450000, 'hot', 35, 'new',
'John Doe', 'john@example.com', '+49123456', 'investor',
datetime('now'), 'lease_signed', 'loan_approved')''')
conn.commit()
print('Test data seeded')
"
Resend Test Emails
When RESEND_API_KEY is blank (default), all emails print to the console —
no Resend account needed.
When you have a Resend API key, you can use their test addresses to simulate delivery outcomes without a verified domain:
| Address | Behavior |
|---|---|
delivered@resend.dev |
Accepted, simulates successful delivery |
bounced@resend.dev |
Simulates a hard bounce |
complained@resend.dev |
Simulates a spam complaint |
suppressed@resend.dev |
Simulates a suppressed recipient |
These support +label syntax (e.g. delivered+test1@resend.dev) for unique
recipients. You can also send from onboarding@resend.dev without a
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
# 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:
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:
@waitlist_gate(template, **context)decorator — intercepts GET requests whenWAITLIST_MODE=truecapture_waitlist_email(email, intent, plan)helper — handles DB + email + Resend
Example usage:
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 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
# In .env
WAITLIST_MODE=false
Restart → all routes revert to normal flows.
Architecture Overview
padelnomics/src/padelnomics/
├── app.py # Application factory, blueprint registration
├── core.py # Config, database, email, CSRF, rate limiting
├── credits.py # Credit system (balance, ledger, unlock)
├── businessplan.py # WeasyPrint PDF generation engine
├── worker.py # SQLite-based background task queue
├── migrations/
│ ├── schema.sql # Full schema (source of truth for fresh DBs)
│ ├── migrate.py # Migration runner
│ └── versions/ # Incremental migrations (0001-0008)
├── scripts/
│ └── setup_paddle.py # Create Paddle products + write IDs to DB
├── templates/
│ ├── base.html # Layout: nav, footer, HTMX, Paddle.js, Umami
│ └── businessplan/ # PDF templates (plan.html, plan.css)
├── static/ # CSS, JS, images
├── admin/ # Admin panel (dashboard, users, leads, suppliers, feedback)
├── auth/ # Magic link auth, dev-login
├── billing/ # Paddle checkout, webhooks, subscription management
├── dashboard/ # User dashboard
├── directory/ # Supplier directory (FTS5 search, profiles)
├── leads/ # Quote request wizard
├── planner/ # Financial planner + PDF export routes
├── public/ # Landing, marketing, legal, feedback endpoint
└── suppliers/ # Supplier signup wizard, dashboard (4 tabs), lead feed
Key Patterns
- HTMX everywhere: server renders HTML partials, HTMX swaps them. No
client-side state management. Forms use
hx-post, filters usehx-get. - Paddle.js overlay checkout: server returns JSON
{items, customData, settings}, frontend callsPaddle.Checkout.open(). Webhook handles the rest. - Credit system: heat-based pricing (hot=35, warm=20, cool=8). Suppliers unlock leads by spending credits. Ledger-based balance tracking.
- SQLite + aiosqlite: single-file database with WAL mode. Background worker uses same DB file.
- Blueprints: each domain has its own blueprint with template folder.
Docker (Production)
docker compose up -d # app + worker + scheduler + litestream
docker compose logs -f app # tail logs
CI/CD
Go to GitLab → padelnomics → Settings → CI/CD → Variables and add:
| Variable | Value | Notes |
|---|---|---|
| SSH_PRIVATE_KEY | Your ed25519 private key | Mask it, type "Variable" |
| DEPLOY_HOST | Your Hetzner server IP | e.g. 1.2.3.4 |
| DEPLOY_USER | SSH username on the server | e.g. deploy or root |
| SSH_KNOWN_HOSTS | Server host key | Run ssh-keyscan $YOUR_SERVER_IP |
Server-side one-time setup:
- Add the matching public key to
~/.ssh/authorized_keysfor the deploy user - Clone the repo to
/opt/padelnomics - Create
.envfrompadelnomics/.env.examplewith production values chmod +x deploy.sh && ./deploy.shfor the first deploy- Point reverse proxy to port 5000