update README with local testing guide, fix feedback placeholder, sync .env.example
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,16 +12,20 @@ DATABASE_PATH=data/app.db
|
|||||||
MAGIC_LINK_EXPIRY_MINUTES=15
|
MAGIC_LINK_EXPIRY_MINUTES=15
|
||||||
SESSION_LIFETIME_DAYS=30
|
SESSION_LIFETIME_DAYS=30
|
||||||
|
|
||||||
# Email (Resend)
|
# Email (Resend) — leave blank for dev (emails print to console)
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM=hello@padelnomics.io
|
EMAIL_FROM=hello@padelnomics.io
|
||||||
ADMIN_EMAIL=leads@padelnomics.io
|
ADMIN_EMAIL=leads@padelnomics.io
|
||||||
|
|
||||||
# Paddle
|
# Paddle — leave blank to skip checkout (overlay won't initialize)
|
||||||
PADDLE_API_KEY=
|
PADDLE_API_KEY=
|
||||||
|
PADDLE_CLIENT_TOKEN=
|
||||||
PADDLE_WEBHOOK_SECRET=
|
PADDLE_WEBHOOK_SECRET=
|
||||||
PADDLE_PRICE_STARTER=
|
PADDLE_ENVIRONMENT=sandbox
|
||||||
PADDLE_PRICE_PRO=
|
|
||||||
|
# Umami — leave blank for dev (analytics tracking disabled)
|
||||||
|
UMAMI_API_URL=https://umami.padelnomics.io
|
||||||
|
UMAMI_API_TOKEN=
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
RATE_LIMIT_REQUESTS=100
|
RATE_LIMIT_REQUESTS=100
|
||||||
|
|||||||
@@ -2,39 +2,295 @@
|
|||||||
|
|
||||||
Plan, finance, and build your padel business.
|
Plan, finance, and build your padel business.
|
||||||
|
|
||||||
## Development
|
## Local Development Setup
|
||||||
|
|
||||||
### CSS (Tailwind)
|
### Prerequisites
|
||||||
|
|
||||||
Uses the [Tailwind CSS standalone CLI](https://tailwindcss.com/blog/standalone-cli) — no Node.js required.
|
- 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
|
```bash
|
||||||
make css-watch # rebuild on file changes (dev)
|
make css-watch # rebuild on file changes (dev)
|
||||||
make css-build # one-off minified build (CI/Docker)
|
make css-build # one-off minified build (CI/Docker)
|
||||||
```
|
```
|
||||||
|
|
||||||
The first run downloads the Tailwind binary to `bin/tailwindcss` automatically.
|
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. Output is gitignored — Docker builds it in a dedicated stage.
|
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')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
## CI/CD:
|
|
||||||
Go to GitLab → padelnomics → Settings → CI/CD → Variables and add:
|
Go to GitLab → padelnomics → Settings → CI/CD → Variables and add:
|
||||||
|
|
||||||
┌─────────────────┬────────────────────────────┬───────────────────────────────────────────┐
|
| Variable | Value | Notes |
|
||||||
│ Variable │ Value │ Notes │
|
|----------|-------|-------|
|
||||||
├─────────────────┼────────────────────────────┼───────────────────────────────────────────┤
|
| SSH_PRIVATE_KEY | Your ed25519 private key | Mask it, type "Variable" |
|
||||||
│ 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 |
|
||||||
│ DEPLOY_HOST │ Your Hetzner server IP │ e.g. 1.2.3.4 │
|
| SSH_KNOWN_HOSTS | Server host key | Run `ssh-keyscan $YOUR_SERVER_IP` |
|
||||||
├─────────────────┼────────────────────────────┼───────────────────────────────────────────┤
|
|
||||||
│ DEPLOY_USER │ SSH username on the server │ e.g. deploy or root │
|
|
||||||
├─────────────────┼────────────────────────────┼───────────────────────────────────────────┤
|
|
||||||
│ SSH_KNOWN_HOSTS │ Server host key │ Run ssh-keyscan $YOUR_SERVER_IP to get it │
|
|
||||||
└─────────────────┴────────────────────────────┴───────────────────────────────────────────┘
|
|
||||||
|
|
||||||
And on the server side (one-time setup):
|
Server-side one-time setup:
|
||||||
1. Add the matching public key to ~/.ssh/authorized_keys for the deploy user
|
1. Add the matching public key to `~/.ssh/authorized_keys` for the deploy user
|
||||||
2. Clone the repo to /opt/padelnomics
|
2. Clone the repo to `/opt/padelnomics`
|
||||||
3. Create .env from padelnomics/.env.example with production values
|
3. Create `.env` from `padelnomics/.env.example` with production values
|
||||||
4. chmod +x deploy.sh && ./deploy.sh for the first deploy
|
4. `chmod +x deploy.sh && ./deploy.sh` for the first deploy
|
||||||
5. Point NPM to port 5000
|
5. Point reverse proxy to port 5000
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="page_url" id="feedback-page-url">
|
<input type="hidden" name="page_url" id="feedback-page-url">
|
||||||
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">Send Feedback</p>
|
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">Send Feedback</p>
|
||||||
<textarea name="message" rows="3" required placeholder="What's on your mind?"
|
<textarea name="message" rows="3" required placeholder="Ideas to improve this page..."
|
||||||
style="width:100%;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
|
style="width:100%;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
|
||||||
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">Send</button>
|
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">Send</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user