refactor: flatten padelnomics/padelnomics/ → repo root

git mv all tracked files from the nested padelnomics/ workspace
directory to the git repo root. Merged .gitignore files.
No code changes — pure path rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 00:44:40 +01:00
parent 5e471567b9
commit 4ae00b35d1
235 changed files with 45 additions and 42 deletions

445
docs/CMS.md Normal file
View File

@@ -0,0 +1,445 @@
# CMS & Programmatic SEO
How the content management system works: template definitions, bulk article generation, scenario embedding, the build pipeline, and how content is discovered and served.
---
## Overview
The CMS is a **template-driven, programmatic content system**. The core idea:
1. Define a **template** — URL pattern, title pattern, meta description pattern, and a Jinja2 body
2. Upload **data rows** (CSV or single entry via admin UI) that fill the template variables
3. **Generate** — the system runs the financial calculator, writes HTML to disk, and inserts `articles` rows
4. Articles are **served** by a catch-all route that looks up the URL path in the database
This is designed for scale: one template definition can produce hundreds of SEO-targeted articles.
---
## Database schema
```
articles — Published articles (the SEO output)
article_templates — Template definitions (patterns + Jinja2 body)
template_data — One row per article to be generated; links template → article
published_scenarios — Calculator results embedded in article bodies
articles_fts — FTS5 virtual table for full-text search over articles
```
### articles
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `url_path` | TEXT UNIQUE | Matches `request.path`, e.g. `/markets/de/berlin` |
| `slug` | TEXT UNIQUE | Used for the build HTML filename |
| `title` | TEXT | |
| `meta_description` | TEXT | |
| `country` | TEXT | Used for grouping in the markets hub |
| `region` | TEXT | Used for grouping in the markets hub |
| `og_image_url` | TEXT | |
| `status` | TEXT | `'published'` or `'draft'` |
| `published_at` | DATETIME | Future dates suppress the article until then |
| `template_data_id` | INTEGER FK | Back-reference to `template_data.id` |
FTS5 virtual table `articles_fts` is kept in sync with `title`, `meta_description`, `country`, `region`.
### article_templates
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `name` | TEXT | Human label shown in admin |
| `slug` | TEXT UNIQUE | |
| `content_type` | TEXT | e.g. `'market'` |
| `input_schema` | JSON | Array of field definitions; drives the admin form |
| `url_pattern` | TEXT | Jinja2, e.g. `/markets/{{ country \| lower }}/{{ city_slug }}` |
| `title_pattern` | TEXT | Jinja2, e.g. `{{ city }} Padel Court Economics` |
| `meta_description_pattern` | TEXT | Jinja2 |
| `body_template` | TEXT | Jinja2 Markdown with optional `[scenario:slug]` markers |
### template_data
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `template_id` | INTEGER FK | → `article_templates.id` |
| `data_json` | JSON | The variables that fill the template |
| `scenario_id` | INTEGER FK | → `published_scenarios.id` (set after generation) |
| `article_id` | INTEGER FK | → `articles.id` (set after generation) |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
### published_scenarios
Pre-calculated financial scenarios embedded in articles.
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `slug` | TEXT UNIQUE | Referenced in `[scenario:slug]` markers |
| `title` | TEXT | |
| `subtitle` | TEXT | |
| `location` | TEXT | City name |
| `country` | TEXT | |
| `venue_type` | TEXT | |
| `ownership` | TEXT | |
| `court_config` | TEXT | |
| `state_json` | JSON | Calculator input state |
| `calc_json` | JSON | Calculator output (all sections) |
| `template_data_id` | INTEGER FK | |
---
## Programmatic SEO pipeline
### Step 1 — Define a template
In admin → Templates → New Template.
**URL pattern** uses Jinja2 and must produce a unique, clean path:
```jinja2
/markets/{{ country | lower }}/{{ city_slug }}
```
**Title and meta description patterns** are also Jinja2:
```jinja2
{{ city }} Padel Court Business Plan — {{ court_count }}-Court Indoor Venue
Annual revenue and ROI analysis for a {{ court_count }}-court padel venue in {{ city }}, {{ country }}.
```
**Body template** is Jinja2 Markdown. Access any field from `data_json`, plus the generated scenario's slug via `scenario_slug`. Use `[scenario:slug]` markers to embed calculator sections:
```markdown
## {{ city }} Market Overview
A {{ court_count }}-court indoor venue in {{ city }} has the following economics:
[scenario:{{ scenario_slug }}]
### Capital Expenditure
[scenario:{{ scenario_slug }}:capex]
### Annual Cash Flow
[scenario:{{ scenario_slug }}:cashflow]
```
**Scenario section options** for the `[scenario:slug:section]` syntax:
| Section | Shows |
|---------|-------|
| _(none)_ | Default summary card |
| `capex` | Capital expenditure breakdown |
| `operating` | Operating cost breakdown |
| `cashflow` | Annual cash flow |
| `returns` | ROI / returns summary |
| `full` | All sections |
### Step 2 — Upload data
Admin → Templates → (template) → Data → Upload CSV, or add single rows via the form.
The CSV columns must match the template's `input_schema`. Each row becomes one `template_data` record and will generate one article.
### Step 3 — Generate
Admin → Templates → (template) → Generate.
**What happens for each data row:**
1. Merge row data with calculator DEFAULTS
2. Run financial calculator → produce `state_json` + `calc_json`
3. Create a `published_scenarios` record with an auto-derived slug
4. Render Jinja2 patterns (URL, title, meta description, body) using row data + scenario slug
5. Convert body Markdown → HTML via `mistune`
6. Replace `[scenario:slug(:section)?]` markers with rendered HTML partials
7. Write final HTML to `data/content/_build/{slug}.html`
8. Back up Markdown source to `data/content/articles/{slug}.md`
9. Create an `articles` record
10. Update `template_data` row with `scenario_id` and `article_id`
**Staggered publishing:** the `articles_per_day` parameter spreads `published_at` dates so articles drip out over time rather than all appearing at once.
### Step 4 — Serve
The `content` blueprint registers a catch-all route **last**, so it only fires if no other blueprint matched:
```python
@bp.route("/<path:url_path>")
async def article_page(url_path: str):
...
```
Reserved prefixes are short-circuited to avoid conflicts:
```python
RESERVED_PREFIXES = (
"/admin", "/auth", "/planner", "/billing", "/dashboard",
"/directory", "/leads", "/suppliers", "/health",
"/sitemap", "/static", "/markets", "/features", "/feedback",
)
```
Lookup query:
```sql
SELECT * FROM articles
WHERE url_path = ? AND status = 'published' AND published_at <= datetime('now')
```
If found, the pre-built HTML is read from `data/content/_build/{slug}.html` and injected into `article_detail.html`. No runtime Markdown rendering.
---
## SEO meta tags
`article_detail.html` overrides the base template's defaults with article-specific values:
```html
<title>{{ article.title }} | Padelnomics</title>
<meta name="description" content="{{ article.meta_description }}">
<link rel="canonical" href="{{ config.BASE_URL }}{{ request.path }}">
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description }}">
<meta property="og:image" content="{{ article.og_image_url }}">
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
```
**JSON-LD structured data** is also injected per article:
```json
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "...",
"datePublished": "2025-01-15",
"author": { "@type": "Organization", "name": "Padelnomics" },
"publisher": { "@type": "Organization", "name": "Padelnomics" }
}
```
---
## Markets hub
`/markets` is the discovery index for all published articles.
- Groups articles by `country` and `region`
- Full-text search via FTS5 (`articles_fts`) across `title`, `meta_description`, `country`, `region`
- HTMX partial at `/markets/results` handles live filtering (no page reload)
FTS5 query (simplified):
```sql
SELECT a.* FROM articles a
JOIN articles_fts ON articles_fts.rowid = a.id
WHERE articles_fts MATCH ?
AND a.status = 'published'
AND a.published_at <= datetime('now')
ORDER BY a.published_at DESC
LIMIT 100
```
Filter fields: `q` (full-text), `country`, `region`.
---
## Directory structure
```
src/padelnomics/
├── content/
│ ├── routes.py # Catch-all article serving + markets hub
│ └── templates/
│ ├── article_detail.html # SEO meta tags, JSON-LD, article body injection
│ ├── markets.html # Discovery index
│ └── partials/
│ ├── market_results.html # HTMX search result rows
│ └── scenario_*.html # Scenario cards (capex, cashflow, etc.)
├── admin/
│ ├── routes.py # Full CMS admin (templates, data, generate, articles)
│ └── templates/admin/
│ ├── templates.html
│ ├── template_form.html
│ ├── template_data.html
│ ├── generate_form.html
│ ├── articles.html
│ └── article_form.html
└── data/
└── content/
├── _build/ # Generated HTML (served at runtime)
│ └── {slug}.html
└── articles/ # Markdown source backup
└── {slug}.md
```
---
## Adding a new content type
### Step 1 — Define the template in admin
Go to **Admin → Templates → New Template** and fill in:
| Field | Notes |
|-------|-------|
| Name | Human label, e.g. `"Indoor Market Report"` |
| Slug | Auto-generated; used to derive scenario slugs (cannot be changed after creation) |
| Content type | `"calculator"` (the only current option) |
| Input schema | JSON — see below |
| URL pattern | Jinja2 — see below |
| Title pattern | Jinja2 |
| Meta description pattern | Jinja2 (optional) |
| Body template | Jinja2 Markdown |
### Step 2 — Write the input_schema
`input_schema` is a JSON array of field definitions. Each field becomes a column in the CSV and an input in the single-row add form.
```json
[
{ "name": "city", "label": "City", "field_type": "text", "required": true },
{ "name": "city_slug", "label": "City slug", "field_type": "text", "required": true },
{ "name": "country", "label": "Country (ISO 2)", "field_type": "text", "required": true },
{ "name": "region", "label": "Region", "field_type": "text", "required": false },
{ "name": "court_count", "label": "Number of courts", "field_type": "number", "required": false },
{ "name": "ratePeak", "label": "Peak rate (€/hr)", "field_type": "float", "required": false }
]
```
**Field keys:**
| Key | Required | Values | Effect |
|-----|----------|--------|--------|
| `name` | yes | any string | Variable name in Jinja2 patterns; CSV column header |
| `label` | yes | any string | Human label in the admin form |
| `field_type` | yes | `"text"`, `"number"`, `"float"` | Controls input type and coercion during generation |
| `required` | no | `true` / `false` | Whether the admin form enforces a value |
**`city_slug` is special** — the system looks for a field named exactly `city_slug` to derive the scenario slug. If it's absent, the `template_data` row ID is used as a fallback.
**Calculator field names** — any field whose name matches a key in the calculator `DEFAULTS` dict (e.g. `ratePeak`, `rateOffPeak`, `dblCourts`, `loanPct`) is automatically passed to the financial model during generation. You don't need to do anything extra; just name the field correctly and the generator merges it into the calc state.
### Step 3 — Write the Jinja2 patterns
All patterns are rendered with the same context: every field from the data row, plus `scenario_slug` (injected by the generator).
**URL pattern** — must be unique across all articles and must not start with a reserved prefix:
```jinja2
/markets/{{ country | lower }}/{{ city_slug }}
```
The article slug is derived by stripping the leading `/` and replacing remaining `/` with `-`, so `/markets/de/berlin` → slug `markets-de-berlin`.
**Title pattern:**
```jinja2
{{ city }} Padel Court Business Plan — {{ court_count }}-Court {{ venue_type | title }} Venue
```
**Meta description pattern** (aim for ≤ 160 characters):
```jinja2
Revenue and ROI analysis for a {{ court_count }}-court padel venue in {{ city }}, {{ country }}. Includes CapEx, operating costs, and cash flow projections.
```
**Body template** — Jinja2 Markdown. Use any data row field, plus `scenario_slug` to embed calculator results:
```markdown
## {{ city }} Market Overview
A {{ court_count }}-court indoor venue in {{ city }} ({{ country }}) projects the following economics:
[scenario:{{ scenario_slug }}]
### Capital Expenditure
[scenario:{{ scenario_slug }}:capex]
### Annual Cash Flow
[scenario:{{ scenario_slug }}:cashflow]
### Returns
[scenario:{{ scenario_slug }}:returns]
```
### Step 4 — How scenario_slug is derived
```
scenario_slug = template_slug + "-" + city_slug
```
Example: template slug `indoor-market` + city_slug `berlin``indoor-market-berlin`.
This slug identifies the `published_scenarios` row. It's what you reference in `[scenario:slug]` markers. Always use `{{ scenario_slug }}` in the body template — never hardcode a slug.
### Step 5 — Upload data
**CSV upload** (Admin → Templates → (template) → Data → Upload CSV):
- Column headers must exactly match the `name` fields in `input_schema`
- One row per article to be generated
- Values are coerced to the declared `field_type` (`text` → string, `number` → int, `float` → float)
Example CSV:
```csv
city,city_slug,country,region,court_count,ratePeak,rateOffPeak
Berlin,berlin,DE,Brandenburg,6,55,35
Munich,munich,DE,Bavaria,8,60,40
Hamburg,hamburg,DE,Hamburg,4,50,30
```
**Single-row add** — use the form in Admin → Templates → (template) → Data → Add Row for one-off entries.
### Step 6 — Generate
Admin → Templates → (template) → Generate. Set:
- **Start date** — first article's `published_at`
- **Articles per day** — how many articles share each calendar day (stagger cadence)
The generator processes all data rows that don't yet have an `article_id` (i.e. not previously generated). For each row it:
1. Merges calculator fields from the row into `DEFAULTS`, validates and runs `calc(state)`
2. Creates a `published_scenarios` row
3. Injects `scenario_slug` into the render context
4. Renders URL, title, meta, and body via Jinja2
5. Converts body Markdown → HTML, replaces `[scenario:…]` markers with rendered cards
6. Writes `data/content/_build/{slug}.html`
7. Inserts the `articles` row (status `'draft'` until publish date passes)
### Step 7 — Review and publish
Articles with a `published_at` in the future are invisible to the catch-all route. Check a few via Admin → Articles → (article) → Preview before the first date arrives. If anything looks wrong, fix the template and use Rebuild (see below) — no need to delete and re-generate.
---
## Rebuilding an article
If you edit a template body or fix an error in a data row:
1. Admin → Articles → (article) → Rebuild
2. The system re-renders the body template with the current data, regenerates the scenario card HTML, and overwrites the `_build/{slug}.html` file
3. The `articles` row metadata (title, meta description, URL) is also updated
---
## Gotchas
- **Reserved prefixes** — if a new blueprint is added at a path that matches a content article's URL, the blueprint wins. Keep all content under `/markets/` or add a new reserved prefix.
- **Slug uniqueness** — article slugs drive the build filenames. The slug is derived from the URL path; collisions are caught as a DB UNIQUE constraint violation during generation.
- **Future publish dates** — articles with `published_at` in the future are invisible to the catch-all route and the markets hub. They still exist in the DB and can be previewed via admin.
- **FTS5 sync** — the FTS5 table is populated by triggers. If you manually insert into `articles` without going through the admin, run `INSERT INTO articles_fts(articles_fts) VALUES('rebuild')` to resync.
- **Scenario slugs in templates** — use `{{ scenario_slug }}` (injected at generation time) rather than hardcoding a slug in the body template. Hardcoded slugs break if you regenerate with different data.

