Remove outdated SSH-push model referencing GitLab variables. Document the actual pull-based flow: Gitea Actions → tag → supervisor polls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
415 lines
14 KiB
Markdown
415 lines
14 KiB
Markdown
# Padelnomics
|
|
|
|
Plan, finance, and build your padel business.
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
./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](https://docs.astral.sh/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`
|
|
|
|
### 1. Install dependencies
|
|
|
|
```bash
|
|
cd padelnomics
|
|
uv sync
|
|
```
|
|
|
|
### 2. Configure environment
|
|
|
|
```bash
|
|
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 errors
|
|
- `ADMIN_PASSWORD=admin` — admin panel login at `/admin`
|
|
- `DATABASE_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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
uv run python -m padelnomics.app
|
|
```
|
|
|
|
App runs at **http://localhost:5000**.
|
|
|
|
### 6. Start the background worker (separate terminal)
|
|
|
|
```bash
|
|
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)
|
|
|
|
```bash
|
|
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
|
|
|
|
1. Go to http://localhost:5000/auth/login
|
|
2. **Dev shortcut**: http://localhost:5000/auth/dev-login?email=test@example.com
|
|
— instant login, no email needed (DEBUG mode only)
|
|
3. Magic links: submit any email → link prints to console (no Resend key needed)
|
|
|
|
### Financial Planner
|
|
|
|
1. Log in → http://localhost:5000/planner/
|
|
2. Create a scenario, fill in parameters, click Calculate
|
|
3. View results across tabs (Investment, Revenue, Cash Flow, Metrics)
|
|
|
|
### Business Plan PDF Export
|
|
|
|
1. Create at least one scenario with calculated results
|
|
2. Click "Export Business Plan (PDF)" in the planner sidebar, or go to
|
|
http://localhost:5000/planner/export
|
|
3. **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:
|
|
```bash
|
|
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.
|
|
4. **With Paddle sandbox**: complete checkout → webhook triggers worker →
|
|
PDF generated → download link appears
|
|
|
|
### Quote Request (Lead Submission)
|
|
|
|
1. Go to http://localhost:5000/leads/quote
|
|
2. Walk through the 9-step wizard
|
|
3. If logged in: lead submitted directly
|
|
4. If guest: verification email prints to console → click the link → lead
|
|
goes live
|
|
|
|
### Supplier Directory
|
|
|
|
1. http://localhost:5000/directory/ — full-text search, tier-based ordering
|
|
2. Click a supplier → profile page at `/directory/<slug>`
|
|
3. Click tracking routes: `/directory/<slug>/website` and `/directory/<slug>/quote`
|
|
redirect to supplier website / quote form
|
|
|
|
### Supplier Signup + Dashboard
|
|
|
|
1. Visit http://localhost:5000/suppliers — the "For Suppliers" landing page
|
|
2. Click "Get Started" on a plan → redirects to `/suppliers/signup`
|
|
3. Walk through the 4-step HTMX wizard (plan → boosts → credits → checkout)
|
|
4. **Without Paddle**: the final checkout step will fail. To simulate a
|
|
signed-up supplier, insert test data:
|
|
```bash
|
|
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')
|
|
"
|
|
```
|
|
5. Log in as that user → http://localhost:5000/suppliers/dashboard
|
|
6. Browse tabs: Overview, Lead Feed, My Listing, Boost & Upsells
|
|
7. Lead Feed: unlock leads (costs credits), filter by heat/country/timeline
|
|
8. My Listing: edit profile fields, upload logo
|
|
9. Boosts: purchase boosts and credit packs (needs Paddle for actual checkout)
|
|
|
|
### Admin Panel
|
|
|
|
1. Go to http://localhost:5000/admin
|
|
2. Log in with password: `admin` (default in dev)
|
|
3. **Dashboard**: user stats, lead funnel, supplier stats, task queue health
|
|
4. **Leads** (`/admin/leads`): filter by status/heat/country, view detail,
|
|
update status, forward to supplier
|
|
5. **Suppliers** (`/admin/suppliers`): filter by tier/country/name, view detail
|
|
with credit ledger + boost history, adjust credits, change tier
|
|
6. **Feedback** (`/admin/feedback`): view all feedback submissions
|
|
7. **Users** (`/admin/users`): search, impersonate users
|
|
8. **Tasks** (`/admin/tasks`): view/retry/delete background tasks
|
|
|
|
### Feedback Widget
|
|
|
|
1. Click the "Feedback" button in the top navbar (visible on every page)
|
|
2. Type a message → Send
|
|
3. View submissions at http://localhost:5000/admin/feedback
|
|
|
|
### Seeding Test Data
|
|
|
|
The database starts empty. To populate it with test data for development:
|
|
|
|
```bash
|
|
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
|
|
|
|
```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
|
|
|
|
```
|
|
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 use `hx-get`.
|
|
- **Paddle.js overlay checkout**: server returns JSON `{items, customData,
|
|
settings}`, frontend calls `Paddle.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)
|
|
|
|
```bash
|
|
docker compose up -d # app + worker + scheduler + litestream
|
|
docker compose logs -f app # tail logs
|
|
```
|
|
|
|
## CI/CD
|
|
|
|
Pull-based deployment via Gitea Actions — no SSH keys or deploy credentials in CI.
|
|
|
|
1. Push to master → Gitea Actions runs tests (`.gitea/workflows/ci.yaml`)
|
|
2. On success, CI creates tag `v<run_number>` using the built-in `github.token`
|
|
3. On-server supervisor polls for new tags every 60s and deploys automatically
|
|
|
|
**Server-side one-time setup:**
|
|
```bash
|
|
bash infra/setup_server.sh # creates padelnomics_service user, keys, dirs
|
|
ssh root@<server> 'bash -s' < infra/bootstrap_supervisor.sh
|
|
```
|
|
|
|
1. `setup_server.sh` generates an ed25519 SSH deploy key — add the printed public key to Gitea:
|
|
`git.padelnomics.io → padelnomics → Settings → Deploy Keys → Add key (read-only)`
|
|
2. Add the printed age public key to `.sops.yaml`, re-encrypt, commit + push
|
|
3. Run `bootstrap_supervisor.sh` — clones from `git.padelnomics.io:2222`, decrypts secrets, starts systemd supervisor
|