Deeman bc28d93662
Some checks are pending
CI / test (push) Waiting to run
CI / tag (push) Blocked by required conditions
fix: remove duplicate age key in .sops.yaml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:30:31 +01:00
2026-02-27 14:03:40 +01:00
2026-02-27 15:39:39 +01:00
2026-02-27 14:03:40 +01:00

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:

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

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 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

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

  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:
      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:
    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:

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:

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:

  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:

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 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)

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:

  1. Add the matching public key to ~/.ssh/authorized_keys for the deploy user
  2. Clone the repo to /opt/padelnomics
  3. Create .env from padelnomics/.env.example with production values
  4. chmod +x deploy.sh && ./deploy.sh for the first deploy
  5. Point reverse proxy to port 5000
Description
No description provided
Readme 32 MiB
Languages
HTML 46.4%
Python 43.3%
JavaScript 4.6%
Jinja 2.3%
CSS 2.2%
Other 1.2%