154
docs/I18N.md Normal file
View File

@@ -0,0 +1,154 @@
# i18n
How internationalisation works in padelnomics: locale files, language routing, template usage, and how to add new keys or languages.
---
## Architecture
```
src/padelnomics/
├── i18n.py # Loader + get_translations() + get_calc_item_names()
├── locales/
│ ├── en.json # 1222 keys, flat key→value
│ └── de.json # 1222 keys, must match en.json exactly
└── app.py # registers tformat filter + t/lang in template context
```
Both JSON files are loaded at import time. A key-parity assertion runs on startup — mismatched keys crash the process immediately rather than serving partially translated pages.
---
## Language routing
Lang-prefixed blueprints (`public`, `planner`, `directory`, `leads`, `suppliers`, `content`) all mount under `/<lang>/`. The `<lang>` segment is validated in `before_request`; unsupported values get a 404.
Blueprints without a lang prefix (`auth`, `dashboard`, `billing`, `admin`) detect language from a cookie (`lang`) → `Accept-Language` header → fallback `"en"`.
`g.lang` is set on every request and used by `get_translations(g.lang)` to populate the `t` context variable available in all templates.
---
## Template usage
### Basic lookup
```jinja2
{{ t.nav_planner }}
{{ t.landing_faq_q1 }}
```
`t` is a plain dict; `t.key` and `t["key"]` are equivalent in Jinja2.
### Parameterised strings
Use the `tformat` filter for strings with runtime values. The JSON value uses `{named_placeholder}` syntax:
```json
"dir_results_count": "Showing {shown} of {total} suppliers"
"dir_results_count" (de): "{shown} von {total} Anbietern"
```
```jinja2
{{ t.dir_results_count | tformat(shown=suppliers|length, total=total_count) }}
```
### JS-embedded strings
Server-render translation values into `<script>` blocks using `| tojson` to get a properly quoted JS string literal:
```jinja2
btn.textContent = {{ t.sup_step4_checkout | tojson }};
var loadingText = {{ t.sup_step4_loading | tojson }};
```
### Looping over a key list
Feature lists and similar repeated items use a key list rather than a parallel translated list:
```jinja2
{% for key in plan.feature_keys %}
<li>{{ t[key] }}</li>
{% endfor %}
```
---
## Key naming conventions
Keys are `snake_case` with a module prefix. Suffixes hint at the UI role.
| Prefix | Module / area | Count |
|--------|---------------|-------|
| `sup_` | Supplier signup wizard, dashboard | 238 |
| `dir_` | Directory (listing, filters, enquiry) | 83 |
| `landing_` | Landing page (hero, journey, FAQ, SEO) | 78 |
| `planner_` | Financial planner (steps, sections, charts) | 76 |
| `tip_` | Tooltip copy | 70 |
| `sl_` | Slider labels in planner inputs | 60 |
| `suppliers_` | Supplier-facing static copy | 51 |
| `scenario_` | Scenario names, labels | 50 |
| `auth_` | Auth pages (login, signup, verify) | 42 |
| `features_` | Features page | 26 |
| `export_` | PDF export page | 23 |
| `about_` | About page | 13 |
| `nav_` | Navigation | 12 |
| `bp_` | Business plan PDF section headings | 9 |
| `plan_` | Supplier plan feature strings | 16 |
| `wf_` | Waterfall table row labels | 9 |
| `q*_` / `qs_` / `qv_` | Quote wizard steps | ~115 |
Common suffixes: `_title`, `_h1`, `_h2`, `_h3`, `_subtitle`, `_desc`, `_label`, `_btn`, `_hint`, `_body`, `_p`, `_item`.
---
## Two translation namespaces
**UI strings** (`locales/en.json`, `locales/de.json`) — everything rendered in templates. Loaded via `get_translations(lang)`.
**Calculator item names** (`_CALC_ITEM_NAMES` in `i18n.py`) — CAPEX/OPEX line item display names used only by `calculator.py`. Kept inline (~36 keys × 2 langs) because they belong to a different domain and are never referenced from templates.
Do not mix the two: `get_translations()` returns UI strings, `get_calc_item_names()` returns calc item names.
---
## Adding keys
1. Add the key to `en.json` and `de.json` (both files, same key).
2. Use it in the template: `{{ t.your_new_key }}`.
3. Run `uv run pytest tests/test_i18n_parity.py` — verifies key sets match, all values non-empty, and no key has identical EN/DE copy (with an allowlist for proper nouns like EBITDA, Dashboard).
The startup assertion also catches mismatches immediately on next server boot.
---
## Adding a language
1. Create `locales/<lang>.json` with the same keys as `en.json`.
2. Add `"<lang>"` to `SUPPORTED_LANGS` in `i18n.py`.
3. Add `"<lang>"` to `LANG_BLUEPRINTS` if it should be URL-prefixed (it should).
4. Add the language switcher link in `base.html`.
5. Update `test_i18n_parity.py` to include the new locale in its parity checks.
---
## Not yet translated
These areas have no `{% if lang %}` blocks and serve English copy only. They need translation work but were out of scope for the initial i18n foundation:
- `/dashboard/` — user scenario list
- `/billing/` — billing webhooks and receipts
- Supplier dashboard tabs (overview, leads, listing, boosts)
- Business plan PDF — section headings are translated via `bp_*` keys, but formatted values within (payback period strings, metric labels in the PDF template) remain English
**Admin panel** (`/admin/`) is intentionally English-only — it's an internal tool, not user-facing.
---
## Parity test
`tests/test_i18n_parity.py` runs three checks:
1. **Key sets match**`en.json` and `de.json` have identical keys.
2. **No empty values** — every key has a non-empty string in both locales.
3. **No untranslated copy** — no key has the same value in EN and DE (allowlist covers proper nouns: `EBITDA`, `MOIC`, `IRR`, `DSCR`, `Dashboard`, `RevPAH`, etc.).

224
docs/USER_FLOWS.md Normal file
View File

@@ -0,0 +1,224 @@
# User Flows
All user-facing flows through the padelnomics app. Use this as the reference when writing E2E tests or auditing coverage.
---
## 1. Visitor → Planner
**Entry:** `/<lang>/` → click "Planner" in nav
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /<lang>/planner/` | Wizard loads with default state (indoor, 6 courts, rent). `s` = default state, `d` = calc results. |
| 2 | Adjust any slider | `POST /<lang>/planner/calculate` (HTMX, `hx-trigger="input changed delay:200ms"`) → returns `#tab-content` partial |
| 3 | Switch result tab | `POST /<lang>/planner/calculate` with `activeTab=<tab>` → HTMX swaps `#tab-content` |
| 4 | View charts | Charts embedded as `<script type="application/json" id="chartX-data">` in response. `initCharts()` in `planner.js` renders them. |
| 5 | Wizard preview | `#wizPreview` updated OOB (`hx-swap-oob="true"`) with CAPEX / monthly CF / IRR |
**Auth required:** No (logged-in users get their default scenario pre-loaded)
**HTMX partials:** `calculate_response.html`, `wizard_preview.html`
**Key state:** `s` (validated via `validate_state()`), `d` (calc output via `calc()`)
---
## 2. Visitor → Quote Request (from Planner)
**Entry:** Planner → quote sidebar (>1400px wide) or inline CTA (results tabs, <1400px)
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /<lang>/leads/quote` | Planner passes state via query params — step 1 pre-populated |
| 29 | `POST /<lang>/leads/quote/step/<n>` | HTMX: each step swaps `#q-step` content; progress bar OOB-swapped into `#q-progress` |
| 9 (submit) | `POST /<lang>/leads/quote` | Standard HTML form POST (not HTMX) |
| After submit | Verification email sent if guest; or lead created directly if logged in |
| Verify | `GET /<lang>/leads/verify?token=...&lead=...` | Token validated → lead status updated → success page |
**Auth required:** No (guests get email verification; logged-in users skip verification)
**Key validation:** Step 1: facility_type required. Step 2: country required. Step 5: timeline required. Step 7: stakeholder_type required. Step 9: name, email, phone, consent required.
**Email sent:** `send_quote_verification` (worker task) — verify URL must include `/<lang>/`
---
## 3. Visitor → Quote Request (Direct)
Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state). Step 1 shows the full editable form (no pre-fill).
---
## 4. Visitor → Directory
**Entry:** `/<lang>/directory/` via nav
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /<lang>/directory/` | Lists all suppliers, filter UI visible |
| 2 | Search/filter | `GET /<lang>/directory/results?q=...&country=...&category=...` HTMX swaps `#dir-results` |
| 3 | Click supplier card | `GET /<lang>/directory/<slug>` — supplier detail page |
| 4 | Send enquiry | `POST /<lang>/directory/<slug>/enquiry` HTMX swaps `#enquiry-result` |
| 4b | External link | `GET /<lang>/directory/<slug>/website` → 302 redirect (click-tracking) |
| 4c | Get quote | `GET /<lang>/directory/<slug>/quote` → redirect to quote wizard |
**Auth required:** No
**Category/country labels:** Must be translated per `g.lang` via `get_directory_labels(lang)`
---
## 5. Visitor → Signup
**Entry:** Any CTA "Create Account" / "Sign up" → `/auth/signup`
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /auth/signup` | Signup form (or waitlist form if `WAITLIST_MODE=true`) |
| 2 | Submit email | `POST /auth/signup` → sends magic link via `send_welcome` + `send_magic_link` tasks |
| 3 | Click email link | `GET /auth/verify?token=...` → session created, redirect to `next` param or `/dashboard/` |
| 4 | Dashboard | `GET /dashboard/` |
**Auth required:** No
**Language detection:** Auth routes have no `<lang>` prefix — lang detected from cookie or `Accept-Language` header via `@bp.before_request`
---
## 6. Returning User → Login
**Entry:** `/auth/login`
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /auth/login` | Login form |
| 2 | Submit email | `POST /auth/login` → enqueues `send_magic_link` |
| 3 | Confirmation | `GET /auth/magic-link-sent` |
| 4 | Click email link | `GET /auth/verify?token=...` → session set, redirect |
| 5 | Dashboard or prior page | Session `user_id` set |
**Dev shortcut:** `GET /auth/dev-login?email=test@example.com` (DEBUG mode only) — instant login, used in tests
---
## 7. User → Save/Load Scenario
**Entry:** Planner while logged in
| Step | URL | Notes |
|------|-----|-------|
| 1 | Open scenarios panel | `GET /<lang>/planner/scenarios` HTMX partial — lists saved scenarios |
| 2 | Save current state | `POST /<lang>/planner/scenarios/save` (JSON body: `{name, state}`) → creates/updates scenario |
| 3 | Load scenario | `GET /<lang>/planner/scenarios/<id>` → returns scenario JSON → JS populates form |
| 4 | Set default | `POST /<lang>/planner/scenarios/<id>/default` |
| 5 | Delete | `DELETE /<lang>/planner/scenarios/<id>` |
**Auth required:** Yes (`@login_required`)
---
## 8. User → Export PDF
**Entry:** Planner → "Export" tab/button
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /<lang>/planner/export` | Export options page (or waitlist if `WAITLIST_MODE=true`) |
| 2 | Checkout | `POST /<lang>/planner/export/checkout` → returns Paddle checkout URL (JSON) |
| 3 | Paddle checkout | External Paddle overlay/redirect |
| 4 | Post-checkout | `GET /<lang>/planner/export/success` |
| 5 | Download PDF | `GET /<lang>/planner/export/<id>` (checks subscription/purchase) |
**Auth required:** Yes (`@login_required`)
**Email sent:** `send_welcome` (if new user), PDF ready notification
---
## 9. Supplier → Signup
**Entry:** `/<lang>/suppliers/signup` or from nav "For Suppliers"
| Step | URL | Notes |
|------|-----|-------|
| 1 | `GET /<lang>/suppliers/signup` | Plan selection (or waitlist if `WAITLIST_MODE=true`) |
| 24 | `POST /<lang>/suppliers/signup/step/<n>` | HTMX wizard: step 2 = details, step 3 = credits, step 4 = contact |
| Checkout | `POST /<lang>/suppliers/signup/checkout` | Returns Paddle URL |
| Success | `GET /<lang>/suppliers/signup/success` | Post-checkout confirmation |
**Auth required:** No
**Plans:** `basic` (free listing), `growth` (leads), `pro` (pro listing + leads)
---
## 10. Supplier → Dashboard
**Entry:** Login → redirect to `/<lang>/suppliers/dashboard` (if supplier role)
| Tab | URL | Notes |
|-----|-----|-------|
| Overview | `GET /<lang>/suppliers/dashboard/overview` | Stats: views, leads, credits |
| Lead Feed | `GET /<lang>/suppliers/dashboard/leads` | Lead cards (teased for basic, unlockable for growth/pro) |
| Listing | `GET /<lang>/suppliers/dashboard/listing` | Edit supplier profile |
| Boosts | `GET /<lang>/suppliers/dashboard/boosts` | Purchase credit packs |
**Auth required:** Yes — `@_supplier_required` (basic+); lead tabs require `@_lead_tier_required` (growth/pro)
**Dashboard shell:** `GET /<lang>/suppliers/dashboard` — tabs loaded via HTMX
---
## 11. Supplier → Unlock Lead
**Entry:** Lead feed in supplier dashboard
| Step | URL | Notes |
|------|-----|-------|
| 1 | View teased lead | `GET /<lang>/suppliers/dashboard/leads` — lead shown with blurred contact info |
| 2 | Unlock | `POST /<lang>/suppliers/leads/<id>/unlock` — deducts 1 credit, reveals full lead |
| 3 | Receive email | `send_lead_forward_email` task enqueued — full project brief sent to supplier |
| 4 | Entrepreneur notified | `send_lead_matched_notification` task — notifies entrepreneur a supplier was matched |
**Auth required:** Yes — `@_lead_tier_required`
**Credit check:** Server-side check; if 0 credits → redirect to boosts tab
---
## 12. Admin Flows
**Entry:** `/admin/` (requires `@role_required("admin")`)
| Area | URL | What you can do |
|------|-----|-----------------|
| Dashboard | `GET /admin/` | Stats overview |
| Users | `GET /admin/users`, `/admin/users/<id>` | List, view, impersonate |
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
| Feedback | `GET /admin/feedback` | View all submitted feedback |
| Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
| Task Queue | `GET /admin/tasks` | View worker tasks, retry/delete failed |
**Dev shortcut:** `/auth/dev-login?email=<admin-email>` where email is in `config.ADMIN_EMAILS`
---
## Route Prefix Reference
| Blueprint | URL Prefix | Lang-prefixed? |
|-----------|------------|----------------|
| `public` | `/<lang>` | Yes |
| `planner` | `/<lang>/planner` | Yes |
| `directory` | `/<lang>/directory` | Yes |
| `leads` | `/<lang>/leads` | Yes |
| `suppliers` | `/<lang>/suppliers` | Yes |
| `content` | `/<lang>` (catch-all, registered last) | Yes |
| `auth` | `/auth` | No |
| `dashboard` | `/dashboard` | No |
| `billing` | `/billing` | No |
| `admin` | `/admin` | No |
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`
---
## Known Test Shortcuts
- **Dev login (no magic link):** `GET /auth/dev-login?email=...` (only when `DEBUG=True`)
- **Admin login:** `GET /auth/dev-login?email=<email-in-ADMIN_EMAILS>`
- **Quote verify URL pattern:** `GET /<lang>/leads/verify?token=...&lead=...`
- **Auth verify URL pattern:** `GET /auth/verify?token=...`

916
docs/WAITLIST.md Normal file
View File

@@ -0,0 +1,916 @@
# 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 automatically segmented into per-blueprint Resend audiences for targeted 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_API_KEY=re_xyz123 # Optional: audiences created automatically per blueprint
```
### 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
Audiences are created automatically per blueprint — no configuration needed beyond `RESEND_API_KEY`. The first signup from each blueprint creates a named audience in Resend and caches its ID in the `resend_audiences` database table. Subsequent signups from the same blueprint skip the API call and use the cached ID.
### Audience names
| Route | Blueprint | Resend audience |
|-------|-----------|-----------------|
| `/auth/signup` | `auth` | `waitlist-auth` |
| `/suppliers/signup/waitlist` | `suppliers` | `waitlist-suppliers` |
| `/planner/export` | `planner` | `waitlist-planner` |
Adding `@waitlist_gate` to any new blueprint automatically creates its own audience on first signup.
### Setup
Set `RESEND_API_KEY` in `.env` — no audience IDs needed:
```bash
RESEND_API_KEY=re_xyz123
```
### How it works
```python
# Derives audience name from current request's blueprint
blueprint = request.blueprints[0] if request.blueprints else "default"
audience_name = f"waitlist-{blueprint}"
# Lazy-creates audience on first use, caches ID in resend_audiences table
audience_id = await _get_or_create_resend_audience(audience_name)
if audience_id:
resend.Contacts.create({"email": email, "audience_id": audience_id})
```
**Silent failures:**
- `Audiences.create` error → `audience_id` is None, contact skipped
- `Contacts.create` error → ignored
- Duplicate emails (already in audience) → ignored
- API rate limits → ignored
Waitlist signups always succeed even if Resend is down.
### Launch campaign
When ready to launch:
1. Go to Resend dashboard → Audiences → select the segment (e.g., `waitlist-suppliers`)
2. Create a broadcast email: "We're live! Here's your early access..."
3. 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_API_KEY` | string | `""` | Resend API key — audiences created automatically per blueprint |
### Usage
```bash
# .env
WAITLIST_MODE=true
RESEND_API_KEY=re_xyz123
# No audience IDs needed — created automatically on first signup
```
### 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_API_KEY` not set
2. `Audiences.create` API error on first signup (check logs)
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('API key:', 'Set' if config.RESEND_API_KEY else 'Not set')
"
# Check resend_audiences table for cached IDs
uv run python -c "
import sqlite3
conn = sqlite3.connect('data/app.db')
rows = conn.execute('SELECT * FROM resend_audiences').fetchall()
for row in rows:
print(row)
"
```
### 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 (old: manual audience ID required)
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

View File

@@ -0,0 +1,618 @@
# Padel Market Intelligence — Data Sources Inventory
Compiled: 2026-02-21
Purpose: Identify data sources to feed a DuckDB analytics pipeline for padel business intelligence.
---
## Priority Summary Table
Sorted by Priority (High first), then by category.
| Source | Category | Access Method | Priority | Notes |
|--------|----------|---------------|----------|-------|
| OpenStreetMap / Overpass API | Court Locations | Public API | High | Free, global, `sport=padel` tag, no auth |
| Playtomic API (read-only) | Court Locations / Pricing | Public API | High | Some endpoints unauthenticated; official API needs club credentials |
| Eurostat Statistics API | Demographics | Public API | High | Free, no auth, NUTS city-level data |
| US Census Bureau API | Demographics | Public API | High | Free with API key, comprehensive |
| ONS Beta API | Demographics | Public API | High | Free, no auth, 120 req/10 s limit |
| FIP World Padel Report | Market Reports | Open Download | High | Free PDF; 2024 and 2025 editions available |
| Playtomic Global Padel Report | Market Reports | Open Download | High | Free PDF; co-produced with PwC/Strategy& |
| Sport England Active Lives | Demographics | Open Download | High | Free download; UK sports participation data |
| USPA Court Directory | Court Locations | Scrape | High | Website scrape; 100+ member clubs listed |
| DPV Standorte (Germany) | Court Locations | Scrape | High | German federation venue page, small dataset |
| LTA Padel Venue Finder | Court Locations | Scrape | High | UK venue registry; The Padel Directory also available |
| PadelAPI.org | Tournament Data | Public API | High | Free tier: 50k req, last 6 months of data |
| padelapi.org MCP server | Tournament Data | Public API | High | AI-accessible padel tournament & player stats |
| Google Maps Places API | Court Locations | Public API | Medium | $200 free/mo credit; text search for padel courts |
| Playtomic third-party API | Pricing / Bookings | Public API | Medium | Club credential required; read-only; 1 req/min |
| ImmoScout24 API | Real Estate | Public API | Medium | Developer portal; commercial use; auth required |
| Immowelt API | Real Estate | Public API | Medium | API documented; aggregator EstateSync also available |
| planning.data.gov.uk | Regulatory | Public API | Medium | UK planning data portal; some endpoints open |
| FEP (Spanish federation) | Market Reports | Manual | Medium | Annual statistics published as press releases |
| Statista (padel topic page) | Market Reports | Subscription | Low | Some charts free; full data requires subscription |
| Playskan.com | Pricing / Bookings | Scrape | Low | No public API; consumer site; ToS unclear |
| CoStar / LoopNet | Real Estate | Subscription | Low | No public API; subscription only; scraping violates ToS |
| Rightmove Commercial API | Real Estate | Subscription | Low | ADF partner program only; not open to arbitrary developers |
| JLL / CBRE Reports | Real Estate | Manual | Low | Published reports only; no API |
| Court Metrics | Pricing / Utilisation | Subscription | Low | Aggregated padel club competitive intelligence platform |
| Shovels.ai | Regulatory | Subscription | Low | US building permit intelligence; paid |
| Matchi | Court Locations | Scrape | Low | No documented public API; consumer app |
---
## 1. Court Location & Registry Data
### 1.1 OpenStreetMap — Overpass API
| Field | Value |
|-------|-------|
| URL | https://overpass-turbo.eu / https://overpass-api.de/api/ |
| Data Type | Geographic — padel court locations, geometry, names, addresses |
| Access Method | Public API |
| Update Frequency | Continuous (community-edited) |
| License / TOS | ODbL — open use with attribution |
| Priority | **High** |
OSM uses the tag `sport=padel` on `leisure=sports_centre` or `leisure=pitch` nodes. The Overpass API is free, unauthenticated, and globally scoped. Query example:
```
[out:json];
(
node["sport"="padel"];
way["sport"="padel"];
relation["sport"="padel"];
);
out body;
```
Limitations: coverage is community-driven and incomplete in newer markets (Germany, US). Spain and UK coverage is reasonable. Data can be downloaded in bulk as `.osm.pbf` files from Geofabrik for a full DuckDB load.
OSM wiki: https://wiki.openstreetmap.org/wiki/Tag:sport=padel
---
### 1.2 Playtomic API
| Field | Value |
|-------|-------|
| URL | https://third-party.playtomic.io / https://api.playtomic.io/v1 |
| Data Type | Venues (tenants), court availability, pricing slots |
| Access Method | Public API (some endpoints); club credentials (official third-party API) |
| Update Frequency | Real-time |
| License / TOS | Playtomic ToS — data may not be redistributed; read-only API |
| Priority | **High** (unauthenticated availability), **Medium** (official API) |
Two access tiers exist:
**Unauthenticated endpoints** (confirmed via reverse engineering, March 2025):
- `GET /v1/availability?sport_id=PADEL&start_min=...&start_max=...&tenant_id=...` — max 25 h window per request
- `GET /v1/tenants?sport_ids=PADEL&...` — tenant (venue) search by geo-bounds; no auth required
**Official Third-Party API** (credential-based):
- Credentials generated in Playtomic Manager → Settings → Developer Tools
- Requires Champion or Master subscription plan per tenant
- Read-only; rate limit ~1 call/minute
- Auth: `POST https://api.playtomic.io/oauth/token` with `client_id`, `client_secret`, `grant_type`
External API docs (Notion): https://playtomicio.notion.site/Playtomic-External-API-Documentation-v1-5-57430603e8324c7c9f69bb2c9327eb98
Playtomic covers 16,000+ courts globally. The platform is dominant in Spain, UK, France, Germany, and expanding in the US.
---
### 1.3 DPV — Deutscher Padel Verband Standorte
| Field | Value |
|-------|-------|
| URL | https://www.dpv-padel.de/standorte-2/ |
| Data Type | German federation-registered padel venues |
| Access Method | Scrape |
| Update Frequency | Periodic (federation-managed) |
| License / TOS | No explicit open data license; scrape for internal use only |
| Priority | **High** |
The DPV "Standorte" page lists DPV-registered venues in Germany. No API exists. The dataset is small (Germany has ~875 courts as of end 2025) and can be scraped as a one-time or periodic snapshot.
---
### 1.4 LTA Padel Venue Finder (UK)
| Field | Value |
|-------|-------|
| URL | https://www.ltapadel.org.uk/play/find-a-padel-court/ |
| Data Type | UK padel venue registry; court count, location, facilities |
| Access Method | Scrape |
| Update Frequency | Ongoing (LTA maintains registration program) |
| License / TOS | No public API or data license; scrape for internal use |
| Priority | **High** |
The LTA runs a venue registration program. As of July 2025, the UK has 1,000+ courts across 325 venues. The Padel Directory (https://www.thepadeldirectory.co.uk/) is an alternative aggregator with filtering. Neither offers a public API.
---
### 1.5 USPA US Padel Club Directory
| Field | Value |
|-------|-------|
| URL | https://padelusa.org/us-padel-clubs/ |
| Data Type | US padel club registry; name, city, state |
| Access Method | Scrape |
| Update Frequency | Periodic |
| License / TOS | No explicit license; internal use scrape |
| Priority | **High** |
The USPA lists 100+ member clubs with city/state. The dataset is small enough for a one-time scrape plus quarterly refresh. Only USPA member clubs are listed — not comprehensive for all US courts.
---
### 1.6 Google Maps Places API
| Field | Value |
|-------|-------|
| URL | https://developers.google.com/maps/documentation/places/web-service/overview |
| Data Type | Business name, address, coordinates, ratings, opening hours |
| Access Method | Public API (paid) |
| Update Frequency | Real-time |
| License / TOS | Google Maps Platform ToS — data cannot be stored beyond caching limits without a license |
| Priority | **Medium** |
Text Search (`"padel court"` + city) returns POI data including address and rating. Pricing from March 2025: Essentials tier free up to 10,000 events/month; Text Search (Basic) ~$0.04/request beyond that. Storing results in a database requires a Maps Data Export license agreement.
Use case: gap-fill where OSM or federation data is absent, particularly for US venues.
---
### 1.7 World Padel Rating
| Field | Value |
|-------|-------|
| URL | https://app.worldpadelrating.com/tournaments |
| Data Type | Player rankings, tournament venues |
| Access Method | Scrape |
| Update Frequency | After each tournament |
| License / TOS | No public API documented |
| Priority | **Low** |
Tournament venue data, not a comprehensive court registry. Limited utility for location intelligence.
---
### 1.8 Matchi (Racket Sports Booking)
| Field | Value |
|-------|-------|
| URL | https://www.matchi.se |
| Data Type | Venue listings, court availability (Scandinavia, some EU) |
| Access Method | Scrape |
| Update Frequency | Real-time |
| License / TOS | No public API; ToS prohibits scraping |
| Priority | **Low** |
Matchi is a Playtomic competitor popular in Sweden and northern Europe. Used by Playskan as a data source. No documented public API found.
---
## 2. Pricing & Revenue Data
### 2.1 Playtomic — Public Availability & Pricing
(See 1.2 above for API details.)
The unauthenticated `/v1/availability` endpoint returns time slots with prices visible to consumers. This enables per-venue, per-city price benchmarking without club credentials. Max 25 h window per request; rate limit must be respected.
---
### 2.2 Playskan.com
| Field | Value |
|-------|-------|
| URL | https://www.playskan.com |
| Data Type | Aggregated court availability + price ranges across Playtomic, Matchi, Padel Mates (UK-focused) |
| Access Method | Scrape |
| Update Frequency | Real-time (consumer UI) |
| License / TOS | No public API; built on FastAPI + DynamoDB internally; ToS unclear |
| Priority | **Low** |
Playskan is the world's first padel booking aggregator (UK + some EU), described as "Skyscanner for padel courts." The platform itself aggregates Playtomic, Matchi, and Padelmates data. It offers a calendar view with availability counts and price ranges. No public API; the backend is internal. Going directly to source APIs (Playtomic, Matchi) is preferred.
---
### 2.3 Court Metrics
| Field | Value |
|-------|-------|
| URL | https://courtmetrics.io |
| Data Type | Estimated booking revenue, pricing, utilisation signals, Google Maps reputation per club |
| Access Method | Subscription |
| Update Frequency | Ongoing |
| License / TOS | Commercial SaaS; data derived from public booking platform signals |
| Priority | **Low** |
Court Metrics is a padel-specific competitive intelligence SaaS that aggregates publicly visible pricing and availability data plus Google Maps signals. It provides estimated revenue per competitor club. Useful as a benchmark check but adds cost as a dependency. Data source is ultimately the same public Playtomic/booking signals.
---
## 3. Market Growth & Industry Reports
### 3.1 FIP World Padel Report
| Field | Value |
|-------|-------|
| URL | https://www.padelfip.com/world-padel-report-2025/ |
| Data Type | Global court counts, player numbers, federation membership by country, tournament stats |
| Access Method | Open Download (PDF) |
| Update Frequency | Annual (December) |
| License / TOS | FIP copyright; citations permitted |
| Priority | **High** |
Published annually by the International Padel Federation. The 2025 edition (released December 2025) reports 77,300 courts globally (+15.2%), 35M+ players, 100 member federations. The 2024 edition PDF is available directly from the Danish Padel Federation and other mirrors.
Direct PDF (2024): https://padelfip.com/pdf/WORLD_PADEL_REPORT_2024_FIP.pdf
---
### 3.2 Playtomic Global Padel Report
| Field | Value |
|-------|-------|
| URL | https://playtomic.com/global-padel-report |
| Data Type | Global court counts, club growth rates, booking patterns, country-level breakdowns |
| Access Method | Open Download (PDF) |
| Update Frequency | Annual |
| License / TOS | Playtomic/PwC copyright; citations permitted |
| Priority | **High** |
Co-produced with PwC's Strategy& arm. The 2025 report covers 50,000+ courts globally, 3,282 new clubs in 2024 (avg 9/day), 92% player return rate. Free download at playtomic.com/global-padel-report.
PDF mirror: https://www.padeladdict.com/wp-content/uploads/2025/07/PLAYTOMIC_GLOBAL-_PADEL_REPORT_2025.pdf
---
### 3.3 Playtomic Global Padel Report 2023 (Deloitte)
| Field | Value |
|-------|-------|
| URL | https://www.scribd.com/document/714549135/202306-Global-Padel-Report-2023 |
| Data Type | Market valuation (€1.775B global padel club market as of 2023), growth projections |
| Access Method | Open Download (PDF) |
| Update Frequency | One-time (2023 edition) |
| License / TOS | Playtomic/Deloitte copyright |
| Priority | **Medium** |
Earlier edition co-produced with Deloitte. Contains baseline market valuation data useful for DuckDB time series.
---
### 3.4 Statista — Padel Topic Page
| Field | Value |
|-------|-------|
| URL | https://www.statista.com/topics/12528/padel/ |
| Data Type | Market size, equipment revenue, country stats, consumer survey results |
| Access Method | Subscription (some charts free) |
| Update Frequency | Irregular |
| License / TOS | Statista commercial license required for data export |
| Priority | **Low** |
The padel topic page aggregates third-party data (FIP, Playtomic, national federations). The underlying data is available from primary sources for free; Statista adds presentation but no original data collection.
---
### 3.5 Padel Biz Magazine Newsletter
| Field | Value |
|-------|-------|
| URL | https://newsletter.padelbusinessmagazine.com |
| Data Type | Market statistics digests, court count updates, industry news |
| Access Method | Open (newsletter / web) |
| Update Frequency | Weekly |
| License / TOS | Editorial content; no API |
| Priority | **Medium** |
Regularly aggregates and re-publishes FIP/Playtomic data with added context. Useful for tracking new report releases. No structured data.
---
### 3.6 Misitrano Consulting — State of Padel in the US 2025
| Field | Value |
|-------|-------|
| URL | https://www.misitranoconsulting.com/us-padel-report |
| Data Type | US market size, court counts by state, investment trends |
| Access Method | Manual (form download) |
| Update Frequency | Annual |
| License / TOS | Commercial consulting report |
| Priority | **Medium** |
US-specific market sizing. Gated but free download. Useful supplement to FIP for the US segment.
---
## 4. Commercial Real Estate Data
### 4.1 ImmoScout24 API (Germany)
| Field | Value |
|-------|-------|
| URL | https://api.immobilienscout24.de |
| Data Type | Commercial property listings; rent/price per sqm by location |
| Access Method | Public API (requires registration + commercial use agreement) |
| Update Frequency | Real-time (listings) |
| License / TOS | Commercial use only; API ToS prohibits redistribution |
| Priority | **Medium** |
ImmoScout24 has a documented developer portal with Import/Export API, search API, and a Market Data endpoint. Sandbox access is available via registration. The Market Data API provides price/rent indices. Authentication via OAuth 1.0/2.0. Primarily targets real estate agents and portals, but market data endpoints are accessible to analytics users with a commercial account.
---
### 4.2 Immowelt API (Germany)
| Field | Value |
|-------|-------|
| URL | https://www.immowelt.de/anbieten/gewerbe/apitechdoku |
| Data Type | Commercial rental listings; warehouse and industrial space |
| Access Method | Public API (requires partner registration) |
| Update Frequency | Real-time |
| License / TOS | Partner/commercial agreement required |
| Priority | **Medium** |
Immowelt documents an API for commercial listings. EstateSync (https://estatesync.com/en/) provides a wrapper REST API for both ImmoScout24 and Immowelt. Useful for tracking warehouse/industrial rental rates as a proxy for padel hall fit-out costs.
---
### 4.3 Rightmove Commercial Listings API (UK)
| Field | Value |
|-------|-------|
| URL | https://api-docs.rightmove.co.uk/docs/property-feed-api-product/1/overview |
| Data Type | Commercial property listings; rents, sizes, locations |
| Access Method | Subscription (ADF partner program) |
| Update Frequency | Real-time |
| License / TOS | Rightmove ADF partner agreement; not open to arbitrary developers |
| Priority | **Low** |
Rightmove's API (ADF format) is restricted to partner estate agents and portals. Contact adfsupport@rightmove.co.uk for access. Not practically accessible for a startup analytics pipeline without a commercial relationship.
---
### 4.4 LoopNet / CoStar (US + UK)
| Field | Value |
|-------|-------|
| URL | https://www.loopnet.com / https://www.costar.com |
| Data Type | Commercial real estate listings, market analytics |
| Access Method | Subscription |
| Update Frequency | Real-time |
| License / TOS | Proprietary; no public API; scraping violates ToS |
| Priority | **Low** |
CoStar acquired LoopNet and operates both as subscription services. No public API exists. A previous government-mandated data sharing arrangement (post-LoopNet acquisition) with Xceligent collapsed after copyright violations. For US commercial rent benchmarks, manual extraction or a CoStar institutional subscription is the only legitimate path.
---
### 4.5 JLL / CBRE Market Reports
| Field | Value |
|-------|-------|
| URL | https://www.jll.com/en-de/insights / https://www.cbre.com/insights |
| Data Type | Commercial real estate market indices; industrial/warehouse rents; European market outlook |
| Access Method | Manual (PDF reports) |
| Update Frequency | Quarterly |
| License / TOS | Copyright; no API; PDF/HTML reports only |
| Priority | **Low** |
JLL and CBRE publish free quarterly market reports for Germany, UK, and other EU markets covering industrial/warehouse rents. These are manually downloaded PDFs — no structured data export. Useful for one-time benchmarks to seed DuckDB reference tables.
JLL Germany Q4 2025 Investment Market: https://www.jll.com/en-de/insights/market-dynamics/germany-investment
---
## 5. Demographics & Socioeconomics
### 5.1 Eurostat Statistics API
| Field | Value |
|-------|-------|
| URL | https://ec.europa.eu/eurostat/web/user-guides/data-browser/api-data-access/api-introduction |
| Data Type | Population, income, sports participation (EHIS), NUTS city-level |
| Access Method | Public API |
| Update Frequency | Annual to multi-year (survey-dependent) |
| License / TOS | CC BY 4.0 — free use with attribution |
| Priority | **High** |
Eurostat's Statistics API (SDMX 3.0 + REST) is free and unauthenticated. Base URL: `https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{datasetCode}`.
Key datasets:
- `sprt_pcs` — sport participation by country (from EHIS; wave 4 expected 2025/26)
- `urb_cpop1` — city population statistics (NUTS LAU)
- `ilc_di03` — median equivalised net income by NUTS2
The R `eurostat` package and Python `eurostat` library provide typed wrappers. Data is queryable at NUTS2/NUTS3 and city level using `geoLevel=city`.
---
### 5.2 US Census Bureau API
| Field | Value |
|-------|-------|
| URL | https://www.census.gov/data/developers.html |
| Data Type | Population, income, age distribution (ACS 5-year), geographic boundaries |
| Access Method | Public API (free API key) |
| Update Frequency | Annual (ACS) |
| License / TOS | Public domain (US federal government data) |
| Priority | **High** |
The American Community Survey (ACS) API provides city and tract-level demographics. Free API key required (no cost). Endpoint pattern: `https://api.census.gov/data/2023/acs/acs5?get=B01003_001E,NAME&for=place:*&in=state:12`.
Relevant for US market expansion analysis.
---
### 5.3 ONS Beta API (UK)
| Field | Value |
|-------|-------|
| URL | https://developer.ons.gov.uk |
| Data Type | UK city/MSOA demographics, income, population |
| Access Method | Public API |
| Update Frequency | Annual |
| License / TOS | Open Government Licence v3 — free use |
| Priority | **High** |
The ONS Beta API at `https://api.beta.ons.gov.uk/v1` is open and unauthenticated. Rate limit: 120 requests/10 s, 200/min. Datasets include population estimates, deprivation indices, and 2021 census variables at MSOA/LAD level. Sports participation specifically comes from Sport England (see 5.4), not ONS directly.
---
### 5.4 Sport England — Active Lives Survey
| Field | Value |
|-------|-------|
| URL | https://www.sportengland.org/research-and-data/data/active-lives |
| Data Type | UK sports participation rates by sport, age, geography (local authority level) |
| Access Method | Open Download (CSV/Excel) |
| Update Frequency | Annual (April publication) |
| License / TOS | Open Government Licence v3 |
| Priority | **High** |
Active Lives is the UK's primary sports participation survey (~200,000 respondents/year). The November 202324 report was published April 2025. Data tables are downloadable from Sport England's website. The UK Data Service also holds microdata for detailed analysis. Sports classification does not yet include padel as a standalone category, but racket sports and physical activity levels at local authority level are relevant for site selection.
Interactive explorer: https://activelives.sportengland.org/
---
### 5.5 Statista (Sports Market Data)
(See 3.4 above — same platform, subscription required for export.)
---
## 6. Regulatory & Zoning
### 6.1 UK Planning Data Portal
| Field | Value |
|-------|-------|
| URL | https://www.planning.data.gov.uk |
| Data Type | Planning applications, permissions, land use data (England) |
| Access Method | Public API |
| Update Frequency | Ongoing |
| License / TOS | Open Government Licence v3 |
| Priority | **Medium** |
MHCLG's Planning Data service provides an API for planning applications across England. The API documentation is at https://www.planning.data.gov.uk/docs. Third-party services like Landhawk (https://www.landhawk.uk/api/planning-application-data/) and Searchland provide enhanced APIs with historical data back to 1990. The London Planning Datahub (https://www.london.gov.uk/programmes-strategies/planning/digital-planning/planning-london-datahub) provides London-specific real-time planning data.
Use case: identify commercial/industrial sites with planning permission for sports use, or track padel-related applications.
---
### 6.2 GovData — Germany Open Data Portal
| Field | Value |
|-------|-------|
| URL | https://www.govdata.de |
| Data Type | German federal/state open data (CKAN catalog); building permits at aggregate level |
| Access Method | Public API (CKAN REST) |
| Update Frequency | Varies by dataset |
| License / TOS | CKAN ODbL / individual dataset licenses |
| Priority | **Medium** |
GovData hosts 1,200+ high-value datasets from German federal, state, and local governments. Building permit data is available at aggregate statistical level (monthly counts by type, from Destatis). Individual permit records are not centralised — they remain with local Bauämter (building offices). The CEIC database publishes aggregated Germany Building Permits indicators: https://www.ceicdata.com/en/indicator/germany/building-permits.
---
### 6.3 Shovels.ai (US Building Permits)
| Field | Value |
|-------|-------|
| URL | https://www.shovels.ai |
| Data Type | US building permit records; commercial construction activity |
| Access Method | Subscription |
| Update Frequency | Ongoing |
| License / TOS | Commercial SaaS |
| Priority | **Low** |
Shovels aggregates US local building permit databases into a searchable API. Relevant for tracking new sports facility construction in the US. Paid subscription; pricing not publicly listed.
---
## 7. Tournament & Professional Circuit Data
### 7.1 PadelAPI.org
| Field | Value |
|-------|-------|
| URL | https://padelapi.org / https://docs.padelapi.org |
| Data Type | Professional tournament draws, results, player stats, rankings (Premier Padel + WPT archive) |
| Access Method | Public API |
| Update Frequency | Real-time during tournaments |
| License / TOS | Free tier available (50k requests, last 6 months); paid tiers for full history |
| Priority | **High** |
Token-based REST API. Free tier includes 50k requests/month and last 6 months of match data. Covers Premier Padel and 2023 WPT events. Includes an MCP server for AI assistant integration. Useful for correlating major tournament venues with local market demand signals.
---
## 8. DuckDB Integration Notes
### Recommended ingestion patterns
| Source | Ingestion Pattern |
|--------|------------------|
| Eurostat API | `httpfs` + JSON → staging table; run weekly |
| Overpass API / OSM | Bulk `.osm.pbf` download via Geofabrik → DuckDB spatial extension; run monthly |
| Playtomic unauthenticated API | Paginated scraper per city bounding box → Parquet; run nightly |
| FIP / Playtomic PDFs | Manual parse → CSV seed files; run annually |
| US Census ACS | `httpfs` REST → staging; run annually |
| ONS Beta API | `httpfs` REST → staging; run annually |
| Sport England CSV | Manual download → seed file; run annually |
| ImmoScout24 / Immowelt | API → staging (requires partner account); run monthly |
| planning.data.gov.uk | REST API → staging; run weekly for new permissions |
### Key technical constraints
- **Playtomic**: availability endpoint limited to 25 h windows per call; ~1 req/min recommended on the official API. The unauthenticated tenant search endpoint has no documented rate limit but should be throttled (1 req/2 s).
- **Eurostat**: no rate limit documented for the Statistics API; the SDMX API supports bulk dataset downloads.
- **ONS Beta API**: 120 req/10 s hard limit; back off on 429.
- **Google Maps Places**: storage of query results beyond the caching window requires a Maps Data Export license.
- **CoStar/LoopNet/Rightmove**: no legitimate automated access path without partner agreements. Avoid scraping — ToS explicitly prohibit it and CoStar has a history of pursuing copyright enforcement.
---
## Sources
- [Reverse Engineering Playtomic](https://mattrighetti.com/2025/03/03/reverse-engineering-playtomic)
- [Playtomic Third Party API](https://third-party.playtomic.io/)
- [Playtomic External API Documentation v1.5](https://playtomicio.notion.site/Playtomic-External-API-Documentation-v1-5-57430603e8324c7c9f69bb2c9327eb98)
- [Playtomic API Complete Guide](https://helpmanager.playtomic.com/hc/en-gb/articles/38836515997073-Playtomic-API-Complete-Guide)
- [Global Padel Report 2025 — Playtomic](https://playtomic.com/global-padel-report)
- [Global Padel Report 2025 PDF](https://www.padeladdict.com/wp-content/uploads/2025/07/PLAYTOMIC_GLOBAL-_PADEL_REPORT_2025.pdf)
- [FIP World Padel Report 2025](https://www.padelfip.com/world-padel-report-2025/)
- [FIP World Padel Report 2024 PDF](https://padelfip.com/pdf/WORLD_PADEL_REPORT_2024_FIP.pdf)
- [DPV Standorte](https://www.dpv-padel.de/standorte-2/)
- [LTA Padel Court Finder](https://www.ltapadel.org.uk/play/find-a-padel-court/)
- [The Padel Directory UK](https://www.thepadeldirectory.co.uk/)
- [USPA Club Directory](https://padelusa.org/us-padel-clubs/)
- [Playskan — World's First Padel Booking Aggregator (Medium)](https://medium.com/@henrihapponen/we-built-the-worlds-first-padel-booking-aggregator-33c6511e212e)
- [PadelAPI.org](https://padelapi.org)
- [PadelAPI.org Documentation](https://docs.padelapi.org/)
- [OSM Tag: sport=padel](https://wiki.openstreetmap.org/wiki/Tag:sport=padel)
- [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API)
- [Eurostat Statistics API](https://ec.europa.eu/eurostat/web/user-guides/data-browser/api-data-access/api-introduction)
- [Eurostat Sport Participation Dataset (sprt_pcs)](https://ec.europa.eu/eurostat/cache/metadata/en/sprt_pcs_esms.htm)
- [US Census Bureau API](https://www.census.gov/data/developers.html)
- [ONS Developer Hub](https://developer.ons.gov.uk/)
- [Sport England Active Lives](https://www.sportengland.org/research-and-data/data/active-lives)
- [Google Maps Places API](https://developers.google.com/maps/documentation/places/web-service/overview)
- [ImmoScout24 API Developer Portal](https://api.immobilienscout24.de/)
- [Immowelt API Documentation](https://www.immowelt.de/anbieten/gewerbe/apitechdoku)
- [Rightmove Commercial Listings API](https://api-docs.rightmove.co.uk/docs/property-feed-api-product/1/overview)
- [planning.data.gov.uk](https://www.planning.data.gov.uk/)
- [GovData Germany](https://www.govdata.de/)
- [Court Metrics](https://courtmetrics.io/)
- [Shovels.ai](https://www.shovels.ai/)
- [Statista Padel](https://www.statista.com/topics/12528/padel/)
- [JLL Germany Insights](https://www.jll.com/en-de/insights/market-dynamics/germany-investment)
- [CBRE Insights](https://www.cbre.com/insights)
- [Padel Biz Magazine](https://newsletter.padelbusinessmagazine.com)
- [Misitrano — State of Padel US 2025](https://www.misitranoconsulting.com/us-padel-report)

View File

@@ -0,0 +1,685 @@
# Padel Club Financial Model — Market Research Report
**February 2026 · Germany / United Kingdom / United States**
> Currency conversions used throughout: **£1 = €1.20 · $1 = €0.95**
> All values converted to EUR to match the financial model (EUR-only).
---
## How to Use This Report
This report maps directly to the per-city override fields in `seed_content.py`:
| Field | Unit | Section |
|---|---|---|
| `ratePeak` | EUR/hr per court | §24 Court Hire Rates |
| `rateOffPeak` | EUR/hr per court | §24 Court Hire Rates |
| `rentSqm` | EUR/sqm/month | §5 Commercial Rent |
| `electricity` | EUR/month (facility total) | §6 Operating Costs |
| `heating` | EUR/month (facility total) | §6 Operating Costs |
| `staff` | EUR/month (total employer cost) | §6 Operating Costs |
| `permitsCompliance` | EUR one-time | §7 CAPEX & Permits |
| `utilTarget` | % (e.g. 0.30 = 30%) | §8 Recommended Values |
Country-level CAPEX benchmarks (court construction, hall build cost, land) are documented for reference but are not city-specific model inputs in V1.
---
## 2. Court Hire Rates — Germany
Rates are per court per hour (full court, 4 players). Off-peak = weekday daytime before ~17:00; peak = evenings and weekends.
| City | Off-Peak (€/hr) | Peak (€/hr) | Confidence | Key Venue / Source |
|---|---|---|---|---|
| Berlin | €33 | €46 | ✅ High | Playskan city avg; Padel Berlin outdoor €37/indoor €46 |
| Munich | €30 | €42 | ⚠️ Est. | No public pricing; estimated from city market position |
| Hamburg | €26 | €36 | ✅ Medium | Padelon: double court €30 flat; single court €20 |
| Frankfurt | €24 | €28 | ✅ High | Padel Frankfurt: €24 off-peak / €28 peak (confirmed) |
| Cologne | €22 | €27 | ✅ High | Padel Walls: €22 daytime / €26 evening (confirmed) |
| Düsseldorf | €30 | €42 | ⚠️ Est. | TG Nord guest €48 (members club outlier); commercial est. |
| Stuttgart | €26 | €38 | ✅ Medium | Stuttgart Padel 45-min slots: €34/€44 per 4 players = €45/€59/hr; adjusted down for typical 60-min model |
| Leipzig | €18 | €26 | ⚠️ Est. | PadelCity Leipzig (Nov 2024 opening); app-only pricing |
**Industry context:** Playtomic Global Padel Report 2025 shows Germany average GMV per court rose 48% YoY to **€4,000/month**. At 30% utilisation and ~450 bookable slots/month, this implies a blended rate of ~€30/hr — consistent with the figures above.
**Sources:** [Playskan](https://www.playskan.com/), [Padelberlin.de](https://padelberlin.de/pricing/), [Padelon Hamburg](https://www.padel-test.de/en/padel-locations-2/padel-hamburg-rahlstedt/), [Padel Frankfurt / padelfinder.de](https://padelfinder.de/en/paddle-court/padel-frankfurt/), [Padel Walls Cologne](https://www.padel-test.de/en/padel-locations-2/padel-koeln-walls/), [Stuttgart Padel](https://www.stuttgart-padel.com/), [Playtomic Global Padel Report 2025](https://playtomic.com/global-padel-report)
---
## 3. Court Hire Rates — United Kingdom
Rates confirmed from aggregators (Playskan, TimeOut), direct venue pages, and Powerleague data. Listed in GBP first, then EUR.
| City | Off-Peak (£/hr) | Peak (£/hr) | Off-Peak (€/hr) | Peak (€/hr) | Source |
|---|---|---|---|---|---|
| London | £2535 | £4560 | €3042 | €5472 | TimeOut map (Mar 2025); Rocket Padel £20, Padel Social Club £50, The Padel Yard £60 |
| Manchester | £30 | £45 | €36 | €54 | Powerleague Ardwick; DesignMyNight avg £38 blended |
| Birmingham | £22 | £35 | €26 | €42 | Powerleague Birmingham (Jul 2024); Playskan avg £31 |
| Edinburgh | £18 | £28 | €22 | €34 | LTA Padel Edinburgh; Playskan avg £24 blended |
**Notes:**
- London range is extremely wide (£20 suburban Zone 4 → £60 premium Zone 1). Model values use a mid-market estimate (not budget, not flagship).
- Indoor courts cost 1525% more than outdoor across all UK cities.
- Off-peak/peak price gap is typically 1030%; the figures above use the midpoint of reported ranges.
**Sources:** [TimeOut London Padel Map (March 2025)](https://www.timeout.com/london/news/a-comprehensive-map-of-londons-padel-courts-with-pricing-031925), [Playskan UK](https://www.playskan.com/play-padel), [Powerleague Padel Birmingham](https://www.powerleague.com/padel-birmingham), [Powerleague Manchester](https://www.powerleague.com/padel-manchester), [LTA Padel Edinburgh](https://www.ltapadel.org.uk/play/padel-in-your-area/padel-in-edinburgh/)
---
## 4. Court Hire Rates — United States
Listed in USD first, then EUR (×0.95). US padel is emerging but already price-leading globally in gateway cities.
| City | Off-Peak ($/hr) | Peak ($/hr) | Off-Peak (€/hr) | Peak (€/hr) | Source |
|---|---|---|---|---|---|
| Miami | $40 | $60 | €38 | €57 | Ultra Padel: $25/player/90 min outdoor; One Padel Indoor ~$56/court |
| New York | $60 | $110 | €57 | €105 | Padel Haus Brooklyn: $55/person/hr peak (4 players = $220/court); off-peak ~$60 |
| Los Angeles | $60 | $100 | €57 | €95 | The Padel Courts Hollywood: $100/hr Premier; $50 Mini |
| Dallas | $20 | $30 | €19 | €28 | Padel39: $30/90-min drop-in (~$20/hr full court) |
| Austin | $20 | $30 | €19 | €28 | Padel39 Austin: $30/90 min |
| Chicago | $28 | $42 | €27 | €40 | Union Padel Club: $35/hr non-member (opened Jan 2026) |
**Notes:**
- NYC pricing is the highest in the world for a commercial club. Padel Haus's $55/person rate is for 4-player courts → $220/court/hour at peak. This is real pricing for this venue, but the model uses a more conservative estimate of $110/hr peak to reflect market positioning for a hypothetical new entrant.
- Ultra Padel Miami (26 courts, largest US facility) charges ~$4550/court/hour; smaller indoor clubs charge more.
- Sun Belt cities (Dallas, Austin, Miami) see strong outdoor padel demand; Chicago and NYC are predominantly indoor.
**Sources:** [Ultra Padel Miami](https://ultrapadelclub.com/play), [Padel Haus Brooklyn](https://www.padel.haus/), [The Padel Courts LA](https://thepadelcourts.com/prices), [Padel39](https://www.padel39.com), [Union Padel Club Chicago](https://www.unionpadelclub.com/court-booking)
---
## 5. Commercial Rent by City
Padel clubs typically occupy 8002,000 sqm of industrial/warehouse/sports hall space (not retail). Rents are for that category unless noted.
### Germany — Industrial/Warehouse Rent (€/sqm/month)
Data from Realogis H1 2024 Mietpreiskarte and Statista.
| City | Rent (€/sqm/mo) | Range | Notes |
|---|---|---|---|
| Munich | €10.50 | €9.0012.00 | Top-tier logistics market; prime rents at record high |
| Berlin | €10.50 | €9.0012.00 | Comparable to Munich; strong demand |
| Hamburg | €9.00 | €8.0010.50 | Key northern port logistics hub |
| Frankfurt | €7.95 | €7.009.50 | Realogis confirmed 2024 |
| Cologne | €8.25 | €7.509.00 | prim logistics corridor |
| Düsseldorf | €7.50 | €7.008.25 | Realogis H1 2024 |
| Stuttgart | €8.00 | €7.508.50 | Realogis H1 2024 |
| Leipzig | €5.90 | €5.007.00 | Eastern Germany discount |
**Source:** [Realogis Mietpreiskarte H1 2024](https://www.realogis.de/unternehmen/aktuelles/artikel/realogis-mietpreiskarte-h1-2024-spitzenmieten-fuer-logistikneubauten-in-deutschland-auf-hoechstniveau), [Statista DE industrial rents](https://de.statista.com/statistik/daten/studie/536669/umfrage/mietpreis-fuer-lager-und-logistikflaechen-in-koeln/)
### United Kingdom — Industrial/Commercial Rent (EUR/sqm/month)
UK rents are published in £/sqft/year; converted here. 1 sqft = 0.093 sqm → 1 sqft/year = 10.76 sqm/year.
| City | £/sqft/year | £/sqm/month | €/sqm/month | Notes |
|---|---|---|---|---|
| London (inner) | £2535 | £2231 | €2637 | M25 industrial prime; padel clubs use Zone 35 space at lower end |
| London (model) | £22 | £20 | **€24** | Mid-market Zone 34 warehouse; used in model |
| Manchester | £79 | £6.38.1 | **€8** | Powerleague Ardwick-style industrial |
| Birmingham | £68 | £5.47.2 | **€7** | Midlands Golden Triangle |
| Edinburgh | £46 | £3.65.4 | **€5** | Scotland discount; less dense logistics market |
**Source:** [Lambert Smith Hampton Industrial Land Values 2022](https://content.knightfrank.com/research/2504/documents/en/industrial-land-values-research-2022-9242.pdf), [Statista UK Industrial Construction Cost 2024](https://www.statista.com/statistics/601846/industrial-building-cost-uk-2016/)
### United States — Industrial Lease Rates (EUR/sqm/month)
US rents are published in $/sqft/year; converted. Data from CommercialEdge and CoStar.
| City | $/sqft/year | $/sqm/month | €/sqm/month | Notes |
|---|---|---|---|---|
| Miami | $21.00 | $18.8 | **€18** | CommercialEdge Q2 2025 avg gross lease; South Florida +9.6% YoY |
| New York | $2530 | $22.426.8 | **€23** | NYC/NJ industrial proxy; Brooklyn commercial median high |
| Los Angeles | $1417 | $12.515.3 | **€13** | CommercialEdge; 11.6% YoY growth |
| Dallas | $711 | $6.39.8 | **€8** | Low-cost Texas industrial |
| Austin | $812 | $7.210.8 | **€9** | Slightly above Dallas (tech hub premium) |
| Chicago | $812 | $7.210.8 | **€8** | Midwest moderate |
**Source:** [CommercialEdge National Industrial Report Aug 2024](https://www.commercialedge.com/blog/national-industrial-report-august-2024/), [Built World Advisors Miami Q4 2025](https://builtworldadvisors.com/q4-2025-miami-industrial-market-update-a-submarket-by-submarket-breakdown/)
---
## 6. Operating Costs by Country
### Electricity
**Germany** — Gewerbestrom (commercial electricity, <100,000 kWh/year tier):
| Consumption tier | Rate (ct/kWh) | Year |
|---|---|---|
| Small commercial (10,000 kWh/yr) | 31.1 ct | 2025 |
| Medium commercial (50 MWh/yr) | 30.5 ct | Apr 2024 |
| New contracts, mid-size (100,000 kWh/yr) | 25.0 ct | Mid 2024 |
| Industrial (2070 GWh/yr) | 15.8 ct | 2025 |
A 46 court indoor facility consumes ~100,000200,000 kWh/year → commercial-tier rate **~€0.250.30/kWh**. Monthly electricity cost: **€2,000€4,500**.
**Sources:** [Strom-Report Gewerbestrom](https://strom-report.com/strompreis-gewerbe/), [BDEW Strompreisanalyse](https://www.bdew.de/service/daten-und-grafiken/bdew-strompreisanalyse/), [GlobalPetrolPrices Germany](https://www.globalpetrolprices.com/Germany/electricity_prices/)
**United Kingdom** — Non-domestic electricity:
| Period | Rate (p/kWh) |
|---|---|
| Q4 2024 Ofgem non-domestic average | 26.0p |
| April 2025 range | 25.727.5p |
| Fixed 1-year contracts, June 2025 | 21.723.9p |
Monthly estimate for 46 court indoor UK facility: **£2,100£3,900/month** (8,00015,000 kWh/month at 25p).
**Source:** [Business Energy Deals Feb 2026](https://www.businessenergydeals.co.uk/business-electricity-prices/), [Utility4Business 2025](https://utility4business.com/blogs/business-electricity-costs-2025-uk)
**United States** — Commercial electricity by state (EIA, 2024 avg):
| State | Rate (ct/kWh) | Monthly bill (20,000 kWh) | EUR equiv. |
|---|---|---|---|
| Texas | 9.0¢ | $1,800 | €1,710 |
| Florida | 12.0¢ | $2,400 | €2,280 |
| Illinois | 11.0¢ | $2,200 | €2,090 |
| New York | 17.0¢ | $3,400 | €3,230 |
| California | 22.0¢ | $4,400 | €4,180 |
**Source:** [EIA Electric Power Monthly 2024](https://www.eia.gov/electricity/monthly/epm_table_grapher.php?t=epmt_5_6_a), [ElectricChoice.com by State Feb 2026](https://www.electricchoice.com/electricity-prices-by-state/)
---
### Staff Costs
**Germany** — Key wage benchmarks (2025):
| Role | Basis | Gross monthly | Employer cost (+22% social) |
|---|---|---|---|
| Minimum wage | €12.82/hr × 173 hrs | €2,218 | €2,706 |
| Cleaner (Gebäudereinigung tariff) | €14.25/hr, 20 hrs/week | €1,235 | €1,507 |
| Reception staff | National avg full-time | €2,051 | €2,502 |
| Fitness/sports trainer | Avg full-time | €2,800 | €3,416 |
| **Total: lean 4-court club** | 2 reception + 1 coach + 1 cleaner | €8,109 gross | **~€9,900/month** |
| **Total: full 6-court club** | + manager | €11,609 gross | **~€14,200/month** |
**Sources:** [Mindestlohn 2025 / Fairgueten.de](https://fairgueten.de/wissen/mindestlohn-deutschland-2025/), [Reinigungskraft tariff / Blink.de](https://www.blink.de/blog/mindestlohn-reinigungskraft-2025/), [Stepstone Empfangsbereich 2025](https://www.stepstone.de/gehalt/Mitarbeiter-in-Empfangsbereich.html)
**United Kingdom** — Key wage benchmarks (2025):
| Role | Rate | Monthly cost |
|---|---|---|
| National Living Wage (21+) | £12.21/hr | — |
| Receptionist (sports/leisure) | £1217.50/hr | £2,080£3,033 |
| Padel coach | £2640/hr; avg ~£35/hr | £4,550 full-time |
| Cleaner | £1315/hr | £1,127 (20 hrs/wk) |
| **Total: lean 4-court club wages** | 2 reception + 2 coaches + 1 cleaner | **~£12,740/month** |
| **Total employer cost (+ NI 15% + pension 3%)** | | **~£15,200/month = €18,240** |
**Sources:** [GOV.UK NLW 2025](https://www.gov.uk/national-minimum-wage-rates), [Glassdoor UK Padel Coach](https://www.glassdoor.co.uk/Hourly-Pay/We-are-padel-Sports-Coach-Hourly-Pay-E8567625_D_KO13,25.htm)
**United States** — Key wage benchmarks (2025):
| State | Min wage | Gym receptionist avg | Padel coach avg | Cleaner (BLS median) |
|---|---|---|---|---|
| Federal | $7.25 | — | — | — |
| Florida | $14.00 | $18.22/hr | $19.70/hr | $17.27/hr |
| California | $16.90 | $18.22/hr | $40.71/hr (Hi Padel) | $17.27/hr |
| Texas | $7.25 | $18.22/hr | $19.70/hr | $17.27/hr |
| Illinois | $15.00 | $18.22/hr | $19.70/hr | $17.27/hr |
| New York (NYC) | $17.00 | $18.22/hr | $19.70/hr | $17.27/hr |
**Total monthly payroll benchmark (6-court US club, 6.5 FTE staff):** ~$23,700/month ≈ **€22,515/month**
**Sources:** [ZipRecruiter Padel Coach Salary Dec 2025](https://www.ziprecruiter.com/Salaries/Padel-Coach-Salary), [BLS Janitors & Cleaners OES 2023](https://www.bls.gov/oes/2023/may/oes372011.htm), [BusinessDojo Padel Club Operating Expenses 2026](https://dojobusiness.com/blogs/news/padel-center-costs-maintenance-equipment)
---
## 7. CAPEX Benchmarks by Country
### Court Construction Cost (per double court)
| Market | Range | Typical | Notes |
|---|---|---|---|
| Germany (structure only) | €25,000€35,000 | €30,000 | Outdoor; panoramic glass adds ~30% |
| Germany (complete outdoor incl. foundation) | €50,000€80,000 | €65,000 | Site prep, lighting, fencing included |
| Europe (supply-only entry-level) | €18,000€25,000 | €20,000 | ex-works, no installation |
| Europe (WPT-spec panoramic) | €35,000€45,000 | €40,000 | Premium finish |
| United States | $24,000$65,000 | $45,000 | Higher installation labour |
| United Kingdom | £18,000£35,000 | £25,000 | Comparable to European rates |
**Sources:** [Padel-Test.de court costs](https://www.padel-test.de/en/padel-knowledge/paddle-court-costs/), [Sports Venue Calculator cost guide](https://sportsvenuecalculator.com/knowledge/padel-courts/padel-court-construction-cost/), [TennisTraveller.net build special](https://www.tennistraveller.net/en/blog/1261)
### Hall/Building Construction Cost (per sqm)
| Country | Range (€/sqm) | Notes |
|---|---|---|
| Germany (steel sports hall) | €700€1,100/sqm | BKI 2024; Gymnastikhalle ~€950/sqm base |
| Germany (new purpose-built, full fit-out) | €1,900€2,250/sqm | BKI data for Sporthallen with tribune |
| United Kingdom (new-build sports hall) | €1,200€1,800/sqm | £1,0001,500/sqm; Sport England benchmarks |
| United States (commercial/sports) | €513€713/sqm | $5070/sqft Arrant Construction; lower fit-out spec |
**Sources:** [BKI Kostenrahmen Sporthallen 2024](https://imageserver.stadionwelt.de/Image/6/6/299c845739d0c7173521a9e9bbd4ed7ed6d2bd7b0764c8e464d51caf9bbbd5/Kostenrahmen_Sporthalle_BKI-2024.pdf), [Arrant Construction Sports Facility Texas 2026](https://www.arrantconstruction.com/sports-facility-construction-texas-complete-guide/)
### Permits & Compliance Budget
| Country | Method | Typical amount |
|---|---|---|
| Germany | 0.5% of construction value + plan-check + statutory inspections | €15,000€37,500 for a €1.52.5M build |
| Germany (change-of-use only) | Nutzungsänderung flat fee | €500€5,000 |
| United Kingdom | £635/75sqm band (1,0003,750sqm building); plus noise survey, planning consultant | €12,000€22,000 total |
| USA (Florida/Broward) | 1.85% of job valuation (RS Means) + DCA 1.5% surcharge | €20,000€35,000 |
| USA (NYC) | $100 + $13 per $1,000 of construction cost | €15,000€30,000 |
| USA (CA) | 15% of project value | €20,000€50,000 |
| USA (Texas) | 0.52% of project value | €12,000€25,000 |
**Sources:** [Ofina.de Bauantrag Kosten](https://www.ofina.de/baufinanzierung/bauantrag-kosten/), [GOV.UK planning fees from April 2025](https://www.gov.uk/guidance/fees-for-planning-applications), [Broward County Fee Schedule PDF](https://www.broward.org/Building/Forms/Documents/BCSD%20Fee%20Schedule.pdf), [NYC DOB Permit Fee Structure](https://www.nyc.gov/assets/buildings/pdf/new_permit_fee_structure.pdf), [HomeGuide Building Permit Costs 2026](https://homeguide.com/costs/building-permit-cost)
---
## 8. Recommended Seed Script Values per City
The table below provides recommended values for each input field, ready to plug into `seed_content.py`. All values are in **EUR** (or unitless for ratios). Values marked ⚠️ are estimates where direct data is sparse; verify before production use.
### Germany — 8 Cities
| City | `ratePeak` | `rateOffPeak` | `rentSqm` | `electricity` | `heating` | `staff` | `permitsCompliance` | `utilTarget` |
|---|---|---|---|---|---|---|---|---|
| Berlin | **46** | **33** | **10.50** | **3,000** | **1,200** | **11,000** | **28,000** | **0.35** |
| Munich | **42** | **30** | **10.50** | **3,200** | **1,500** | **12,000** | **30,000** | **0.35** |
| Hamburg | **36** | **26** | **9.00** | **2,800** | **1,200** | **10,500** | **25,000** | **0.33** |
| Frankfurt | **28** | **24** | **7.95** | **2,700** | **1,100** | **10,000** | **25,000** | **0.30** |
| Cologne | **27** | **22** | **8.25** | **2,700** | **1,100** | **10,000** | **24,000** | **0.30** |
| Düsseldorf | **42** ⚠️ | **30** ⚠️ | **7.50** | **2,700** | **1,100** | **10,000** | **24,000** | **0.30** |
| Stuttgart | **38** | **26** | **8.00** | **2,800** | **1,300** | **10,500** | **26,000** | **0.28** |
| Leipzig | **26** ⚠️ | **18** ⚠️ | **5.90** | **2,500** | **1,100** | **9,000** | **20,000** | **0.28** |
### United Kingdom — 4 Cities
| City | `ratePeak` | `rateOffPeak` | `rentSqm` | `electricity` | `heating` | `staff` | `permitsCompliance` | `utilTarget` |
|---|---|---|---|---|---|---|---|---|
| London | **72** | **36** | **24** | **4,200** | **1,200** | **18,000** | **24,000** | **0.40** |
| Manchester | **54** | **36** | **8** | **3,600** | **1,000** | **15,000** | **18,000** | **0.33** |
| Birmingham | **42** | **26** | **7** | **3,500** | **1,000** | **14,500** | **17,000** | **0.30** |
| Edinburgh | **34** | **22** | **5** | **3,200** | **1,100** | **14,000** | **17,000** | **0.30** |
### United States — 6 Cities
| City | `ratePeak` | `rateOffPeak` | `rentSqm` | `electricity` | `heating` | `staff` | `permitsCompliance` | `utilTarget` |
|---|---|---|---|---|---|---|---|---|
| Miami | **57** | **38** | **18** | **2,300** | **200** | **16,000** | **22,000** | **0.35** |
| New York | **105** | **57** | **23** | **3,400** | **400** | **22,000** | **28,000** | **0.38** |
| Los Angeles | **95** | **57** | **13** | **4,200** | **300** | **20,000** | **26,000** | **0.35** |
| Dallas | **28** | **19** | **8** | **1,800** | **500** | **14,500** | **18,000** | **0.30** |
| Austin | **28** | **19** | **9** | **1,800** | **400** | **15,000** | **18,000** | **0.30** |
| Chicago | **40** | **27** | **8** | **2,200** | **700** | **17,000** | **21,000** | **0.30** |
---
## 9. Land Price Reference (context only — not a model input field)
Padel clubs almost always **lease** rather than purchase land. Land prices are included here as context for investors evaluating lease vs. buy, or for purpose-built developments.
| Market | Land price range | Notes |
|---|---|---|
| Munich (commercial) | €500700/sqm | Urban commercial; logistics on city periphery cheaper |
| Berlin | €300500/sqm | Growing gap between inner and outer districts |
| Frankfurt / Hamburg | €300500/sqm | Prime commercial; logistics parks far lower |
| Cologne / Düsseldorf | €200350/sqm | Rhine corridor; good logistics but not gateway-premium |
| Stuttgart | €250400/sqm | Baden-Württemberg premium |
| Leipzig | €80150/sqm | Eastern Germany discount |
| London (industrial) | £1,0501,915/sqm (€1,2602,300) | Constrained supply; M25 inside premium |
| Birmingham / Midlands | £247+/sqm (€296+) | Golden Logistics Triangle; strong demand |
| Manchester / Edinburgh | £100200/sqm (€120240) | Below London but rising |
| Miami-Dade (industrial) | $74/sqft = $797/sqm (€757) | +76% YoY in H1 2025 — highly volatile |
| NYC metro | $1,000+/sqm | Very constrained; Brooklyn industrial extremely expensive |
| Los Angeles | $500800/sqm | SoCal logistics constrained |
| Dallas (commercial) | $1249/sqm | Wide range; urban vs suburban |
| Austin | $50120/sqm (urban core) | Tech-hub premium vs suburban |
| Chicago | $50100/sqm | Midwest moderate |
**Sources:** [Statista UK industrial land by market](https://www.statista.com/statistics/1263353/industrial-land-value-united-kingdom-by-market/), [Miami Realtors South Florida Land Sales H1 2025](https://www.miamirealtors.com/2025/07/10/south-florida-land-sales-surge-leading-indicator-of-future-real-estate-development/), [Texas Real Estate Research Center Q3 2024](https://trerc.tamu.edu/article/texaslandmarket-2268/)
---
## 10. Methodology & Caveats
**Data collection:** Web searches conducted February 2026 using Playtomic booking pages, aggregators (Playskan, TimeOut, Padel Map USA), official planning authority documents, EIA electricity data, BLS wage surveys, Realogis/CommercialEdge real estate reports, and industry publications (DPV, Playtomic Global Padel Report 2025).
**Currency conversions** use approximate February 2026 mid-market rates: £1 = €1.20, $1 = €0.95. Apply a ±5% buffer for live transactions.
**Key limitations:**
1. **German city rates** — the majority of clubs price via Playtomic or proprietary apps without public web pages. Berlin has the most confirmed data; Munich and Leipzig are estimated.
2. **Electricity figures** — facility-level consumption estimates assume a 4-double-court indoor facility running 12 hours/day, 30 days/month. Actual consumption depends on HVAC spec, LED vs halogen, building insulation, and climate.
3. **Staff costs** — Germany and UK employer on-costs (+22% and +18% respectively) are included in the model values. US figures represent gross payroll only; add ~15% for FICA and workers' comp.
4. **US permits** — extremely jurisdiction-dependent. The Broward 1.85% is well-documented; NYC formula is clearly published. For Texas jurisdictions, the 0.52% national range is the only reliable guidance — verify with the local building department.
5. **US land prices (Miami especially)** — Miami-Dade industrial land rose 76% YoY in H1 2025. Any figures in this report will be stale for site-specific underwriting. Get a current broker appraisal.
6. **Utilisation targets** are planning estimates for a new club's first operating year. Mature clubs in established markets (Berlin, London, Miami) target 5060%+ utilisation. The seed values use conservative 2840% to model realistic break-even timelines.
---
*Report compiled from parallel web research, February 2026. For live underwriting, verify rates through Playtomic or direct venue enquiry.*
---
## 11. Demand-Side City Profiles
Data sources: national statistics offices, Wikipedia city infoboxes, Padelinsider.de / Padelfinder.de venue counts (cross-checked Feb 2026), LTA padel venue registry, USPA court directory, FIP World Padel Report 2025.
> **Court density** = registered courts ÷ population (city proper) × 100,000. Court counts are venue-aggregated estimates from Padelinsider / Playtomic / LTA / USPA as of early 2026; rapid growth means these figures may be understated within months.
### Germany — 8 Cities
| City | Population (city proper) | GDP/cap proxy | Courts (approx.) | Courts / 100k pop | Climate zone | Notes |
|---|---|---|---|---|---|---|
| Berlin | 3,590,000 ✅ | €38,000 ⚠️ | ~50 ⚠️ | ~1.4 | Cfb — humid continental, cold winters | Largest DE market; fastest venue growth; Padel FC (9 courts), PBC (6), Mitte (5), Lankwitz (4) among key venues |
| Munich | 1,490,000 ✅ | €65,300 ✅ | ~28 ⚠️ | ~1.9 | Dfb — continental, heavy winter snow | Highest income city in DE; PadelCity Munich, Padel Seasons (8 courts), Casa Padel network |
| Hamburg | 1,940,000 ✅ | €64,000 ✅ | ~35 ⚠️ | ~1.8 | Cfb — oceanic, wet and mild | Hanse Padel (16 courts), Padelon Hamburg-Hummelsbüttel (11 courts) anchor the market |
| Frankfurt | 770,000 ✅ | €83,400 ✅ | ~18 ⚠️ | ~2.3 | Cfb — oceanic | Highest GDP/cap in DE (financial hub); relatively small city footprint inflates density |
| Cologne | 1,090,000 ✅ | ~€40,000 ⚠️ | ~20 ⚠️ | ~1.8 | Cfb — oceanic | 5 locations, ~1822 courts; Padel Walls Cologne is key operator |
| Düsseldorf | 640,000 ✅ | ~€45,000 ⚠️ | ~22 ⚠️ | ~3.4 | Cfb — oceanic | High-income Rhine corridor; We Are Padel franchise present; 4 locations, ~20 courts |
| Stuttgart | 640,000 ✅ | ~€50,000 ⚠️ | ~12 ⚠️ | ~1.9 | Cfb — slightly warmer | Baden-Württemberg industrial hub; Padeldir lists 17 clubs but many share courts; net courts lower |
| Leipzig | 630,000 ✅ | ~€28,000 ⚠️ | ~8 ⚠️ | ~1.3 | Dfb — continental | Lowest income Eastern city; PadelCity Leipzig (7 courts) dominant operator; nascent market |
**Germany total context:** DPV and FIP estimate Germany at 550+ padel locations / ~2,000+ courts nationally by end-2025, representing ~130% growth YoY in 2024. ⚠️ City-level court counts are aggregated from venue directories, not official DPV registrations.
### United Kingdom — 4 Cities
| City | Population (city proper) | GDP/cap proxy | Courts (approx.) | Courts / 100k pop | Climate zone | Notes |
|---|---|---|---|---|---|---|
| London | 9,650,000 ✅ | £69,000 (~€83k) ✅ | ~250 ⚠️ | ~2.6 | Cfb — oceanic, mild | LTA: GB reached 1,000 courts in July 2025 across 325 venues; London estimated ~25% share. Stratford (9), Rocket Padel (4), Padel Social Club (7), The Padel Yard (3) are notable venues |
| Manchester | 560,000 ✅ | £65,591 (~€79k) ✅ | ~60 ⚠️ | ~10.7 | Cfb — oceanic, wet | First city in Britain to develop a local padel plan (LTA + Manchester City Council). High density relative to city-proper population; metro area 2.8M |
| Birmingham | 1,150,000 ✅ | ~£30,000 (~€36k) ⚠️ | ~45 ⚠️ | ~3.9 | Cfb — oceanic | Powerleague multi-court facility key operator; below-average income vs London but growing padel market |
| Edinburgh | 530,000 ✅ | ~£35,000 (~€42k) ⚠️ | ~15 ⚠️ | ~2.8 | Cfb — cool oceanic | LTA Edinburgh page confirms ~2 clubs; Powerleague Scotland venues; cold winters reduce outdoor demand |
**UK total context:** LTA confirmed 1,000 courts across 325 venues in July 2025, up from 763 at end-2024 (+31% in 6 months). UK leads globally on GMV per court (€9,700/month, +74% YoY per Playtomic 2025 report). ⚠️ City-level splits are estimates based on proportional venue count from LTA venue map.
### United States — 6 Cities
| City | Population (city proper) | Median HH income | Courts (approx.) | Courts / 100k pop | Climate zone | Notes |
|---|---|---|---|---|---|---|
| Miami | 460,000 ✅ | ~$62,000 ⚠️ | ~80 ⚠️ | ~17.4 | Aw — tropical savanna | Ultra Padel (26 courts) alone makes Miami highest-density US market; outdoor-dominant, year-round play |
| New York | 8,340,000 ✅ | ~$70,000 ⚠️ | ~45 ⚠️ | ~0.5 | Cfa — humid subtropical | Very low density for city size; indoor-only due to climate/space constraints; Padel Haus Brooklyn anchor |
| Los Angeles | 3,900,000 ✅ | ~$80,000 ⚠️ | ~40 ⚠️ | ~1.0 | BSk/Csb — semi-arid | The Padel Courts Hollywood; year-round outdoor possible; courts concentrated in West Side / Hollywood |
| Dallas | 1,300,000 ✅ | ~$58,000 ⚠️ | ~25 ⚠️ | ~1.9 | Cfa — humid subtropical | Padel39 flagship market; hot summers drive indoor preference JulSep |
| Austin | 980,000 ✅ | ~$72,000 ⚠️ | ~18 ⚠️ | ~1.8 | Cfa — humid subtropical | Tech hub; above-average income; Padel39 Austin; strong growth trajectory |
| Chicago | 2,700,000 ✅ | ~$62,000 ⚠️ | ~20 ⚠️ | ~0.7 | Dfa — humid continental | Cold winters require indoor-only; Union Padel Club (opened Jan 2026); emerging market |
**US total context:** USPA reports ~688 courts in operation across 31 states as of Q2 2025, with ~352 new courts built in 2024. Florida, California, and Texas lead in court count; Northeast/Midwest growing fastest. ⚠️ City-level counts are aggregated from USPA directory, Padel Dynasty (2024), and operator websites.
**Sources:** [Padelinsider.de city pages](https://padelinsider.de/), [LTA 1,000 courts milestone](https://www.ltapadel.org.uk/news/padels-rapid-rise-great-britain-reaches-1000-padel-courts/), [USPA court directory](https://padelusa.org/us-padel-clubs/), [FIP World Padel Report 2025](https://thepadelpaper.com/fip-world-padel-report-2025/), [Worldpopulationreview Germany cities](https://worldpopulationreview.com/cities/germany), [List of German cities by GDP — Wikipedia](https://en.wikipedia.org/wiki/List_of_German_cities_by_GDP), [House of Commons London GDP briefing](https://commonslibrary.parliament.uk/research-briefings/cdp-2025-0153/), [Padel Business Magazine USPA growth](https://newsletter.padelbusinessmagazine.com/p/global-growth-accelerates-padel-courts-worldwide-exceed-70-000)
---
## 12. CAPEX Breakdown by Component
All prices are per double court (10m × 20m playing surface) unless noted. Installation labour is included unless stated as supply-only (ex-works).
### Component Cost Matrix — per Double Court
| Component | Germany (€) | UK (£ → €) | USA ($ → €) | Confidence | Notes |
|---|---|---|---|---|---|
| **Steel frame / structure** | €8,00012,000 | £7,00010,000 (→€8,40012,000) | $8,00014,000 (→€7,60013,300) | ⚠️ Est. | Q235 hot-dip galvanised; includes uprights and horizontal rails. Supply-only ex-works China ~€4,000; European-assembled ~€10,000 |
| **Panoramic glass walls** | €9,50016,000 | £9,00014,000 (→€10,80016,800) | $9,50011,500 (→€9,00010,900) | ✅ High | 1012mm tempered glass; panoramic (360°) costs ~20% more than standard back-glass-only. Prices confirmed from Fortune Padel, MejorSet, Padel Hispania quotes |
| **Artificial turf (surface)** | €4,5008,000 | £4,2007,500 (→€5,0009,000) | $5,0009,500 (→€4,7509,000) | ✅ High | Court footprint = 200 sqm. FIP-approved: 812mm pile, sand-rubber infill. €1222/sqm supply + lay in Europe; €1422/sqm in North America. Annual maintenance ~€23/sqm |
| **LED sports lighting** | €4,0007,000 | £3,5006,500 (→€4,2007,800) | $3,5006,500 (→€3,3006,200) | ✅ High | 8 × 200W fixtures per court = 1.6 kW total. Fixtures: €1,8003,000; poles/brackets/wiring: €1,0002,000; installation labour: €1,2002,000. Full LED vs halogen saves 5075% on electricity |
| **Foundation & site prep** | €5,00010,000 | £5,00012,000 (→€6,00014,400) | $5,00015,000 (→€4,75014,250) | ✅ High | Excavation, drainage, compaction, concrete slab. Varies heavily by ground conditions |
| **Fencing / mesh panels** | €2,0004,000 | £1,8003,500 (→€2,2004,200) | $2,5005,000 (→€2,4004,750) | ✅ High | Hot-dip galvanised wire mesh for non-glass sections |
| **Subtotal: outdoor double court (supply + install)** | **€33,00057,000** | **£30,00053,500 (→€36,00064,200)** | **$33,50061,500 (→€31,80058,400)** | ✅ High | Consistent with §7 benchmarks; outdoor only, no enclosure |
### Indoor-Only Additional Costs (per facility, not per court)
| Component | Germany (€) | UK (£ → €) | USA ($ → €) | Confidence | Notes |
|---|---|---|---|---|---|
| **Steel portal frame hall** (per sqm of GFA) | €350550/sqm | £300480/sqm (→€360576/sqm) | $180300/sqm (→€171285/sqm) | ⚠️ Est. | A 4-court facility needs ~1,2001,500 sqm of gross floor area (including service areas). US lower due to lighter spec and lower labour |
| **Roof + cladding** (single-skin membrane) | €80130/sqm | £70120/sqm (→€84144/sqm) | $50100/sqm (→€4895/sqm) | ⚠️ Est. | Included in portal frame quote by many contractors; separated here for clarity |
| **HVAC / climate system** (4-court hall) | €80,000150,000 | £70,000130,000 (→€84,000156,000) | $60,000120,000 (→€57,000114,000) | ⚠️ Est. | Heating + ventilation mandatory for indoor; A/C adds ~40% in hot climates (US South, Texas). Condensation control critical to protect turf. HVAC typically 70% of ongoing climate cost |
| **Changing rooms & showers** (4-court standard) | €40,00080,000 | £35,00070,000 (→€42,00084,000) | $30,00070,000 (→€28,50066,500) | ⚠️ Est. | Male/female changing rooms, showers, lockers, WC. Upscale finish doubles this budget |
| **Reception / lobby fit-out** | €20,00040,000 | £18,00035,000 (→€21,60042,000) | $15,00035,000 (→€14,25033,250) | ⚠️ Est. | Desk, point-of-sale, waiting area, equipment storage/retail |
### Total Facility CAPEX Ranges (indicative)
| Facility type | Courts | Germany (€) | UK (£ → €) | USA ($ → €) | Confidence |
|---|---|---|---|---|---|
| Outdoor club (courts + lighting + foundation) | 4 | €200,000350,000 | £160,000280,000 (→€192,000336,000) | $180,000320,000 (→€171,000304,000) | ✅ High |
| Indoor club (courts + hall + HVAC + fit-out) | 4 | €700,0001,200,000 | £600,0001,050,000 (→€720,0001,260,000) | $500,000950,000 (→€475,000903,000) | ✅ High |
| Indoor club (courts + hall + HVAC + fit-out) | 6 | €1,000,0001,700,000 | £850,0001,500,000 (→€1,020,0001,800,000) | $750,0001,400,000 (→€713,0001,330,000) | ⚠️ Est. |
**Notes on regional variation:**
- Germany: Higher construction labour and engineering compliance (BauO, TÜV inspections) add 1020% vs equivalent UK spec.
- UK: LTA construction guidance note (2025) quotes £45,000£80,000 per indoor single court all-in.
- USA: Lower labour costs in Texas/Florida; higher in California (+30%) and New York (+40%) due to Davis-Bacon / local union rules.
- Indoor courts are typically 2040% more expensive than outdoor equivalent.
**Sources:** [Sports Venue Calculator CAPEX guide](https://sportsvenuecalculator.com/knowledge/padel-courts/padel-court-construction-cost/), [BusinessDojo Padel Court Build 2025](https://dojobusiness.com/blogs/news/how-much-build-padel-court), [Padel One Courts US breakdown](https://www.padelonecourts.com/post/how-much-does-a-padel-court-cost-in-the-u-s), [WePadel 2025 installation cost](https://www.wepadel.com/blog/2025-padel-court-installation-cost), [Padel.fyi build guide 2024](https://www.padel.fyi/articles/padel-court-cost/), [Sports Venue Calculator LED lighting guide](https://sportsvenuecalculator.com/knowledge/led-sports-lighting/padel-court-lighting/), [Mighty Grass turf cost guide](https://www.mightygrass.com/how-much-does-a-padel-turf-court-cost/), [LTA padel construction guidance 2025](https://www.lta.org.uk/siteassets/padel/lta-padel-court-construction-guidance-note-2025.pdf), [FinancialModelsLab Padel Center CAPEX](https://financialmodelslab.com/blogs/startup-costs/padel-center), [Swegon HVAC padel halls guide](https://www.swegon.com/siteassets/4-guides/application-guides/sports-facilities/good-indoor-climate-in-padel-halls-swegon.pdf)
---
## 13. Insurance Cost Benchmarks
Insurance for padel clubs is still an emerging product category. Brokers generally classify padel as a hybrid of tennis (enclosed court) and squash (high-impact enclosed environment). Data from UK specialist brokers is most mature; German and US figures are partially estimated.
### Coverage Types Required for a Commercial Padel Club
| Coverage type | What it covers | Typical limit |
|---|---|---|
| Public liability (PL) | Injury to players, spectators, visitors on premises | £/€/$210M per occurrence |
| Employer's liability (EL) | Claims by employees injured at work | £10M (UK statutory minimum) |
| Property / contents | Building structure, glass walls, turf, fixtures | Replacement value of facility |
| Equipment / court surfaces | Specific cover for glass panels, turf replacement | Per court or per facility |
| Business interruption | Revenue lost if courts are unusable (flood, fire, damage) | 1224 months revenue |
| Professional indemnity | Claims from coaching malpractice or inadequate instruction | £12M (optional for clubs with in-house coaching) |
### UK Insurance Cost Benchmarks
| Facility size | Public liability | Property/contents | Employer's liab. | Approx. total/year | Confidence |
|---|---|---|---|---|---|
| 2-court outdoor club | £9001,500/year | £1,5003,000/year | £500800/year | **£3,0005,300/year** | ⚠️ Est. |
| 4-court indoor club | £1,8004,000/year | £5,00012,000/year | £8001,500/year | **£7,60017,500/year** | ⚠️ Est. |
| 6-court indoor club | £2,5006,000/year | £8,00018,000/year | £1,2002,500/year | **£11,70026,500/year** | ⚠️ Est. |
**UK notes:**
- Public liability insurance for padel venues: ~£9001,500 per annum per court (Insure24 benchmark, 2025).
- LTA via Howden Group provides packaged tennis/padel club insurance; policy limit up to £60M per year.
- Specialist padel brokers: Lime Street Brokers, Ascott Insurance, Howden Group.
- Employers' liability is compulsory for any club with paid staff (Employers' Liability Act 1969).
- Buildings insurance premium depends on rebuild value; glass wall elements require specialist glass cover.
### Germany Insurance Cost Benchmarks
| Coverage | Mechanism | Approx. annual cost | Confidence |
|---|---|---|---|
| Vereinshaftpflicht (club PL) | Association framework contract or standalone; Haftpflicht covers personal injury + property damage | €8003,000/year | ⚠️ Est. |
| Sachversicherung (property/contents) | Covers artificial turf, glass, steel frame against fire, storm, flood, vandalism | €2,0006,000/year | ⚠️ Est. |
| Betriebsunterbrechung (business interruption) | Optional; required by lenders if facility is financed | €5002,000/year | ⚠️ Est. |
| **46 court indoor facility total** | | **€3,30011,000/year** | ⚠️ Est. |
**Germany notes:**
- German sports clubs typically obtain PL via Landessportbund framework (cheaper; DPV membership may include basic PL cover).
- Commercial padel operators (GmbH / UG) must purchase standalone Gewerbehaftpflicht, not club rates.
- ARAG and Sportversicherung provider via Bernhard Assekuranz are active in this space.
- Premium set by replacement value and location; no padel-specific published tariff found in public sources.
### USA Insurance Cost Benchmarks
| Coverage | Mechanism | Approx. annual cost | Confidence |
|---|---|---|---|
| General liability (per occurrence) | $1M2M per occurrence; $2M4M aggregate | $3,5008,000/year | ⚠️ Est. |
| Commercial property | Building + contents at replacement value | $2,00010,000/year | ⚠️ Est. |
| Workers' compensation | Varies by state; ~$35 per $100 payroll in sports sector | $3,0007,000/year | ⚠️ Est. |
| **46 court indoor facility total** | | **$8,50025,000/year (→€8,10023,750)** | ⚠️ Est. |
**USA notes:**
- Minimum premium for sports complex programs (K&K Insurance): $3,500/year.
- Pickleball/padel-specific bundled program available (PIP Program — Property, Liability, Liquor, Assault in one package).
- State variance is high: California workers' comp and litigation risk adds ~2030% to total insurance budget vs Texas or Florida.
- USPA recommends consulting Dustin Decker at RedNil Brokers for padel-specific coverage.
### Recommended Model Allowances (EUR/year)
| Facility | Germany | UK | USA |
|---|---|---|---|
| 4-court indoor club | **€5,0008,000** | **€9,60018,000** | **€8,50019,000** |
| 6-court indoor club | **€7,00011,000** | **€14,00026,000** | **€12,00024,000** |
> Insurance is not currently a separate model field in `seed_content.py`. It is recommended to account for it within a general `operatingOverhead` line or as a fixed annual addition to the cost model. A conservative seed value for a 4-court indoor club across all markets is **€10,000/year (€835/month)**.
**Sources:** [Insure24 Padel Court Insurance guide](https://www.insure24.co.uk/blog/padel-court-insurance-complete-guide-for-court-operators-in-the-uk/), [Lime Street Brokers Padel](https://limestreetbrokers.com/products/sports-leisure/padel/), [Howden Group / LTA insurance](https://www.howdengroup.com/uk-en/lawn-tennis-association/clubs-and-venues), [K&K Insurance Sports Complex](https://www.kandkinsurance.com/programs/sports-insurance/sports-complex-insurance), [USPA FAQs insurance](https://padelusa.org/faqs/), [ARAG Sportversicherung](https://www.arag.de/vereinsversicherung/sportversicherung/), [Bernhard Assekuranz sports](https://bernhard-assekuranz.com/sportversicherungen/), [Thepadeldirectory.co.uk UK step guide](https://www.thepadeldirectory.co.uk/post/how-to-open-a-padel-facility-in-the-uk---step-by-step-guide-with-costs)
---
## 14. Seasonal Utilisation Patterns
### Overview: Indoor vs. Outdoor Demand by Season
| Season | Outdoor demand | Indoor demand | Key driver |
|---|---|---|---|
| Winter (DecFeb) | Very low (Nordic/UK/DE/US North); High (Miami, LA, Texas) | Very high in cold climates; High everywhere | Daylight, temperature, precipitation |
| Spring (MarMay) | Rising; weather variable in DE/UK | Still high until outdoor season peaks | Transition period; most volatile |
| Summer (JunAug) | Peak for outdoor (DE/UK/Nordic); Reduced (Miami heat, TX sun) | Drops in Nordic/UK markets; Stays high in hot climates (AC) | Heat drives indoor in Sun Belt; warmth drives outdoor in Northern Europe |
| Autumn (SepNov) | Declining; good weather window SepOct | Rising sharply from Oct | Back-to-sport season; corporate memberships renew |
### Nordic / UK / German Markets (Dfb/Cfb Climate Zones)
- Indoor padel facilities in these zones show an **inverted seasonal pattern**: peak utilisation DecemberJanuary, trough JuneJuly.
- The pattern mirrors broader indoor sports demand: school terms drive autumn/spring peaks; summer holidays suppress demand.
- Source: machine-learning occupancy study (DiVA portal, Sweden) found indoor padel facilities peak in DecJan and trough JunJul. ✅
- In the UK, clubs offering membership plus additional services (clinics, social play) report year-round occupancy above 60%, while outdoor-only operators see utilisation fall to 2030% in winter.
- Germany: outdoor courts in Berlin and Hamburg are typically unusable NovemberMarch due to frost and precipitation; operators either close outdoor courts or add temporary bubble/tent covers.
### Sun Belt USA / Miami Climate (Aw/Cfa)
- Miami and South Florida: outdoor courts are **year-round viable**. Peak booking demand SeptemberMay (when northern snowbirds arrive and heat subsides).
- Summer (JunSep): extreme heat and afternoon thunderstorms suppress outdoor demand 15:0019:00; indoor air-conditioned courts see **premium pricing** in summer.
- Texas (Dallas, Austin): brutal summers (JunAug, 38°C+) drive a strong indoor preference. Clubs report outdoor utilisation dropping 3040% in JulyAugust peak heat.
- Chicago/NYC: winter-only indoor markets. Courts open late 20232026 are exclusively indoor; outdoor seasonal pop-up courts viable MayOctober only.
### Seasonal Utilisation Index (Estimated, Indoor Facility)
Index = 100 at annual average; values above/below show monthly relative demand.
| Month | Berlin/Hamburg/London (indoor) | Miami/Dallas (indoor) | Chicago/NYC (indoor) |
|---|---|---|---|
| Jan | 125 | 90 | 120 |
| Feb | 120 | 85 | 115 |
| Mar | 110 | 85 | 105 |
| Apr | 95 | 90 | 95 |
| May | 80 | 95 | 85 |
| Jun | 70 | 110 | 80 |
| Jul | 65 | 120 | 85 |
| Aug | 65 | 125 | 90 |
| Sep | 90 | 110 | 100 |
| Oct | 110 | 100 | 110 |
| Nov | 120 | 90 | 115 |
| Dec | 130 | 85 | 120 |
> ⚠️ Est. — Index values are estimated from seasonal demand descriptions in industry sources (DiVA academic study, Legendsports.com, Superbpadel.com, Padel Magazine France/UK). No publicly available granular Playtomic monthly booking dataset was found.
### Pricing and Revenue Seasonal Impact
- France data (industry report): average indoor reservation €32, outdoor €24 — a 33% indoor premium. ✅
- USA: indoor/outdoor gap is more pronounced — $90 indoor vs $60 outdoor average (~50% premium). ✅
- Outdoor operators in cold climates (Berlin, Hamburg, Edinburgh) should model **OctoberMarch as 6070% of average monthly revenue** and AprilSeptember as **110130%** to reflect seasonal swing.
- Indoor operators in the same markets should use a flatter curve (±20% monthly variance) but plan for a summer marketing push to fill the trough months.
### Climate Zone Summary for the 18 Model Cities
| City | Climate (Köppen) | Outdoor viability | Indoor critical? | Peak season |
|---|---|---|---|---|
| Munich | Dfb | AprOct | Yes | OctMar |
| Berlin | Cfb | AprOct | Yes | OctMar |
| Hamburg | Cfb | AprOct | Yes | OctMar |
| Frankfurt | Cfb | AprOct | Yes | OctMar |
| Cologne | Cfb | AprOct | Yes | OctMar |
| Düsseldorf | Cfb | AprOct | Yes | OctMar |
| Stuttgart | Cfb | AprOct | Yes | OctMar |
| Leipzig | Dfb | AprSep | Yes | OctMar |
| London | Cfb | AprOct | Yes | OctMar |
| Manchester | Cfb | MaySep | Yes | OctMar |
| Edinburgh | Cfb | MaySep | Yes | OctApr |
| Birmingham | Cfb | AprOct | Yes | OctMar |
| Miami | Aw | Year-round | Beneficial (AC) | SepMay |
| New York | Cfa | MayOct | Yes | OctApr |
| Los Angeles | BSk/Csb | Year-round | Beneficial (AC) | Year-round even |
| Dallas | Cfa | OctMay | Yes (JunSep) | OctMay |
| Austin | Cfa | OctMay | Yes (JunSep) | OctMay |
| Chicago | Dfa | MaySep | Yes | OctApr |
**Sources:** [DiVA machine-learning padel occupancy study (Sweden)](https://www.diva-portal.org/smash/get/diva2:1712822/FULLTEXT01.pdf), [Legendsports Indoor vs Outdoor ROI](https://www.legendsports.com/industry-news/indoor-vs-outdoor-padel-courts-maximizing-roi-and-year-round-profitability/), [Superbpadel indoor design guide](https://superbpadel.com/how-to-design-a-profitable-indoor-padel-court-for-year-round-operation/), [Padel Magazine FR indoor/outdoor balance](https://padel-magazine.co.uk/terrains-indoor-vs-outdoor-un-equilibre-delicat-entre-meteo-prix-et-rentabilite/), [Reform Sports indoor vs outdoor guide](https://en.reformsports.com/should-a-padel-court-be-indoor-or-outdoor/), [PadelMagic UK pros/cons](https://www.padelmagic.co.uk/padel-news/playing-padel-indoors-vs-outdoors-pros-and-cons-for-facility-managers)
---
## 15. Playtomic & Platform Market Data
### Platform Overview
Playtomic is the dominant global padel booking platform, operating in 60+ countries and serving as the primary data source for industry benchmarks. Its annual Global Padel Report (2025 edition produced with PwC) is the most cited primary source in the industry.
### GMV per Court by Country (Playtomic Global Padel Report 2025)
GMV = Gross Monetary Value = monthly court rental revenue booked through the platform.
| Country | GMV per court/month (2024) | YoY growth vs 2023 | Confidence |
|---|---|---|---|
| United Kingdom | **€9,700/month** | **+74%** | ✅ High — Playtomic 2025 report |
| Germany | **€4,000/month** | **+48%** | ✅ High — Playtomic 2025 report |
| Spain | ~€3,500/month (est.) | +16% | ✅ Mature market; lowest growth, highest base volume |
| Italy | ~€3,200/month (est.) | +11% | ✅ Maturing; court glut in some cities |
| Benelux | ~€3,100/month (est.) | +12% | ✅ |
| Indonesia | ❓ | +173% | ✅ High growth; small base |
| USA | ❓ Unavailable | ❓ | ❓ US data not published in 2025 report summary |
**Implied model check (Germany):** At €4,000 GMV/month and ~30% utilisation (450 bookable slot-hours/month), the implied blended rate is ~€29/hr — consistent with the §2 data showing €2542/hr across DE cities.
**Implied model check (UK):** At €9,700 GMV/month and ~35% utilisation, the implied blended rate is ~€62/hr — consistent with London peak rates of €5472/hr pulling the national average up significantly.
### Court Growth & Global Market Scale
| Metric | Value | Source | Confidence |
|---|---|---|---|
| Global courts (end 2024, Playtomic dataset) | 50,000+ | Playtomic Global Padel Report 2025 | ✅ |
| Global courts (end 2025, FIP World Padel Report) | 77,300 | FIP World Padel Report 2025 (Dec 2025) | ✅ |
| Global clubs | 24,600+ | FIP 2025 | ✅ |
| New courts built in 2025 | ~14,355 | FIP 2025 | ✅ |
| Global padel players | 35+ million | FIP 2025 | ✅ |
| New court opening rate | Every 2.5 hours worldwide | Playtomic / FIP 2025 | ✅ |
| Player return rate (after first session) | 92% | Playtomic 2025 | ✅ |
| UK courts (end 2024) | 763 | LTA | ✅ |
| UK courts (July 2025) | 1,000+ | LTA | ✅ |
| Germany courts (locations, 2025) | 550+ | FIP/DPV | ✅ |
| US courts (Q2 2025) | 688 | USPA | ✅ |
### Booking Pattern Data
| Metric | Value | Confidence | Source |
|---|---|---|---|
| Peak demand window | 17:0023:00 weekdays, all weekend | ✅ | BusinessDojo / industry consensus |
| Peak hour occupancy (mature markets) | 8090% | ✅ | Industry operators, BusinessDojo |
| Off-peak occupancy (weekday daytime) | 2040% | ✅ | Industry operators |
| Prime-time booking lead time | 710 days in advance | ✅ | BusinessDojo |
| Off-peak booking lead time | 048 hours | ✅ | BusinessDojo |
| Last-minute bookings share (off-peak) | Frequent; rare at peak | ✅ | BusinessDojo |
| Cancellation / no-show rate | 510% per day | ✅ | Operator benchmarks |
| Weekend price premium vs weekday | +2050% | ✅ | Industry consensus |
| Digital vs non-digital club revenue gap | 35× outperformance | ⚠️ Est. | Playtomic 2025 (marketing claim) |
### Revenue KPI Benchmarks for Financial Modelling
| KPI | Benchmark | Notes |
|---|---|---|
| Target annual utilisation (new club, yr 1) | 2840% | §8 seed values; conservative for break-even modelling |
| Target annual utilisation (mature club, yr 3+) | 5065% | Industry operator benchmark |
| Target peak-hour utilisation | 7085% (max 90%) | Above 90% creates access scarcity and churn |
| GMV per court (UK leader) | €9,700/month | Playtomic 2025; highest globally |
| GMV per court (Germany emerging) | €4,000/month | Playtomic 2025; +48% YoY; growing fast |
| Padel clubs using Playtomic vs not | 35× revenue outperformance claimed | Playtomic marketing; treat as directional only |
| Booking type mix (mature market, e.g. Spain) | 8090% open matches; 1020% classes/events | Revenue per hour higher for open matches |
| Booking type mix (emerging market, e.g. US, DE) | Classes/social events dominant in early phase | Coaching and intro sessions critical to customer acquisition |
### Market Size Projections
| Market | 2025 value | Projected (2035) | CAGR | Confidence |
|---|---|---|---|---|
| Global padel sports market | $248M | $661M | 10.3% | ⚠️ Est. (market research firm; methodology opaque) |
| Global court construction market | Growing at ~39% annually | — | — | ✅ FIP 2025 |
| Padel retail equipment market | Growing at ~47% annually | — | — | ✅ FIP 2025 |
**Sources:** [Playtomic Global Padel Report 2025](https://playtomic.com/global-padel-report), [The Bandeja Playtomic 2025 summary](https://thebandeja.com/playtomic-global-padel-report-2025/), [The Padel Paper Playtomic 2025](https://thepadelpaper.com/playtomic-global-padel-report-2025/), [Padel Business Magazine: 50,000 courts](https://newsletter.padelbusinessmagazine.com/p/playtomic-report-global-padel-courts-top-50-000-up-17-in-2024), [FIP World Padel Report 2025](https://thepadelpaper.com/fip-world-padel-report-2025/), [FIP 77,000 courts announcement](https://newsletter.padelbusinessmagazine.com/p/fip-report-global-padel-courts-exceed-77-000-up-15-2-in-2025), [LTA 1,000 courts milestone](https://www.ltapadel.org.uk/news/padels-rapid-rise-great-britain-reaches-1000-padel-courts/), [BusinessDojo padel KPIs](https://financialmodelslab.com/blogs/kpi-metrics/padel-center), [BusinessDojo daily bookings](https://dojobusiness.com/blogs/news/padel-center-daily-bookings-profit), [Squash Facilities: Playtomic 2025 key findings](https://www.squashfacilities.com/post/padel-goes-global-key-findings-from-the-2025-playtomic-report), [Padeltelegraph statistics 2025](https://padeltelegraph.com/padel-statistics/), [USPA clubs directory](https://padelusa.org/us-padel-clubs/)
---
*Sections 1115 appended February 2026 via parallel web research. Data confidence markers: ✅ High (confirmed source), ⚠️ Est. (extrapolated or aggregated from proxies), ❓ Unavailable (not found in public sources).*