Compare commits

..

110 Commits

Author SHA1 Message Date
Deeman
90754b8d9f chore: move ci.py to ~/.claude/scripts (uv inline script, no project dep)
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 2s
Script now lives globally as a uv inline-dependency script.
Removes per-project scripts/ci.py and the msgspec dev dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:51:36 +01:00
Deeman
277c92e507 chore: add scripts/ci.py for Gitea CI pipeline status
Copies ci.py from beanflows (same script, shared across projects).
Adds msgspec dev dependency required by the script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:38:42 +01:00
Deeman
77ec3a289f feat(transform): H3 catchment index, res 5 k_ring(1) ~24km radius
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Merges worktree-h3-catchment-index. dim_locations now computes h3_cell_res5
(res 5, ~8.5km edge). location_profiles and dim_locations updated;
old location_opportunity_profile.sql already removed on master.

Conflict: location_opportunity_profile.sql deleted on master, kept deletion
and applied h3_cell_res4→res5 rename to location_profiles instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:45:45 +01:00
Deeman
f81d5f19da fix(transform): tighten H3 catchment to res 5 (~24km radius)
Res 4 + k_ring(1) gave ~50-60km effective radius, causing Oldenburg to
absorb Bremen (40km away) and destroying score differentiation.

Res 5 + k_ring(1) gives ~24km — captures adjacent Gemeinden (Delmenhorst
at 15km) without bleeding into unrelated cities at 40km+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:34:56 +01:00
Deeman
4d29ecf1d6 merge: unified location_profiles serving model + both scores on map tooltips
All checks were successful
CI / test (push) Successful in 55s
CI / tag (push) Successful in 3s
# Conflicts:
#	CHANGELOG.md
#	transform/sqlmesh_padelnomics/models/serving/location_opportunity_profile.sql
2026-03-06 14:03:55 +01:00
Deeman
a3b4e1fab6 docs: update CHANGELOG, CLAUDE.md, and comments for location_profiles
Update transform CLAUDE.md source integration map and conformed
dimensions table. Update CHANGELOG with unified model + tooltip
changes. Fix stale comments in dim_cities.sql and serving README.

Subtask 5/5: documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:45:08 +01:00
Deeman
8b794d24a6 feat(maps): show both scores in all map tooltips
Country map: avg Market Score + avg Opportunity Score.
City map: Market Score + Opportunity Score per city.
Opportunity map: Opportunity Score + Market Score per location.

Subtask 4/5: tooltip updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:42:36 +01:00
Deeman
688f2dd1ee refactor(web): update all references to location_profiles
Update api.py (3 endpoints), public/routes.py, analytics.py docstring,
pipeline_routes.py DAG, pipeline_query.html placeholder, and
test_pipeline.py fixtures to use the new unified model.

Subtask 3/5: web app references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:41:42 +01:00
Deeman
81b556b205 refactor(serving): replace old models with location_profiles
Delete city_market_profile.sql and location_opportunity_profile.sql.
Update downstream models (planner_defaults, pseo_city_costs_de,
pseo_city_pricing) to read from location_profiles instead.

Subtask 2/5: delete old models + update downstream SQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:39:52 +01:00
Deeman
cda94c9ee4 feat(serving): add unified location_profiles model
Combines city_market_profile and location_opportunity_profile into a
single serving model at (country_code, geoname_id) grain. Both Market
Score and Opportunity Score computed per location. City data enriched
via LEFT JOIN dim_cities on geoname_id.

Subtask 1/5: create new model (old models not yet removed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:36:36 +01:00
Deeman
4fbd91b59b merge: automate h3 community extension install via sqlmesh config 2026-03-06 10:27:03 +01:00
Deeman
159d1b5b9a fix(transform): use community repository for h3 extension install
SQLMesh's extensions config supports dict form with 'repository' key,
which runs INSTALL h3 FROM community + LOAD h3 automatically at connect
time. No manual one-time install needed per machine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:26:56 +01:00
Deeman
fcd0c9b007 docs: update CHANGELOG with H3 catchment score v3 2026-03-06 10:20:15 +01:00
Deeman
f841ae105a merge: use full trademarked score names in map tooltips 2026-03-06 10:19:56 +01:00
Deeman
dec4f07fbb merge: H3 catchment index for Marktpotenzial-Score v3 2026-03-06 10:19:51 +01:00
Deeman
4e4ff61699 feat(transform): H3 catchment index for Marktpotenzial-Score v3
Add H3 res-4 regional catchment metrics (~15-18km radius, cell + 6
neighbours) to both the addressable market (25pts) and supply gap
(30pts) components of location_opportunity_profile.

Changes:
- config.yaml: add h3 to DuckDB extensions (requires one-time
  INSTALL h3 FROM community on each machine)
- dim_locations: add h3_cell_res4 column via h3_latlng_to_cell()
- location_opportunity_profile: add hex_stats + catchment CTEs;
  update score formula to use catchment_population and
  catchment_padel_courts; expose catchment_population,
  catchment_padel_courts, catchment_venues_per_100k as output cols

Motivation: local population underestimates functional market for
mid-size cities (e.g. Oldenburg ~170K misses surrounding Gemeinden).
H3 k_ring(1) captures the realistic driving-distance catchment
(~462km²) consistently across both score components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:19:43 +01:00
Deeman
f907f2cd60 fix(maps): use full trademarked score names in all map tooltips
"Score X/100" → "Padelnomics Market Score: X/100" on country map (markets
hub), city map (country overview). Opportunity map uses "Padelnomics
Opportunity Score: X/100". Consistent branding across all three map views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:19:08 +01:00
Deeman
3ad2885c84 merge: fix map bubble styling + improve hover UX 2026-03-06 10:11:26 +01:00
Deeman
e2f54552b0 fix(maps): restore score colors for non-article cities, improve hover UX
Non-article cities were fully gray (#9CA3AF), stripping informational value.
Now all cities show score-based colors (green/amber/red). Non-article cities
are differentiated via lower opacity, dashed border, desaturation, and
default cursor (no click handler). Tooltips show scores for all cities —
article cities get "Click to explore →", non-article cities get "Coming soon".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:09:58 +01:00
Deeman
07ca1ce15b merge: custom 404/500 error pages + smarter map city clicks 2026-03-06 10:01:50 +01:00
Deeman
be9b10c13f docs: update CHANGELOG with error pages and map improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:59:29 +01:00
Deeman
82d6333517 feat: differentiate cities with/without articles on country map
Cities without published articles appear in muted gray and are not
clickable. The cities.json API endpoint now queries SQLite for
published articles and adds a has_article boolean to each city row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:57:01 +01:00
Deeman
ed48936dad feat: add styled 404/500 error pages with i18n support
Custom error templates extending base.html with centered layout.
404 is context-aware: detects /markets/{country}/{city} paths and
shows city-specific message with link back to country overview.
Both pages support EN/DE translations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:55:13 +01:00
Deeman
e3bda5b816 merge: fix admin template preview UX issues (maps, article_stats route, dev debug mode) 2026-03-06 09:35:45 +01:00
Deeman
831233cb29 fix(admin): add missing article_stats route, 500 handler, dev debug mode
- Add /admin/articles/stats HTMX partial endpoint that was referenced
  by article_stats.html but never created (caused 500 during generation)
- Add @app.errorhandler(500) to log exceptions with traceback
- Switch dev_run.sh from Granian to Quart debug mode for browser
  tracebacks and auto-reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:34:22 +01:00
Deeman
c5327c4012 fix(maps): move VENUE_ICON creation after Leaflet loads
L.divIcon() was called at IIFE top level before the dynamic Leaflet
script loaded, throwing ReferenceError and preventing all maps from
rendering. Move icon creation into script.onload callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:01:54 +01:00
Deeman
4426ab2cb6 fix(admin): render Leaflet maps in template preview 2026-03-05 22:58:34 +01:00
Deeman
93c9408f6b fix(admin): render Leaflet maps in template preview
The .card wrapper has overflow:hidden which clips Leaflet's
absolutely-positioned tile layers. Override to overflow:visible
on the rendered-article card. Add .catch() to map fetch calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:58:27 +01:00
Deeman
84128a3a64 merge: fix map scripts in template preview 2026-03-05 22:33:16 +01:00
Deeman
e9b4faa05c fix(admin): move map scripts inline in template preview
Put Leaflet init scripts inside admin_content block instead of relying
on the scripts block inheritance chain through base_admin → base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:33:16 +01:00
Deeman
a834bb481d merge: load Leaflet maps in admin template preview 2026-03-05 22:29:13 +01:00
Deeman
9515ec8ae9 fix(admin): load Leaflet maps in template preview page
The /admin/templates/<slug>/preview/<key> page renders article HTML
directly but never loaded Leaflet CSS/JS, so country-map and city-map
divs appeared empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:29:00 +01:00
Deeman
fb99d6e0db merge: fix sqlmesh worker command + article preview maps 2026-03-05 22:14:37 +01:00
Deeman
4ee80603ef fix(articles): load Leaflet maps in article editor preview
The admin article preview iframe was missing Leaflet CSS/JS and had
scripts blocked by the sandbox policy, so map shortcodes rendered as
empty divs.

- Extract inline map script to static/js/article-maps.js (shared
  between article_detail.html and admin preview)
- Replace f-string preview doc with a proper Jinja template that
  includes Leaflet assets
- Add allow-scripts to iframe sandbox on both initial load and HTMX
  preview updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:03:50 +01:00
Deeman
2e42245ad5 fix(worker): use sqlmesh run prod instead of plan prod --auto-apply
`plan --auto-apply` only detects SQL model changes and won't re-run
for new data. `run prod` evaluates missing cron intervals and picks
up newly extracted data — matching the fix already applied to the
supervisor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 21:49:51 +01:00
Deeman
2f47d1e589 fix(pipeline): make availability chain incremental + fix supervisor
Convert the availability chain (stg_playtomic_availability →
fct_availability_slot → fct_daily_availability) from FULL to
INCREMENTAL_BY_TIME_RANGE so sqlmesh run processes only new daily
intervals instead of re-reading all files.

Supervisor changes:
- run_transform(): plan prod --auto-apply → run prod (evaluates
  missing cron intervals, picks up new data)
- git_pull_and_sync(): add plan prod --auto-apply before re-exec
  so model code changes are applied on deploy
- supervisor.sh: same plan → run change

Staging model uses a date-scoped glob (@start_ds) to read only
the current interval's files. snapshot_date cast to DATE (was
VARCHAR) as required by time_column.

Clean up redundant TRY_CAST(snapshot_date AS DATE) in
venue_pricing_benchmarks since it's already DATE from foundation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 21:34:02 +01:00
Deeman
ead12c4552 fix(planner): prevent chart containers from overflowing on small screens
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 2s
2026-03-05 18:27:33 +01:00
Deeman
c54eb50004 fix(planner): prevent chart containers from overflowing on small screens
Grid children default to min-width:auto, letting the Chart.js canvas
push the container wider than its grid track. Adding min-width:0 and
overflow:hidden constrains charts to their column width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:27:27 +01:00
Deeman
5d7fcec17a chore: change GISCO extraction schedule from monthly to yearly
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
2026-03-05 17:50:19 +01:00
Deeman
f7faf7ab57 chore: change GISCO extraction schedule from monthly to yearly
NUTS2 boundaries rarely change; yearly (Jan 1) is sufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:50:14 +01:00
Deeman
add5f8ddfa fix(extract): correct lc_lci_lev lcstruct filter value
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
2026-03-05 17:39:37 +01:00
Deeman
15ca316682 fix(extract): correct lc_lci_lev lcstruct filter value
D1_D2_A_HW doesn't exist in the API; use D1_D4_MD5 (total labour cost
= compensation + taxes - subsidies).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:32:49 +01:00
Deeman
103ef73cf5 fix(pipeline): eurostat filter bugs + supervisor uses sqlmesh plan
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
2026-03-05 17:19:21 +01:00
Deeman
aa27f14f3c fix(pipeline): eurostat filter bugs + supervisor uses sqlmesh plan
- nrg_pc_203: add missing unit=KWH filter (API returns 2 units)
- lc_lci_lev: fix currency→unit filter dimension name
- supervisor: use `sqlmesh plan prod --auto-apply` instead of
  `sqlmesh run` so new/changed models are detected automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:19:12 +01:00
Deeman
8205744444 chore: remove accidentally committed .claire/ worktree directory
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:10:48 +01:00
Deeman
1cbefe349c add env var 2026-03-05 17:08:52 +01:00
Deeman
003f19e071 fix(pipeline): handle DuckDB catalog naming in diagnostic script 2026-03-05 17:07:52 +01:00
Deeman
c3f15535b8 fix(pipeline): handle DuckDB catalog naming in diagnostic script
The lakehouse.duckdb file uses catalog "lakehouse" not "local", causing
SQLMesh logical views to break. Script now auto-detects the catalog via
USE and falls back to physical tables when views fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:06:44 +01:00
Deeman
fcb8ec4227 merge: pipeline diagnostic script + extraction card UX improvements
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
2026-03-05 15:40:16 +01:00
Deeman
6b7fa45bce feat(admin): add pipeline diagnostic script + extraction card UX improvements
- Add scripts/check_pipeline.py: read-only diagnostic for pricing pipeline
  row counts, date range analysis, HAVING filter impact, join coverage
- Add description field to all 12 workflows in workflows.toml
- Parse and display descriptions on extraction status cards
- Show spinner + "Running" state with blue-tinted card border
- Display start time with "running..." text for active extractions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:40:12 +01:00
Deeman
0d8687859d fix(docker): copy workflows.toml into container for admin pipeline view
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
The admin Extraction Status page reads infra/supervisor/workflows.toml
but the Dockerfile only copied web/ into the image. Adding the COPY
so the file exists at /app/infra/supervisor/workflows.toml in the
container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:16:07 +01:00
Deeman
b064e18aa1 fix(admin): resolve workflows.toml path via CWD instead of __file__
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
In prod the package is installed in a venv, so __file__.parents[4] doesn't
reach the repo root. Use CWD (repo root in both dev and prod via systemd
WorkingDirectory) with REPO_ROOT env var override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:39:30 +01:00
Deeman
dc68976148 docs(marketing): add GTM, social posts, Reddit plan, and SEO calendar
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:43:11 +01:00
Deeman
60fa2bc720 test(billing): add Stripe E2E test scripts for sandbox validation
- test_stripe_sandbox.py: API-only validation of all 17 products (67 tests)
- stripe_e2e_setup.py: webhook endpoint registration via ngrok
- stripe_e2e_test.py: live webhook tests with real DB verification (67 tests)
- stripe_e2e_checkout_test.py: checkout webhook tests for credit packs,
  sticky boosts, and business plan PDF purchases (40 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:50:26 +01:00
Deeman
66c2dfce66 fix(billing): fetch line items for checkout.session.completed webhooks
_extract_line_items() was returning [] for all checkout sessions, which
meant _handle_transaction_completed never processed credit packs, sticky
boosts, or business plan PDF purchases. Now fetches line items from the
Stripe API using the session ID, with a fallback to embedded line_items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:49:41 +01:00
Deeman
6e3c5554aa fix(admin): enable bulk actions in grouped articles view
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
- dev_run.sh: also remove app.db-shm and app.db-wal on reset to fix
  SQLite disk I/O error from stale WAL/SHM files
- articles bulk: add checkboxes to grouped rows (data-ids holds all
  variant IDs); checking a group selects EN+DE together
- restore select-all checkbox in grouped <th>
- add toggleArticleGroupSelect() JS function
- fix htmx:afterSwap to re-check group checkboxes correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 09:48:54 +01:00
Deeman
ad02140594 fix(quote): add missing required asterisk and error hint to step 4
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Step 4 (Project Phase) required location_status server-side but had no
visual "*" indicator and no error message when submitting without a
selection. All other steps already had both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:21:57 +01:00
Deeman
5bcd87d7e5 fix(ci): replace non-existent quote.wizard endpoint with leads.quote_request
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
The CRO homepage overhaul (f4f8a45) introduced url_for('quote.wizard')
in landing.html, but that endpoint never existed — the actual route is
leads.quote_request. This broke CI runs #99–#109.

Also adds landing_vs_col_us to i18n allowlist (brand name, same in both
languages).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:12:45 +01:00
Deeman
77772b7ea4 feat(maps): beanflows-style divIcon bubbles + feature flag gate
Replace L.circleMarker with L.divIcon + .pn-marker CSS class (white
border, box-shadow, hover scale) matching the beanflows growing
conditions map pattern. Dark .map-tooltip CSS override (no arrow,
dark navy background). Small venue dots use .pn-venue class.

Add _require_maps_flag() to all 4 API endpoints (default=True so
dev works without seeding the flag row). Gate /opportunity-map route
the same way.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:51:00 +01:00
Deeman
59f1f0d699 merge(worktree): interactive maps for market pages
Self-hosted Leaflet 1.9.4 maps across 4 placements: markets hub
country bubbles, country overview city bubbles, city venue dots, and
a standalone opportunity map. New /api blueprint with 4 JSON endpoints.
New city_venue_locations SQLMesh serving model. No CDN — GDPR-safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
2026-03-04 15:36:41 +01:00
Deeman
0a89ba2213 docs: update CHANGELOG with interactive maps feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:33:35 +01:00
Deeman
6e936dbb95 feat(maps): Phase 5 — standalone opportunity map page
New route GET /<lang>/opportunity-map renders a full-width Leaflet map
with a country selector. On country change, fetches
/api/opportunity/{slug}.json and renders opportunity circles
(color-coded by score, sized by population) plus existing-venue gray
reference dots from /api/markets/{country}/cities.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:32:56 +01:00
Deeman
edf678ac4e feat(maps): Phase 4 — city venue dot map
New serving model: city_venue_locations joins dim_venues + dim_cities
to expose lat/lon/court_count per venue for the city dot map endpoint.

pseo_city_costs_de.sql: add c.lat, c.lon so city-cost articles have
city coordinates for the #city-map data attributes.

city-cost-de.md.jinja: add #city-map div (both DE and EN sections)
after the stats strip. Leaflet init handled by article_detail.html.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:07:06 +01:00
Deeman
0eef455543 feat(maps): Phase 3 — country overview city bubble map + article_detail Leaflet loader
Add #country-map div to country-overview.md.jinja (both DE/EN).
article_detail.html: always include Leaflet CSS, conditionally load
Leaflet JS only when #country-map or #city-map divs are present.
Initializes country city-bubble map and city venue-dot map from
/api/markets/{slug}/cities.json and /api/markets/{country}/{city}/venues.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:15:41 +01:00
Deeman
8e53fda283 feat(maps): Phase 2 — markets hub country bubble map
Add Leaflet map to /markets with country-level bubbles sized by
total_venues and colored by avg_market_score. Click navigates to
country overview page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:04:40 +01:00
Deeman
db0d7cfee9 feat(maps): Phase 1 — Leaflet vendor files, API blueprint, app registration
Self-host Leaflet 1.9.4 JS/CSS/images in static/vendor/leaflet/.
Create api.py blueprint with 4 JSON endpoints for map data.
Register api_bp at /api in app.py (before content catch-all).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:46:13 +01:00
Deeman
61c197d233 merge(worktree): individualise article costs with per-country Eurostat data + tiered proxy tenant work
# Conflicts:
#	CHANGELOG.md
#	transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql
#	transform/sqlmesh_padelnomics/models/foundation/dim_locations.sql
2026-03-04 12:44:56 +01:00
Deeman
2e68cfbe4f feat(transform): individualise article costs with per-country Eurostat data
Add real per-country cost data to ~30 calculator fields so pSEO articles
show country-specific CAPEX/OPEX instead of hardcoded DE defaults.

Extractor:
- eurostat.py: add 8 new datasets (nrg_pc_205, nrg_pc_203, lc_lci_lev,
  5×prc_ppp_ind variants); add optional `dataset_code` field so multiple
  dict entries can share one Eurostat API endpoint

Staging (4 new models):
- stg_electricity_prices — EUR/kWh by country, semi-annual
- stg_gas_prices         — EUR/GJ by country, semi-annual
- stg_labour_costs       — EUR/hour by country, annual (future staffed scenario)
- stg_price_levels       — PLI indices (EU27=100) for 5 categories, annual

Foundation:
- dim_countries (new) — conformed country dimension; eliminates ~50-line CASE
  blocks duplicated in dim_cities/dim_locations; computes ~29 calculator cost
  override columns from PLI ratios and energy price ratios vs DE baseline;
  NULL for DE so calculator falls through to DEFAULTS unchanged
- dim_cities — replace country_name/slug CASE blocks + country_income CTE
  with JOIN dim_countries
- dim_locations — same refactor as dim_cities

Serving:
- pseo_city_costs_de — JOIN dim_countries; add 29 camelCase override columns
  auto-applied by calculator (electricity, heating, rentSqm, hallCostSqm, …)
- planner_defaults — JOIN dim_countries; same 29 cost columns flow through
  to /api/market-data endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 10:09:48 +01:00
Deeman
7af6f32a2b merge: bulk actions for articles and leads
Some checks failed
CI / test (push) Failing after 33s
CI / tag (push) Has been skipped
2026-03-04 09:55:19 +01:00
Deeman
53fdbd9fd5 docs: update CHANGELOG with bulk actions feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:55:04 +01:00
Deeman
81487d6f01 feat(admin): bulk actions for articles and leads
Add bulk selection checkboxes and action bars to the articles and leads
admin pages, replicating the existing supplier bulk pattern.

Articles: publish, unpublish, toggle noindex, rebuild, delete (with
confirmation dialog). Leads: set status, set heat. Both re-render the
results partial after action via HTMX, preserving current filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:40:26 +01:00
Deeman
477f635bc5 test(billing): Stripe E2E webhook lifecycle tests
Some checks failed
CI / test (push) Failing after 29s
CI / tag (push) Has been skipped
2026-03-03 18:17:10 +01:00
Deeman
4dbded74ca test(billing): add Stripe E2E webhook lifecycle tests
16 tests covering the full Stripe webhook flow through /billing/webhook/stripe:
- Subscription creation (customer.subscription.created → DB row)
- Period end extraction from items (Stripe API 2026-02+ compatibility)
- Billing customer creation
- Status updates (active, past_due, trialing)
- Cancellation (customer.subscription.deleted → cancelled)
- Payment failure (invoice.payment_failed → past_due)
- One-time payments (checkout.session.completed mode=payment)
- Full lifecycle: create → update → recover → cancel
- Edge cases: missing metadata, empty items, invalid JSON, bad signatures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:17:05 +01:00
Deeman
230406f34f fix(billing): period_end from Stripe items + test 2026-03-03 18:06:01 +01:00
Deeman
7da6a4737d fix(billing): extract current_period_end from Stripe subscription items
Stripe API 2026-02+ moved current_period_end from subscription to
subscription items. Add _get_period_end() helper that falls back to
items[0].current_period_end when the subscription-level field is None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:05:55 +01:00
Deeman
710e21a186 fix(billing): handle customer.subscription.created + test isolation 2026-03-03 17:58:15 +01:00
Deeman
72c4de91b0 fix(billing): handle customer.subscription.created webhook + test isolation
- Add customer.subscription.created → subscription.activated mapping in
  stripe.parse_webhook so direct API subscription creation also creates DB rows
- Add customer.subscription.created to setup_stripe.py enabled_events
- Pin PAYMENT_PROVIDER=paddle and STRIPE_WEBHOOK_SECRET="" in test conftest
  so billing tests don't hit real Stripe API when env has Stripe keys
- Add 8 unit tests for stripe.parse_webhook covering all event types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:29:13 +01:00
Deeman
046be665db merge: fix remaining request_options in stripe.py 2026-03-03 16:46:48 +01:00
Deeman
7c5fa86fb8 fix(billing): remove remaining request_options from Price.retrieve calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:46:25 +01:00
Deeman
0a9f980813 merge: fix Stripe SDK request_options + webhook endpoint graceful failure 2026-03-03 16:36:58 +01:00
Deeman
2682e810fa fix(billing): remove invalid request_options from Stripe SDK calls
Stripe Python SDK doesn't accept request_options as a kwarg to create/retrieve/modify.
Timeouts are handled by the global max_network_retries setting.
Also gracefully handle webhook endpoint creation failure for localhost URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:36:47 +01:00
Deeman
10af6a284c fix(content): slug transliteration, article links, country overview ranking
Some checks failed
CI / test (push) Failing after 30s
CI / tag (push) Has been skipped
# Conflicts:
#	CHANGELOG.md
2026-03-03 16:29:41 +01:00
Deeman
68f354ac2b docs: update CHANGELOG for slug fix + country overview ranking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:26:55 +01:00
Deeman
0b74156ef7 merge: accept alternative Stripe env var names 2026-03-03 16:24:25 +01:00
Deeman
fab16cb48f fix(billing): accept STRIPE_API_PRIVATE_KEY / STRIPE_API_PUBLIC_KEY env var names
Also normalise PAYMENT_PROVIDER to lowercase so STRIPE/stripe both work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:24:03 +01:00
Deeman
062a6d2766 merge: Stripe payment provider (dispatch-by-config alongside Paddle) 2026-03-03 16:07:52 +01:00
Deeman
80c2f111d2 feat(billing): B4-B5 — tests, lint fixes, CHANGELOG + PROJECT.md
- Fix unused imports in stripe.py (hashlib, hmac, time)
- Update test_billing_routes.py: insert into payment_products table,
  fix mock paths for extracted paddle.py, add Stripe webhook 404 test
- Update CHANGELOG.md with Stripe provider feature
- Update PROJECT.md Done section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:07:30 +01:00
Deeman
7ae8334d7a feat(billing): B3 — setup_stripe.py product/price creation script
Mirrors setup_paddle.py structure:
- Creates 17 products + prices in Stripe (same keys, same prices)
- Writes to payment_products table with provider='stripe'
- Registers webhook endpoint at /billing/webhook/stripe
- tax_behavior='exclusive' (price + VAT on top, EU standard)
- Supports --sync flag to re-populate from existing Stripe products

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:38 +01:00
Deeman
032fe8d86c feat(billing): B2 — Stripe payment provider implementation
billing/stripe.py exports the same interface as paddle.py:
- build_checkout_payload() → Stripe Checkout Session with automatic_tax
- build_multi_item_checkout_payload() → multi-line-item sessions
- cancel_subscription() → cancel_at_period_end=True
- get_management_url() → Stripe Billing Portal session
- verify_webhook() → Stripe-Signature header verification
- parse_webhook() → maps Stripe events to shared format:
  checkout.session.completed → subscription.activated / transaction.completed
  customer.subscription.updated → subscription.updated
  customer.subscription.deleted → subscription.canceled
  invoice.payment_failed → subscription.past_due

All API calls have 10s timeout and max 2 retries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:48:08 +01:00
Deeman
4907bc8b64 feat(billing): B1 — add stripe SDK dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:37:29 +01:00
Deeman
bf69270913 feat(billing): A6 — planner/supplier routes use get_price_id() + _provider()
- planner/routes.py: import get_price_id instead of get_paddle_price,
  export_checkout uses _provider().build_checkout_payload()
- suppliers/routes.py: all get_paddle_price → get_price_id,
  signup_checkout uses _provider().build_multi_item_checkout_payload(),
  dashboard boosts use get_all_price_ids() bulk load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:36:12 +01:00
Deeman
8f0a56079f feat(billing): A5 — dual-path JS templates for Paddle overlay / Stripe redirect
- New _payment_js.html: conditionally loads Paddle.js or nothing (Stripe
  uses server-side Checkout Session). Provides startCheckout() helper.
- All checkout templates use _payment_js.html instead of _paddle.html
- export.html, signup_step_4.html: Paddle.Checkout.open() → startCheckout()
- dashboard_boosts.html: inline onclick → buyItem() with server round-trip
- New /billing/checkout/item endpoint for single-item purchases (boosts, credits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:52 +01:00
Deeman
7af9b2c82c feat(billing): A2+A4 — extract paddle.py + dispatch layer in routes.py
- New billing/paddle.py: Paddle-specific functions (build_checkout_payload,
  cancel_subscription, get_management_url, verify_webhook, parse_webhook)
- routes.py: _provider() dispatch function selects paddle or stripe module
- Checkout/manage/cancel routes now delegate to _provider()
- /webhook/paddle always active (existing subscribers)
- /webhook/stripe endpoint added (returns 404 until Stripe configured)
- Shared _handle_webhook_event() processes normalized events from any provider
- _price_id_to_key() queries payment_products with paddle_products fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:26:47 +01:00
Deeman
276328af33 feat(billing): A1+A3 — payment_products table + provider-agnostic price lookups
- Migration 0028: create payment_products table, copy paddle_products rows
- Add STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET config
- Make PAYMENT_PROVIDER read from env (was hardcoded "paddle")
- Add get_price_id() / get_all_price_ids() querying payment_products
- Keep get_paddle_price() as deprecated fallback alias

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:07:10 +01:00
Deeman
a00c8727d7 fix(content): slugify transliteration + article links + country overview ranking
- Add @slugify SQLMesh macro (STRIP_ACCENTS + ß→ss) replacing broken
  inline REGEXP_REPLACE that dropped non-ASCII chars (Düsseldorf → d-sseldorf)
- Apply @slugify to dim_venues, dim_cities, dim_locations
- Fix Python slugify() to pre-replace ß→ss before NFKD normalization
- Add language prefix to B2B article market links (/markets/germany → /de/markets/germany)
- Change country overview top-5 ranking: venue count (not raw market_score)
  for top cities, population for top opportunity cities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:46:30 +01:00
Deeman
0fc0ca66b1 fix(i18n): replace smart quotes with straight quotes in sup_hero_sub
Some checks failed
CI / test (push) Failing after 29s
CI / tag (push) Has been skipped
Curly quotes (U+201C/U+201D) were used as JSON key/value delimiters
on line 894 of both locale files, making them invalid JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:53:39 +01:00
Deeman
385deb7f81 feat(cro): CRO overhaul — homepage + supplier landing pages (JTBD rewrite)
Some checks failed
CI / test (push) Failing after 9s
CI / tag (push) Has been skipped
2026-03-03 06:44:30 +01:00
Deeman
3ddb26ae0f chore: update CHANGELOG.md and PROJECT.md for CRO overhaul
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:44:19 +01:00
Deeman
695e956501 feat(cro): German translations for all CRO copy changes
Native-quality DE translations for homepage + supplier page:
- Hero, ROI, features, FAQ, final CTA, meta/SEO
- Proof strip, struggling moments, "Why Padelnomics" comparison
- Supplier proof points, ROI line, struggling moments, pricing CTAs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:42:16 +01:00
Deeman
a862d21269 feat(cro): supplier page CRO — struggling moments, conditional stats, honest proof
Task 3: Add "Is this your sales team?" struggling-moments section.
Conditional stats display (hide if below thresholds). Replace anonymous
testimonials with data-backed proof points. Tier-specific pricing CTAs.
Tighter hero sub-headline. Move ROI callout above pricing grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:36:46 +01:00
Deeman
f4f8a45654 feat(cro): homepage structural overhaul — proof strip, struggling moments, comparison
Task 2: Remove journey timeline (3 "SOON" badges = incomplete signal).
Add proof strip below hero with live stats. Add "Sound familiar?"
section with 4 JTBD struggling-moment cards. Add "Why Padelnomics"
3-column comparison (DIY vs consultant vs us). Update hero secondary
CTA and supplier matching links to /quote. Route handler now passes
calc_requests and total_budget_millions to template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:33:43 +01:00
Deeman
9e471f8960 feat(cro): rewrite homepage EN copy — outcome-focused JTBD framing
Task 1: Hero, features, FAQ, final CTA, supplier matching, meta/SEO
strings all rewritten. New keys added for proof strip, struggling-
moments section, and "Why Padelnomics" comparison section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:31:24 +01:00
Deeman
48401bd2af feat(articles): rewrite B2B article CTAs — directory → /quote form
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
All 12 hall-building articles now link to /quote (leads.quote_request).
Previously: 2 had broken directory prose, 4 had unlinked planner mentions,
4 had broken [→ placeholder] links, 2 had scenario cards but no CTA link.

- Group 1 (bauen/build-guide): replace directory section with quote CTA
- Group 2 (kosten/risiken): link planner refs, append quote CTA
- Group 3 (finanzierung): append quote CTA after scenario card
- Group 4 (standort/businessplan): fix broken [→] links to /de|en/planner,
  append quote CTA

CTA copy is contextual per article. Light-blue banner pattern, .btn class.
B2C gear articles unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:28 +01:00
Deeman
cd02726d4c chore(changelog): document B2B article CTA rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:20 +01:00
Deeman
fbc259cafa fix(articles): fix broken CTA links + add /quote CTA in location and business plan articles
- padel-standort-analyse-de, padel-hall-location-guide-en:
  fix [→ ...] placeholders to /de/planner and /en/planner
  append quote CTA "Standort gefunden? Angebote einholen"
- padel-business-plan-bank-de, padel-business-plan-bank-requirements-en:
  fix [→ Businessplan erstellen] / [→ Generate your business plan] to planner
  append quote CTA "Bankfähige Zahlen plus passende Baupartner"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:39:59 +01:00
Deeman
992e448c18 fix(articles): add /quote CTA after scenario card in financing articles
Appends contextual quote CTA block to padel-halle-finanzierung-de.md
and padel-hall-financing-germany-en.md after the scenario card embed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:29:01 +01:00
Deeman
777a4af505 fix(articles): add /quote CTA + planner links in cost and risk articles
- padel-halle-kosten-de, padel-hall-cost-guide-en: link planner ref,
  append quote CTA "Zahlen prüfen — Angebote einholen"
- padel-halle-risiken-de, padel-hall-investment-risks-en: link planner
  in sensitivity tab mention, append quote CTA on risk management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:18:46 +01:00
Deeman
2c8c662e9e fix(articles): replace directory CTA with /quote in build guides
Removes the broken "find suppliers" directory section from
padel-halle-bauen-de.md and padel-hall-build-guide-en.md.
Replaces with a contextual light-blue quote CTA block linking to /quote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:17:28 +01:00
Deeman
34f8e45204 merge(articles): iframe preview + collapsible meta + word count 2026-03-02 12:09:04 +01:00
Deeman
6b9187f420 fix(articles): iframe preview + collapsible meta + word count
Replace the auto-escaped `{{ body_html }}` div (showed raw HTML tags)
with a sandboxed `<iframe srcdoc>` pattern matching the email preview.
Both the initial page load and the HTMX live-update endpoint now build
a full `preview_doc` document embedding the public CSS and wrapping
content in `<div class="article-body">` — pixel-perfect against the
live article, admin styles fully isolated.

Also:
- Delete ~65 lines of redundant `.preview-body` custom CSS
- Add "Meta ▾" toolbar toggle to collapse/expand metadata strip
- Add word count footer in the editor pane (updates on input)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:01:16 +01:00
120 changed files with 8073 additions and 834 deletions

View File

@@ -31,12 +31,18 @@ RESEND_WEBHOOK_SECRET=
#ENC[AES256_GCM,data:1HqXvAspvNIUNpCxJwge3mEsyO0Y/EWvD3vbLxkgGqIex0hABcupX/Nzk15u8iOY5JWvvEuAO414MNt6mFvnWBDpEw==,iv:N7gCzTNJAR/ljx5gGsX+ieZctya8vQbCIb3hw49OhXg=,tag:PJKNyzhrit5VgIXl+cNlbQ==,type:comment] #ENC[AES256_GCM,data:1HqXvAspvNIUNpCxJwge3mEsyO0Y/EWvD3vbLxkgGqIex0hABcupX/Nzk15u8iOY5JWvvEuAO414MNt6mFvnWBDpEw==,iv:N7gCzTNJAR/ljx5gGsX+ieZctya8vQbCIb3hw49OhXg=,tag:PJKNyzhrit5VgIXl+cNlbQ==,type:comment]
#ENC[AES256_GCM,data:do6DZ/1Osc5y4xseG8Q8bDX84JBHLzvmVbHiqxP7ChlicmzYBkZ85g43BuM7V0KInFTFgvaC8xmFic+2d37Holuf1ywdAjbLkRhg,iv:qrNmhPbmFDr2ynIF5EdOLZl3FI5f68WDrxuHMkAzuuU=,tag:761gYOlEdNM+e1//1MbCHg==,type:comment] #ENC[AES256_GCM,data:do6DZ/1Osc5y4xseG8Q8bDX84JBHLzvmVbHiqxP7ChlicmzYBkZ85g43BuM7V0KInFTFgvaC8xmFic+2d37Holuf1ywdAjbLkRhg,iv:qrNmhPbmFDr2ynIF5EdOLZl3FI5f68WDrxuHMkAzuuU=,tag:761gYOlEdNM+e1//1MbCHg==,type:comment]
#ENC[AES256_GCM,data:dseLIQiUEU20xJqoq2dkFho9SnKyoyQ8pStjvfxwnj8v18/ua0TH/PDx/qwIp9z5kEIvbsz5ycJesFfKPhLA5juGcdCbi5zBmZRWYg==,iv:7JUmRnohJt0H5yoJXVD3IauuJkpPHDPyY02OWHWb9Nw=,tag:KcM6JGT01Aa1kTx+U30UKQ==,type:comment] #ENC[AES256_GCM,data:dseLIQiUEU20xJqoq2dkFho9SnKyoyQ8pStjvfxwnj8v18/ua0TH/PDx/qwIp9z5kEIvbsz5ycJesFfKPhLA5juGcdCbi5zBmZRWYg==,iv:7JUmRnohJt0H5yoJXVD3IauuJkpPHDPyY02OWHWb9Nw=,tag:KcM6JGT01Aa1kTx+U30UKQ==,type:comment]
#ENC[AES256_GCM,data:VXv1O5oRNTws8wbx/nZWH6Q=,iv:M/XwF6Zef+xlJ/8AAVI1zSmsEUNYL+0twzxXwkf8moY=,tag:y3Nu5akuiKtEIMeZhSNIkw==,type:comment]
PAYMENT_PROVIDER=ENC[AES256_GCM,data:7uxz3xmr,iv:4uEOA7ZjehD1bF91Gxl0+OxnvlZW3QIq22MhnYM43uE=,tag:XvHqyRM+ugnWTUN9GFJ3fQ==,type:str]
#ENC[AES256_GCM,data:GgXo4zkhJsxXEk8F5a/+wdbvBUGN00MUAutZYLDEqqN4T1rZu92fioOLx7MEoC0b8i61,iv:f1hUBoZpmnzXNcikf/anVNdRSHNwVmmjdIcba3eiRI4=,tag:uWpF40uuiXyWqKrYGyLVng==,type:comment] #ENC[AES256_GCM,data:GgXo4zkhJsxXEk8F5a/+wdbvBUGN00MUAutZYLDEqqN4T1rZu92fioOLx7MEoC0b8i61,iv:f1hUBoZpmnzXNcikf/anVNdRSHNwVmmjdIcba3eiRI4=,tag:uWpF40uuiXyWqKrYGyLVng==,type:comment]
PADDLE_API_KEY= PADDLE_API_KEY=
PADDLE_CLIENT_TOKEN= PADDLE_CLIENT_TOKEN=
PADDLE_WEBHOOK_SECRET= PADDLE_WEBHOOK_SECRET=
PADDLE_NOTIFICATION_SETTING_ID= PADDLE_NOTIFICATION_SETTING_ID=
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:KIGNxEaodA==,iv:SRebaYRpVJR0LpfalBZJLTE8qBGwWZB/Fx3IokQF99Q=,tag:lcC56e4FjVkCiyaq41vxcQ==,type:str] PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:KIGNxEaodA==,iv:SRebaYRpVJR0LpfalBZJLTE8qBGwWZB/Fx3IokQF99Q=,tag:lcC56e4FjVkCiyaq41vxcQ==,type:str]
#ENC[AES256_GCM,data:sk79dbsswA==,iv:J8CyJt/WOMLd7CZNutDwIOtAOAooaMsLPO35gfWo+Nc=,tag:JQcGMYdgcQgtIWKcqXZkNQ==,type:comment]
STRIPE_API_PUBLIC_KEY=ENC[AES256_GCM,data:WhWvIzNd1sS+IrrEdE+FJI6ZgEiNlgG3oxC8VoDzXf0z1oH1wgY6m9wUq6UEZZyzeiRGAeAylOk6wHJ+Lx4+zx2cfv+yweX7I3Sq5VN2D1OBPiQ3Kde4zm5cXqA92jRkLAomZxw/DkeiB14=,iv:Rb3GSLMVSySR++X240MICsXbVtOuqZNjm+nIe+s65dU=,tag:z82dyRzmxF3e87Sm2F+4Qw==,type:str]
STRIPE_API_PRIVATE_KEY=ENC[AES256_GCM,data:/62y1Iv2Op21eEvT3BosgWD0S3YqGMgdfb2Edjhq2cuh32B3eH5fh9FaqBc3CvJpM7R79hy9jTnV3CTjlCkvrXGCLDnFY2a6kvSz5f+v2d/lsr8zvFLs6OP+bhssHdVygfIwz9ye46tfcFk=,iv:iw0NAYUf/gCM4awb2tKBEKuo/j7kkpVP6JjIIdVy7O8=,tag:GO3ASp5bykwHDHNkCYsdiA==,type:str]
STRIPE_ACCOUNT_ID=ENC[AES256_GCM,data:ahJsOgZLRi5n9P7Dy0U1rvmhwr/B,iv:aoVA3M8Faqv1kZwTtagD0WLVipkA5nkX5uSjtHl14+I=,tag:XwLOu9ZiHUizcsnk73bt1w==,type:str]
#ENC[AES256_GCM,data:2Hs7ds2ppeRqKB7EiAAbWqlainKdZ+eTYZSvPloirT4Hlsuf+zTwtJTA6RzHNCuK4em//jhOx8R2k80I,iv:1N6CNPqYWp3z8lm5e2Vp6OlpgHdMOiD7dsEYp23nMtA=,tag:ulWP/BFFoLljLMVCrsgizw==,type:comment] #ENC[AES256_GCM,data:2Hs7ds2ppeRqKB7EiAAbWqlainKdZ+eTYZSvPloirT4Hlsuf+zTwtJTA6RzHNCuK4em//jhOx8R2k80I,iv:1N6CNPqYWp3z8lm5e2Vp6OlpgHdMOiD7dsEYp23nMtA=,tag:ulWP/BFFoLljLMVCrsgizw==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:oX/m95YB+S2ziUKoxDhsDzMhGZfxppw+w603tQ==,iv:GAj7ccF6seiCfLAh2XIjUi13RpgNA3GONMtINcG+KMw=,tag:mUfRlvaEWrw2QWFydtnbNA==,type:str] UMAMI_API_URL=ENC[AES256_GCM,data:oX/m95YB+S2ziUKoxDhsDzMhGZfxppw+w603tQ==,iv:GAj7ccF6seiCfLAh2XIjUi13RpgNA3GONMtINcG+KMw=,tag:mUfRlvaEWrw2QWFydtnbNA==,type:str]
UMAMI_API_TOKEN= UMAMI_API_TOKEN=
@@ -73,7 +79,7 @@ GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj9
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str] CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-03-01T13:34:16Z sops_lastmodified=2026-03-03T15:16:35Z
sops_mac=ENC[AES256_GCM,data:JLfGLbNTEcI6M/sUA5Zez6cfEUObgnUBmX52560PzBmeLZt0F5Y5QpeojIBqEDMuNB0hp1nnPI59WClLJtQ12VlHo9TkL3x9uCNUG+KneQrn1bTmJpA3cwNkWTzIm4l+TGbJbd4FpKJ9H0v1w+sqoKOgG8DqbtOeVdUfsVspAso=,iv:UqYxooXkEtx+y7fYzl+GFncpkjz8dcP7o9fp+kFf6w4=,tag:/maSb1aZGo+Ia8eGpB7PYw==,type:str] sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
sops_unencrypted_suffix=_unencrypted sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1 sops_version=3.12.1

View File

@@ -3,6 +3,7 @@ APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvz
SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str] SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str]
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str] BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str] DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:rn8u+tGob0vU7kSAtxmrpYQlneesvyO10A==,iv:PuGtdcQBdRbnybulzd6L7JVQClcK3/QjMeYFXZSxGW0=,tag:K2PJPMCWXdqTlQpwP9+DOQ==,type:str]
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment] #ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str] ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment] #ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
@@ -63,7 +64,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
sops_lastmodified=2026-03-01T20:26:09Z sops_lastmodified=2026-03-05T15:55:19Z
sops_mac=ENC[AES256_GCM,data:IxzU6VehA0iHgpIEqDSoMywKyKONI6jSr/6Amo+g3JI72awJtk6ft0ppfDWZjeHhL0ixfnvgqMNwai+1e0V/U8hSP8/FqYKEVpAO0UGJfBPKP3pbw+tx3WJQMF5dIh2/UVNrKvoACZq0IDJfXlVqalCnRMQEHGtKVTIT3fn8m6c=,iv:0w0ohOBsqTzuoQdtt6AI5ZdHEKw9+hI73tycBjDSS0o=,tag:Guw7LweA4m4Nw+3kSuZKWA==,type:str] sops_mac=ENC[AES256_GCM,data:orLypjurBTYmk3um0bDQV3wFxj1pjCsjOf2D+AZyoIYY88MeY8BjK8mg8BWhmJYlGWqHH1FCpoJS+2SECv2Bvgejqvx/C/HSysA8et5CArM/p/MBbcupLAKOD8bTXorKMRDYPkWpK/snkPToxIZZd7dNj/zSU+OhRp5qLGCHkvM=,iv:eBn93z4DSk8UPHgP/Jf/Kz+3KwoKIQ9Et72pbLFcLP8=,tag:79kzPIKp0rtHGhH1CkXqwg==,type:str]
sops_unencrypted_suffix=_unencrypted sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1 sops_version=3.12.1

View File

@@ -6,6 +6,92 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Changed
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
- **Marktpotenzial-Score v3: H3 catchment lens** — addressable market (25pts) and supply gap (30pts) now use a regional H3 catchment (~15-18km radius, res-4 cell + 6 neighbours, ~462km²) instead of local city population and 5km court count. Mid-size cities surrounded by dense Gemeinden (e.g. Oldenburg) now score correctly. New output columns: `catchment_population`, `catchment_padel_courts`, `catchment_venues_per_100k`. Requires one-time `INSTALL h3 FROM community` in DuckDB on each machine.
### Added
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.
### Fixed
- **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise).
- **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint.
- **Silent 500 errors in dev** — `dev_run.sh` used Granian which swallowed Quart's debug error pages, showing generic "Internal Server Error" with no traceback. Switched to `uv run python -m padelnomics.app` for proper debug mode with browser tracebacks. Added `@app.errorhandler(500)` to log exceptions even when running under Granian in production.
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.
### Added
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
- **Running state indicator** — extraction cards show a spinner + "Running" label with a blue-tinted border when an extraction is actively running, replacing the plain Run button. Cards also display the start time with "running..." text.
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
- **City cost articles**: venue dot map centered on city lat/lon (zoom 13), navy dots per venue with tooltip showing name + court breakdown (indoor/outdoor).
- **Opportunity map** (`/<lang>/opportunity-map`): standalone full-width page with country selector. Circles sized by population, colored by opportunity score (green ≥ 70, amber 40-70, blue < 40). Existing venues shown as gray reference dots.
- New `/api` blueprint with 4 JSON endpoints (`/api/markets/countries.json`, `/api/markets/<country>/cities.json`, `/api/markets/<country>/<city>/venues.json`, `/api/opportunity/<country>.json`) — 1-hour public cache headers, all queries against `analytics.duckdb` via `fetch_analytics`.
- New SQLMesh serving model `city_venue_locations` exposing venue lat/lon + court counts per city.
- `pseo_city_costs_de` serving model: added `lat`/`lon` columns for city map data attributes in baked articles.
- Leaflet CSS included on all article pages (5KB, cached). JS loaded dynamically only when a map container is present.
- **Individualised article financial calculations with real per-country cost data** — ~30 CAPEX/OPEX calculator fields now scale to each country's actual cost level via Eurostat data, eliminating the identical DE-hardcoded numbers shown for every city globally.
- **New Eurostat datasets extracted** (8 new landing files): electricity prices (`nrg_pc_205`), gas prices (`nrg_pc_203`), labour costs (`lc_lci_lev`), and 5 price level index categories from `prc_ppp_ind` (construction, housing, services, misc, government).
- `extract/padelnomics_extract/src/padelnomics_extract/eurostat.py`: added 8 dataset entries; added `dataset_code` field support so multiple dict entries can share one Eurostat API endpoint (needed for 5 prc_ppp_ind variants).
- **4 new staging models**: `stg_electricity_prices`, `stg_gas_prices`, `stg_labour_costs`, `stg_price_levels` — all read from landing zone with ISO code normalisation (EL→GR, UK→GB).
- **New `foundation.dim_countries`** — conformed country dimension (grain: `country_code`). Consolidates country names/slugs and income data previously duplicated in `dim_cities` and `dim_locations` as ~50-line CASE blocks. Computes ~29 calculator cost override columns from Eurostat PLI indices and energy prices relative to DE baseline.
- **Refactored `dim_cities`** — removed ~50-line CASE blocks and `country_income` CTE; JOIN `dim_countries` for `country_name_en`, `country_slug`, `median_income_pps`, `income_year`.
- **Refactored `dim_locations`** — same refactor as `dim_cities`; income cascade still cascades EU NUTS-2 → US state → `dim_countries` country-level.
- **Updated `serving.pseo_city_costs_de`** — JOIN `dim_countries`; 29 new camelCase override columns (`electricity`, `heating`, `rentSqm`, `hallCostSqm`, …, `permitsCompliance`) auto-applied by calculator.
- **Updated `serving.planner_defaults`** — JOIN `dim_countries`; same 29 cost columns flow through to the planner API `/api/market-data` endpoint.
- **Bulk actions for articles and leads** — checkbox selection + floating action bar on admin articles and leads pages (same pattern as suppliers). Articles: publish, unpublish, toggle noindex, rebuild, delete. Leads: set status, set heat. Re-renders results via HTMX after each action.
- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active.
- `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing)
- `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module
- `billing/routes.py`: provider-agnostic dispatch layer — checkout, manage, cancel routes call `_provider().xxx()`
- `_payment_js.html`: dual-path JS — conditionally loads Paddle.js SDK, universal `startCheckout()` handles both overlay (Paddle) and redirect (Stripe)
- `scripts/setup_stripe.py`: mirrors `setup_paddle.py` — creates 17 products + prices in Stripe, registers webhook endpoint
- Migration 0028: `payment_products` table generalizing `paddle_products` with `provider` column; existing Paddle rows copied
- `get_price_id()` / `get_all_price_ids()` replace `get_paddle_price()` for provider-agnostic lookups
- Stripe config vars: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`
- Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint
- Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard)
### Fixed
- **City slug transliteration** — replaced broken inline `REGEXP_REPLACE(LOWER(...), '[^a-z0-9]+', '-')` with new `@slugify` SQLMesh macro that uses `STRIP_ACCENTS` + `ß→ss` pre-replacement. Fixes: `Düsseldorf``dusseldorf` (was `d-sseldorf`), `Überlingen``uberlingen` (was `-berlingen`). Applied to `dim_venues`, `dim_cities`, `dim_locations`. Python `slugify()` in `core.py` updated to match.
- **B2B article market links** — added missing language prefix (`/markets/germany``/de/markets/germany` and `/en/markets/germany`). Without the prefix, Quart interpreted `markets` as a language code → 500 error.
- **Country overview top-5 city list** — changed ranking from raw `market_score DESC` (which inflated tiny towns with high density scores) to `padel_venue_count DESC` for top cities and `population DESC` for top opportunity cities. Germany now shows Berlin, Hamburg, München instead of Überlingen, Schwaigern.
### Changed
- **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster."
- **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
- **Proof strip**: live stats bar below hero (business plans created, suppliers, countries, project volume)
- **"Sound familiar?" section**: replaces the 5-step journey timeline (3 items said "SOON") with 4 struggling-moment cards from JTBD research
- **Feature cards reframed as outcomes**: "60+ Variables" → "Know Your Numbers Inside Out", "6 Analysis Tabs" → "Bank-Ready from Day One", "Sensitivity Analysis" → "Stress-Test Before You Commit", etc.
- **"Why Padelnomics" comparison**: 3-column section (DIY Spreadsheet vs. Hired Consultant vs. Padelnomics) from JTBD Competitive Job Map
- **FAQ rewritten**: customer-first questions ("How much does it cost to open a padel facility?", "Will a bank accept this?") replace product-internal questions
- **Final CTA**: "Your Bank Meeting Is Coming. Be Ready." replaces generic "Start Planning Today"
- **Supplier page**: "Is this your sales team?" struggling-moments section, conditional stats display (hides zeros), data-backed proof points replacing anonymous testimonials, ROI math moved above pricing, tier-specific CTAs
- **Meta/SEO**: updated page title and description for search intent
- All changes in both EN and DE (native-quality German, generisches Maskulinum)
### Fixed
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
- `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
- `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
- `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
- `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
- CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
### Changed ### Changed
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler. - **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines. - **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.

View File

@@ -25,6 +25,7 @@ WORKDIR /app
RUN mkdir -p /app/data && chown -R appuser:appuser /app RUN mkdir -p /app/data && chown -R appuser:appuser /app
COPY --from=build --chown=appuser:appuser /app . COPY --from=build --chown=appuser:appuser /app .
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
USER appuser USER appuser
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV DATABASE_PATH=/app/data/app.db ENV DATABASE_PATH=/app/data/app.db

View File

@@ -60,6 +60,7 @@
- [x] Boost purchases (logo, highlight, verified, card color, sticky week/month) - [x] Boost purchases (logo, highlight, verified, card color, sticky week/month)
- [x] Credit pack purchases (25/50/100/250) - [x] Credit pack purchases (25/50/100/250)
- [x] Supplier subscription tiers (Basic free / Growth €199 / Pro €499, monthly + annual) - [x] Supplier subscription tiers (Basic free / Growth €199 / Pro €499, monthly + annual)
- [x] **Stripe payment provider** — env-var toggle (`PAYMENT_PROVIDER=paddle|stripe`), Stripe Checkout Sessions + Billing Portal + webhook handling, `payment_products` table generalizes `paddle_products`, dual-path JS templates, `billing/paddle.py` + `billing/stripe.py` dispatch pattern, `setup_stripe.py` product creation script
- [x] **Feature flags** (DB-backed, migration 0019) — `is_flag_enabled()` + `feature_gate()` decorator replace `WAITLIST_MODE`; 5 flags (markets, payments, planner_export, supplier_signup, lead_unlock); admin UI at `/admin/flags` with toggle - [x] **Feature flags** (DB-backed, migration 0019) — `is_flag_enabled()` + `feature_gate()` decorator replace `WAITLIST_MODE`; 5 flags (markets, payments, planner_export, supplier_signup, lead_unlock); admin UI at `/admin/flags` with toggle
- [x] **Pricing overhaul** — Basic free (no Paddle sub), card color €59, BP PDF €149; supplier page restructured value-first (why → guarantee → leads → social proof → pricing); all CTAs "Get Started Free"; static ROI line; credits-only callout - [x] **Pricing overhaul** — Basic free (no Paddle sub), card color €59, BP PDF €149; supplier page restructured value-first (why → guarantee → leads → social proof → pricing); all CTAs "Get Started Free"; static ROI line; credits-only callout
- [x] **Lead-Back Guarantee** (migration 0020) — 1-click credit refund for non-responding leads (330 day window); `refund_lead_guarantee()` in credits.py; "Lead didn't respond" button on unlocked lead cards - [x] **Lead-Back Guarantee** (migration 0020) — 1-click credit refund for non-responding leads (330 day window); `refund_lead_guarantee()` in credits.py; "Lead didn't respond" button on unlocked lead cards
@@ -157,6 +158,7 @@
- [x] Padel racket SVG logo/favicon - [x] Padel racket SVG logo/favicon
- [x] Feedback widget (HTMX POST, rate-limited) - [x] Feedback widget (HTMX POST, rate-limited)
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call) - [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
--- ---

View File

@@ -160,4 +160,10 @@ Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland. Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
[→ Businessplan erstellen] [→ Businessplan erstellen](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -160,4 +160,10 @@ A bankable business plan depends on the quality of the financial model behind it
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*. The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
[→ Generate your business plan] [→ Generate your business plan](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -331,8 +331,10 @@ Building a padel hall is complex, but it is a solved problem. The failures are n
--- ---
## Find Builders and Suppliers Through Padelnomics ## Find the Right Build Partners
Padelnomics maintains a directory of verified build partners for padel hall projects: architects with sports facility experience, court suppliers, HVAC specialists, and operational consultants. <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
If you're currently in Phase 1 or Phase 2 and looking for the right partners, the directory is the fastest place to start. <p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -191,4 +191,10 @@ Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated. The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers. **Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -179,3 +179,9 @@ Your most powerful tool in every bank meeting: a complete financial model demons
[scenario:padel-halle-6-courts:full] [scenario:padel-halle-6-courts:full]
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application. The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -218,6 +218,12 @@ The investors who succeed long-term in padel aren't the ones who found a risk-fr
## Model the Downside with Padelnomics ## Model the Downside with Padelnomics
The Padelnomics investment planner includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand? The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
Good decisions need an honest model — not just the best-case assumptions. Good decisions need an honest model — not just the best-case assumptions.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -176,7 +176,7 @@ Before committing to a site search in any city, calibrate where it sits on this
Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites. Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites.
[→ View market data by country](/markets/germany) [→ View market data by country](/en/markets/germany)
--- ---
@@ -184,4 +184,10 @@ Padelnomics tracks venue density, booking platform utilisation, and demographic
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it. Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
[→ Run a location analysis] [→ Run a location analysis](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -326,8 +326,10 @@ Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehl
--- ---
## Planer und Lieferanten finden ## Die richtigen Baupartner finden
Padelnomics führt ein Verzeichnis verifizierter Baupartner für Padelhallen im DACH-Raum: Architekten mit Sportanlagenerfahrung, Court-Lieferanten, Haustechnikspezialisten und Betriebsberater. <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
Wenn Sie gerade in Phase 1 oder Phase 2 sind und die richtigen Partner suchen, ist das Verzeichnis der schnellste Einstieg. <p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -199,3 +199,9 @@ Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell
[scenario:padel-halle-6-courts:full] [scenario:padel-halle-6-courts:full]
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann. Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -189,4 +189,10 @@ Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service. Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut. **Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -216,6 +216,12 @@ Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfo
## Die Padelnomics-Investitionsrechnung ## Die Padelnomics-Investitionsrechnung
Der Padelnomics-Planer enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht? Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen. Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -166,7 +166,7 @@ Bevor Sie in einer Stadt konkret nach Objekten suchen, sollten Sie deren Marktre
Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier: Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier:
[→ Marktüberblick nach Land](/markets/germany) [→ Marktüberblick nach Land](/de/markets/germany)
--- ---
@@ -174,4 +174,10 @@ Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografisch
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig. Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
[→ Standortanalyse starten] [→ Standortanalyse starten](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -1,6 +1,6 @@
# Padelnomics — Marketing Master Doc # Padelnomics — Marketing Master Doc
> Living doc. Update state column as things progress. Last updated: 2026-02-22. > Living doc. Update state column as things progress. Last updated: 2026-03-04.
--- ---
@@ -216,9 +216,9 @@ The moat compounds over time — this is critical to long-term defensibility.
| Channel | Approach | State | | Channel | Approach | State |
|---------|----------|-------| |---------|----------|-------|
| **LinkedIn** | Founder posts, thought leadership, padel community | [ ] Not started | | **LinkedIn** | Founder posts, thought leadership, padel community | [~] First post published |
| **Reddit** | r/padel, r/entrepreneur — seeding calculator, articles | [ ] Not started | | **Reddit** | r/padel, r/sweatystartup, r/entrepreneur, r/tennis, r/smallbusiness, r/pickleball, r/CRE — seeding calculator, articles | [~] Active in 7 subreddits |
| **Facebook Groups** | Padel business groups, sports entrepreneur communities | [ ] Not started | | **Facebook Groups** | Padel business groups, sports entrepreneur communities | [~] Active in 2-3 groups |
### Borrowed (Month 2+) ### Borrowed (Month 2+)

89
docs/gtm-day-one.md Normal file
View File

@@ -0,0 +1,89 @@
# GTM — Day One Action Plan
> Created: 2026-03-04. Do these in order. Total time: ~45 hours.
---
## Right Now (12 hours, highest leverage)
### 1. Submit sitemap to Google Search Console + Bing Webmaster Tools
You have 80 programmatic city articles sitting unindexed. Every day without indexing is wasted compound time.
- [search.google.com/search-console](https://search.google.com/search-console) → Add property → Submit sitemap
- [bing.com/webmasters](https://www.bing.com/webmasters) (Bing also feeds DuckDuckGo, Ecosia, Yahoo)
- Your SEO hub already supports both — just add the env vars
### 2. Publish SEO articles on prod
Run `seed_content --generate` from admin or CLI. Those 80 city pages (40 cities × EN+DE) are the primary organic traffic engine. Until they're live and crawlable, they generate zero value.
### 3. Index the planner in Google
Make sure `/en/calculator` and `/de/rechner` are in the sitemap and crawlable. This is the #1 free tool — the entire PLG funnel starts here. Check canonical tags and hreflang are correct.
---
## This Afternoon (23 hours, seed distribution)
### 4. First LinkedIn post
Data-driven insight from the pipeline. See `docs/social-posts.md` for the full post.
### 5. Post in Reddit communities
- **r/padel**: Free calculator angle — genuinely useful tool
- **r/entrepreneur**: Indie maker angle — "built this with real market data"
- **r/smallbusiness**: Business planning tool angle
- **r/tennis**: Cross-sport angle — tennis clubs adding padel courts
See `docs/social-posts.md` for all posts ready to copy-paste.
### 6. Share in 23 Facebook padel business groups
Same angle as Reddit — free tool, no hard sell. Search for:
- "Padel Business" groups
- "Padel Club Owners" groups
- "Padel Deutschland" / "Padel Germany" groups
---
## This Evening (1 hour, set up compounding assets)
### 7. Verify Resend production API key
Test a real magic link email. Until email works in prod, you can't capture traffic.
### 8. Wipe test suppliers
Delete the 5 `example.com` entries. Empty directory with "Be the first to list" > obviously fake data.
### 9. Request indexing for top 5 city pages
After GSC is set up, use "Request Indexing" manually for highest-value pages:
- `/de/markets/berlin`, `/de/markets/muenchen`, `/de/markets/hamburg`
- `/en/markets/london`, `/en/markets/madrid`
Google prioritizes manually requested URLs — can appear in search within days vs. weeks.
---
## What NOT to do today
- ~~"State of Padel" report~~ — multi-day effort
- ~~Supplier outreach~~ — site needs to be live + articles indexed first
- ~~Copy/CRO optimization~~ — premature, get traffic first
- ~~Paid ads~~ — excluded in channel strategy
---
## Expected outcome
If you do steps 19 today:
- 80 pages submitted for indexing (organic traffic starts in 13 weeks)
- 35 social posts seeding traffic immediately
- Planner discoverable and shareable
- Email capture working for when traffic arrives
**Single highest-leverage action: publish the articles + submit the sitemap.** Everything else is distribution on top of that foundation.

View File

@@ -0,0 +1,91 @@
# Reddit Communities — Padelnomics Distribution
> Permanent reference for Reddit distribution. Subreddits ranked by relevance + size.
> Created: 2026-03-04. Review monthly — subreddit rules change.
---
## Tier 1 — Post Here First
High relevance, receptive to tools/data, proven padel or business-planning interest.
| Subreddit | Size | Angle | Notes |
|-----------|------|-------|-------|
| r/padel | ~20K | Free calculator, data insights, answer existing biz threads | Player community — lead with the sport, not the product. Helpful tone only. |
| r/sweatystartup | ~56-81K | "Best brick-and-mortar sports opportunity" with unit economics | Loves concrete P&L numbers. Show CAPEX/OPEX/payback, not vision. |
| r/tennis | ~2M | Tennis club court conversion trends + data | Huge audience. Angle: "your club is probably already thinking about this." |
| r/smallbusiness | ~2.2M | Free business planning tool for sports facilities | Practical, no-hype tone. Lead with the tool, not the market thesis. |
---
## Tier 2 — Test With One Post Each
Potentially high-value but less proven fit. Post once, measure engagement, double down if it works.
| Subreddit | Size | Angle | Notes |
|-----------|------|-------|-------|
| r/entrepreneur | ~4.8M | "Bloomberg for padel" indie builder story | Loves "I built X" posts with real data. Show the data pipeline, not just the product. |
| r/CommercialRealEstate | ~44K | Sports venue site selection as niche CRE | Small but highly targeted. Angle: alternative asset class with data backing. |
| r/realestateinvesting | ~1.2M | Alternative commercial RE asset class | Broader audience. Frame padel as "the new self-storage" — boring but profitable. |
| r/pickleball | ~30K | Padel vs pickleball facility economics comparison | Comparative angle works. Don't trash pickleball — frame as "here's what the padel side looks like." |
| r/gymowners | Small | Cross-reference gym location frameworks with padel data | Niche. Test if gym owners see padel as a complementary or competing asset. |
| r/padelUSA | <5K | US-specific demand data | Tiny but highly relevant. US padel market is nascent — early authority opportunity. |
---
## Tier 3 — Monitor Only
Read these for trends and conversations. Don't post unless a specific thread is a perfect fit for a data-backed comment.
- r/business — too generic, self-promo gets buried
- r/startups — SaaS-focused, padel doesn't fit the narrative
- r/SaaS — pure software community, facility business is off-topic
- r/venturecapital — wrong audience for bootstrapped niche tool
- r/sports — massive, low engagement on niche content
---
## Key Gap
No subreddit exists for padel facility operators or business owners. If community forms organically around Padelnomics content (comments like "where can I discuss this more?"), consider creating **r/padelbusiness** later. Don't force it — let demand signal the timing.
---
## Posting Rules
1. **One link per post, at the end.** Never in the title.
2. **Engage with every comment for 24 hours** after posting. This is where the real value is.
3. **No cross-posting.** Each post is unique to the subreddit's culture and tone.
4. **If a post gets removed, don't repost.** Move to the next subreddit. Respect mod decisions.
5. **Read each subreddit's rules before posting.** Some ban self-promotion entirely. Some require flair. Some have minimum account age/karma requirements.
6. **Never post more than one subreddit per day.** Spread it out. Reddit's spam detection flags rapid multi-sub posting.
7. **Comment on existing threads first.** Build karma and presence in a sub before dropping your own post.
---
## UTM Tracking Format
All Reddit links use this format:
```
https://padelnomics.io/<path>?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_<subreddit>
```
Examples:
- `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel`
- `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_sweatystartup`
- `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_cre`
---
## Measuring Success
| Metric | Good | Great |
|--------|------|-------|
| Post upvotes | 10+ | 50+ |
| Comments | 5+ | 20+ |
| UTM clicks (GA) | 20+ per post | 100+ per post |
| Planner completions from Reddit | 5+ per post | 20+ per post |
| Email captures from Reddit | 2+ per post | 10+ per post |
Track weekly in a simple spreadsheet. Drop subreddits that produce zero clicks after 2 posts.

106
docs/reddit-posting-plan.md Normal file
View File

@@ -0,0 +1,106 @@
# Reddit Posting Plan — Launch Sequence
> Day-by-day posting schedule. One post per day, engage for 24 hours after each.
> Created: 2026-03-04. See `docs/reddit-communities.md` for full subreddit research.
---
## Posting Sequence
| Day | Subreddit | Post Title | Angle | UTM |
|-----|-----------|-----------|-------|-----|
| 1 | r/padel | "I built a free padel court ROI calculator — feedback welcome" | Free tool, genuinely helpful | `utm_content=r_padel` |
| 2 | r/sweatystartup | "25K venues analyzed — which cities are undersupplied for padel" | Unit economics, brick-and-mortar opportunity | `utm_content=r_sweatystartup` |
| 3 | r/entrepreneur | "I'm building the 'Bloomberg for padel' — tracking 10,127 facilities across 17 countries" | Indie builder story with real data | `utm_content=r_entrepreneur` |
| 4 | r/tennis | "Data on padel facility economics — useful for tennis clubs considering adding courts" | Tennis club conversion data | `utm_content=r_tennis` |
| 5 | r/smallbusiness | "Free business planning tool for anyone looking at opening a sports facility" | Practical tool for real decisions | `utm_content=r_smallbusiness` |
| 7 | r/pickleball | "Padel vs pickleball facility economics — a data comparison" | Comparative, respectful of pickleball | `utm_content=r_pickleball` |
| 10 | r/CommercialRealEstate | "Sports venue site selection — data on underserved markets" | Alternative CRE asset class | `utm_content=r_cre` |
Day 6 and days 8-9 are rest days for engaging with comments on previous posts.
---
## Full UTM Format
Every Reddit link follows this exact format:
```
https://padelnomics.io/<path>?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=<value>
```
| Subreddit | utm_content value |
|-----------|-------------------|
| r/padel | `r_padel` |
| r/sweatystartup | `r_sweatystartup` |
| r/entrepreneur | `r_entrepreneur` |
| r/tennis | `r_tennis` |
| r/smallbusiness | `r_smallbusiness` |
| r/pickleball | `r_pickleball` |
| r/CommercialRealEstate | `r_cre` |
---
## Post Content
Full post text is in `docs/social-posts.md`. Before posting, replace `[LINK]` placeholders with the correct UTM-tagged URL:
| Post | Link to |
|------|---------|
| r/padel | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel` |
| r/sweatystartup | `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_sweatystartup` |
| r/entrepreneur | `https://padelnomics.io/en/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_entrepreneur` |
| r/tennis | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_tennis` |
| r/smallbusiness | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_smallbusiness` |
| r/pickleball | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_pickleball` |
| r/CommercialRealEstate | `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_cre` |
---
## Rules
1. **One link per post, at the end.** Never in the title.
2. **Engage with every comment for 24 hours** after posting.
3. **No cross-posting.** Each post is written uniquely for its subreddit's culture.
4. **If a post gets removed, don't repost.** Move to the next subreddit.
5. **Read subreddit rules before posting.** Check for self-promotion policies, flair requirements, minimum karma.
6. **Comment on 2-3 existing threads** in a subreddit before making your own post (builds credibility).
7. **Never mention other posts.** Each community should feel like they're getting a unique share.
---
## Engagement Playbook
### When you get comments:
- **"How accurate is this?"** — Share methodology: real market data from OpenStreetMap, Playtomic, Eurostat. Not generic assumptions.
- **"What about [city]?"** — Run the planner for their city, share the numbers. This is high-value personalized engagement.
- **"I'm actually looking at opening a facility"** — Offer to walk through the planner with them. Ask about their timeline, location, budget. This is a lead.
- **"This is just an ad"** — Don't get defensive. Say "Fair point — I built this and wanted feedback. The tool is free with no signup, so figured it might be useful here."
- **"What's your business model?"** — Be transparent: "Free calculator, paid market intelligence for serious investors, supplier directory for builders."
### When a post gets traction (50+ upvotes):
- Reply with additional data points to keep the thread alive
- Answer every question, even late ones
- Don't edit the original post to add more links
---
## Tracking
After each post, log:
| Field | Example |
|-------|---------|
| Date posted | 2026-03-04 |
| Subreddit | r/padel |
| Post URL | reddit.com/r/padel/... |
| Upvotes (24hr) | 15 |
| Comments (24hr) | 7 |
| UTM clicks (GA, 7d) | 42 |
| Planner starts (7d) | 12 |
| Emails captured (7d) | 3 |
| Removed? | No |
Review after Day 10. Double down on subreddits that drove clicks. Drop ones that didn't.

View File

@@ -0,0 +1,150 @@
# SEO Content Calendar — First 30 Days
> 4-week content plan covering programmatic SEO deployment, cornerstone articles, and data-driven content.
> Created: 2026-03-04.
---
## Week 1 — Foundation (March 4-10)
Get the existing 80 pages indexed and write the first cornerstone article.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon | Publish 80 programmatic city articles (40 cities x EN+DE) | Deploy | [ ] |
| Mon | Submit sitemap to Google Search Console | Manual | [ ] |
| Mon | Submit sitemap to Bing Webmaster Tools | Manual | [ ] |
| Tue | Request manual indexing for top 10 pages in GSC | Manual | [ ] |
| Tue | Verify hreflang tags and canonical URLs on all city pages | Audit | [ ] |
| Wed-Fri | Write Article #1: "Is Padel Still a Good Investment in 2026?" | Editorial | [ ] |
| Fri | Publish Article #1, add to sitemap | Deploy | [ ] |
**Top 10 pages for manual indexing:**
1. `/de/markets/berlin`
2. `/de/markets/muenchen`
3. `/de/markets/hamburg`
4. `/en/markets/london`
5. `/en/markets/madrid`
6. `/en/calculator`
7. `/de/rechner`
8. `/en/markets/paris`
9. `/de/markets/frankfurt`
10. `/de/markets/koeln`
---
## Week 2 — Cornerstone Content (March 11-17)
Two high-value articles targeting decision-stage keywords. Internal linking pass connects everything.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon-Tue | Write Article #2: "How Much Does It Cost to Open a Padel Hall in Germany?" | Editorial | [ ] |
| Wed | Publish Article #2 | Deploy | [ ] |
| Thu-Fri | Write Article #3: "What Banks Want to See in a Padel Business Plan" | Editorial | [ ] |
| Fri | Publish Article #3 | Deploy | [ ] |
| Sat | Internal linking pass: city articles -> cornerstone articles -> planner | Technical | [ ] |
### Article #2 — Target Keywords
- "padel halle kosten" / "padel court cost germany"
- "padel halle eroeffnen kosten" / "how much to open padel hall"
- "padel anlage investition"
### Article #3 — Target Keywords
- "padel business plan" / "padel halle business plan"
- "padel halle finanzierung" / "padel financing"
- "bank business plan padel"
### Internal Linking Structure
```
City article (e.g., /markets/berlin)
-> "How much does it cost?" (Article #2)
-> "Plan your facility" (/calculator)
Article #2 (Cost breakdown)
-> "Build your business plan" (/calculator)
-> "What banks want to see" (Article #3)
-> City-specific examples (/markets/muenchen, /markets/hamburg)
Article #3 (Bank requirements)
-> "Generate your business plan" (/calculator)
-> "Check market data for your city" (/markets)
```
---
## Week 3 — Data-Driven Content (March 18-24)
Leverage the pipeline data for unique content nobody else can produce.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon-Wed | Write "Top 50 Underserved Locations for Padel in Europe" | Editorial | [ ] |
| Wed | Publish Top 50 article | Deploy | [ ] |
| Thu-Fri | Build Gemeinde-level pSEO template (targets "Padel in [Ort]") | Technical | [ ] |
| Fri | Generate first batch of Gemeinde pages (top 20 locations) | Deploy | [ ] |
### Top 50 Article
- Source data from `location_opportunity_profile` in the serving layer
- Rank by opportunity score, filter to locations with zero existing facilities
- Include mini-profiles: population, income level, nearest existing facility, opportunity score
- Embed interactive map if possible, otherwise static top-50 table
- Target keywords: "where to open padel", "best locations padel europe", "padel market gaps"
### Gemeinde-Level pSEO
- Template targets: "Padel in [Ort]" / "Padel [Gemeinde]"
- Zero SERP competition confirmed for most German municipalities
- Content: local demographics, nearest facilities, opportunity score, CTA to planner
- Start with top 20 highest-opportunity Gemeinden, expand weekly
---
## Week 4 — Authority Building (March 25-31)
Establish Padelnomics as the data authority. Begin email-gated content for list building.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon-Wed | Write "State of Padel Q1 2026" report | Editorial | [ ] |
| Wed | Design PDF layout (WeasyPrint or similar) | Technical | [ ] |
| Thu | Publish report landing page (email-gated download) | Deploy | [ ] |
| Thu | Promote Market Score methodology page via social | Social | [ ] |
| Fri | Begin link building via Reddit/LinkedIn engagement | Social | [ ] |
| Ongoing | Monitor GSC for indexing progress, fix crawl errors | Technical | [ ] |
### State of Padel Q1 2026 Report
- Executive summary of European padel market
- Facility count by country (from pipeline data)
- Growth trends (year-over-year where data exists)
- Top opportunity markets (from opportunity scoring)
- Investment economics summary (from planner defaults)
- Email-gated: free download in exchange for email address
- Promote via LinkedIn, Reddit, and direct outreach to industry contacts
---
## Content Inventory (End of Month 1)
| Type | Count | State |
|------|-------|-------|
| Programmatic city articles (EN+DE) | 80 | Deployed Week 1 |
| Cornerstone articles | 3 | Published Weeks 1-2 |
| Data-driven article (Top 50) | 1 | Published Week 3 |
| Gemeinde-level pSEO pages | 20+ | Started Week 3 |
| Gated report (State of Padel) | 1 | Published Week 4 |
| **Total indexable pages** | **105+** | |
---
## SEO KPIs — End of Month 1
| Metric | Target |
|--------|--------|
| Pages indexed (GSC) | 80+ of 105 |
| Organic impressions | 500+ |
| Organic clicks | 50+ |
| Average position (target keywords) | Top 50 |
| Email captures from gated report | 50+ |
| Backlinks acquired | 3+ |
These are conservative baselines. Programmatic pages in zero-competition niches can index and rank faster than typical content.

153
docs/social-posts-de.md Normal file
View File

@@ -0,0 +1,153 @@
# Social Posts — Deutsche Versionen
> Fertige Posts zum Rauskopieren. Domain: padelnomics.io
> Erstellt: 2026-03-04.
>
> Reddit-Posts bleiben auf Englisch (englischsprachige Subreddits).
> Diese Datei enthält LinkedIn- und Facebook-Posts auf Deutsch.
---
## LinkedIn Post #1 — Marktdaten
> Ziel: Glaubwürdigkeit aufbauen + Traffic auf den Rechner lenken.
```
10.127 Padel-Anlagen in 17 Ländern — wir haben sie alle erfasst.
Was dabei auffällt:
→ Italien führt mit 3.069 Anlagen. Mehr als Spanien (2.241).
→ Portugal hat den reifsten Padel-Markt weltweit (Maturity Score 45,2/100) — bei „nur" 506 Anlagen.
→ Deutschland: 359 Anlagen für 84 Mio. Einwohner. Spanien: 2.241 für 47 Mio.
Diese Lücke ist die Chance.
Wir haben 15.390 Standorte ohne Padel-Angebot identifiziert, die hohes Potenzial zeigen. Hamburg, München und Frankfurt stehen in Deutschland ganz oben.
Für alle, die über eine eigene Padel-Anlage nachdenken oder jemanden beraten: Wir haben einen kostenlosen ROI-Rechner gebaut, der mit echten Marktdaten die Kosten, Umsätze und Amortisation für jede Stadt in Europa modelliert.
Ohne Anmeldung. Einfach rechnen.
→ https://padelnomics.io/de/planner/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marktdaten
#padel #sportbusiness #marktdaten #unternehmertum
```
---
## LinkedIn Post #2 — Standortanalyse (Tag 23 posten)
```
Die 5 am stärksten unterversorgten Städte für Padel in Europa:
1. Hamburg — 1,85 Mio. Einwohner, keine einzige Padel-Anlage
2. München — 1,26 Mio. Einwohner, starke Sportkultur, kaum Angebot
3. Bergen (Norwegen) — 294.000 Einwohner, Opportunity Score: 87,5/100
4. Graz (Österreich) — 303.000 Einwohner, null Courts, hohes Einkommen
5. Genf (Schweiz) — 202.000 Einwohner, null Courts, höchste Kaufkraft
Keine Schätzungen. Wir bewerten 143.877 Standorte in Europa anhand von Bevölkerungsdichte, Einkommensdaten, bestehendem Angebot und Sportinfrastruktur.
Der Padel-Markt wächst von 25.000 auf über 50.000 Anlagen weltweit. Die Frage ist nicht ob — sondern wo.
→ Daten für eure Stadt: https://padelnomics.io/de/markets?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_standortanalyse
#padel #marktanalyse #sportsinvestment #immobilien
```
---
## LinkedIn Post #3 — Gründerstory (optional, Woche 2)
```
Vor einem Jahr habe ich angefangen, den europäischen Padel-Markt systematisch zu erfassen.
Der Auslöser: Jeder, der eine Padel-Halle plant, trifft eine Entscheidung im sechsstelligen Bereich — und hat dafür keine belastbaren Daten. Kein zentrales Marktbild. Keine vergleichbaren Kennzahlen. Nur Excel und Bauchgefühl.
Daraus ist Padelnomics entstanden: eine Datenplattform für die Padel-Branche.
Was heute live ist:
→ Kostenloser ROI-Rechner mit stadtspezifischen Realdaten
→ 80 Marktanalysen für Städte in 17 Ländern
→ Standortbewertung für 143.877 Orte in Europa
→ Anbieterverzeichnis für Bau und Ausstattung
Die Daten kommen aus OpenStreetMap, Playtomic, Eurostat und Zensusdaten — automatisch aggregiert und bewertet.
Noch am Anfang, aber der Datenvorsprung wächst jeden Tag.
→ https://padelnomics.io/de/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_gruenderstory
#padel #startup #datenplattform #sportbusiness
```
---
## Facebook — Padel-Gruppen (Deutschland/DACH)
> Ton: locker, hilfsbereit, kurz. Kein Pitch.
**Titel (falls die Gruppe Titel erlaubt):** Kostenloser Padel-Rechner mit echten Marktdaten
```
Moin zusammen,
ich hab einen kostenlosen Finanzplanungs-Rechner für Padel-Anlagen gebaut. CAPEX, laufende Kosten, Umsatzprognose — und am Ende eine 5-Jahres-GuV mit Amortisation.
Der Unterschied zu den üblichen Excel-Vorlagen: Der Rechner befüllt sich automatisch mit echten Daten für euren Standort. Mieten, Nebenkosten, Genehmigungsgebühren — alles stadtspezifisch, basierend auf Daten aus 17 Ländern.
Keine Anmeldung, kostenlos.
→ https://padelnomics.io/de/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_padel_de
Feedback ist willkommen — gerade von Leuten, die den Planungsprozess schon hinter sich haben und wissen, welche Zahlen wirklich zählen.
```
---
## Facebook — Tennisvereine / Sportvereine (DACH)
> Ziel: Tennisvereine, die über Padel-Courts nachdenken.
```
Falls euer Verein gerade über Padel-Courts nachdenkt (und viele tun das): Ich hab ein kostenloses Tool gebaut, das die Wirtschaftlichkeit durchrechnet.
→ Investitionskosten für 26 Courts an bestehenden Anlagen
→ Umsatzprognose auf Basis realer Auslastungs- und Preisdaten
→ Laufende Kosten für euren konkreten Standort
→ Amortisation und ROI-Kennzahlen
Ein paar Zahlen aus unseren Daten:
- Durchschnittliche Auslastung in reifen Märkten: 6075 %
- Outdoor-Anlage mit 4 Courts: 200.000350.000 €
- Indoor: 700.0003 Mio. € je nach Bauweise
- Tennisvereine, die 2 Plätze umrüsten, sehen typischerweise nach 1830 Monaten Amortisation
Keine Anmeldung nötig.
→ https://padelnomics.io/de/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_tennis_de
Kann gern Daten zu einzelnen Städten oder Regionen teilen, wenn ihr etwas Konkretes prüft.
```
---
## Posting-Zeitplan
| Tag | Plattform | Post |
|-----|-----------|------|
| Heute | LinkedIn (Company Page) | Post #1 (Marktdaten) |
| Heute | 12 deutsche FB-Padel-Gruppen | Padel-Rechner |
| Morgen | 12 FB-Tennisvereins-Gruppen | Tennisverein-Angle |
| Tag 3 | LinkedIn (Company Page) | Post #2 (Standortanalyse) |
| Woche 2 | LinkedIn (Company Page) | Post #3 (Gründerstory) |
---
## Regeln
- Ein Link pro Post, am Ende.
- 24 Stunden auf jeden Kommentar reagieren.
- Wenn ein Post Traktion bekommt: mit zusätzlichen Datenpunkten nachliefern.
- UTM-Tracking: `?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marktdaten` bzw. `utm_source=facebook` für FB-Posts.

248
docs/social-posts.md Normal file
View File

@@ -0,0 +1,248 @@
# Social Posts — Launch Day
> Ready to copy-paste. Domain: padelnomics.io
> Created: 2026-03-04.
---
## LinkedIn Post #1 — Data Insight
> Post type: data-driven thought leadership. Goal: establish credibility + drive traffic to planner.
```
We've been tracking 10,127 padel facilities across 17 countries.
Here's what surprised me about the European market:
→ Italy leads with 3,069 facilities — more than Spain (2,241)
→ Portugal has the world's most mature padel market (45.2/100 maturity score) with "only" 506 facilities
→ Germany has just 359 facilities for 84M people. Spain has 2,241 for 47M.
That gap is the opportunity.
We identified 15,390 high-potential locations with zero padel courts worldwide.
Hamburg, Munich, and Frankfurt top the list in Germany alone.
If you're thinking about opening a padel facility — or advising someone who is — we built a free ROI calculator that uses this data to model costs, revenue, and payback period for any city in Europe.
No signup required. Just real numbers.
→ https://padelnomics.io/en/planner/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marketdata
#padel #sportsbusiness #marketdata #entrepreneurship
```
---
## LinkedIn Post #2 — Opportunity Angle (schedule for Day 23)
```
The 5 most underserved cities for padel in Europe right now:
1. Hamburg (1.85M residents, zero dedicated padel facilities)
2. Munich (1.26M residents, massive sports culture, minimal supply)
3. Bergen, Norway (294K residents, opportunity score: 87.5/100)
4. Graz, Austria (303K residents, zero courts, high income)
5. Geneva, Switzerland (202K residents, zero courts, highest purchasing power)
These aren't guesses. We score 143,877 locations across Europe using population density, income data, existing supply, and sports infrastructure.
The padel market is growing from 25K to 50K+ facilities globally. The question isn't whether — it's where.
→ Explore the data for your city: https://padelnomics.io/en/markets?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_opportunity
#padel #marketintelligence #sportsinvestment #realestate
```
---
## Reddit — r/padel
> Tone: genuinely helpful, not promotional. r/padel is a player community, so lead with the sport angle.
**Title:** I built a free padel court ROI calculator — feedback welcome
```
Hey r/padel,
I've been working on a data project tracking the padel market across Europe
(facility counts, market maturity, opportunity gaps). As part of that, I built
a free calculator for anyone thinking about opening a padel facility.
It models:
- CAPEX (construction, equipment, permits)
- OPEX (rent, staffing, utilities, maintenance)
- Revenue projections based on real market data from your city
- 5-year P&L with payback period, IRR, and break-even
It pre-fills with city-specific defaults — so if you pick Munich, it uses
Munich rents, Munich utility costs, etc. Not generic averages.
No signup needed. Just wanted to share in case anyone here has ever thought
about the business side of padel.
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel
Happy to answer questions about the data or methodology. Also open to feedback
on what would make this more useful.
```
---
## Reddit — r/entrepreneur
> Tone: indie builder sharing a project. r/entrepreneur loves "I built X" posts with real data.
**Title:** I'm building the "Bloomberg for padel" — tracking 10,127 facilities across 17 countries
```
Padel is the fastest-growing sport in Europe and Latin America. There are now
10,000+ facilities worldwide and the market is expected to double to 50K+ in
the next 5 years.
The problem: anyone trying to open a padel facility is flying blind. No
centralized market data exists. People are making €200K€2M investment
decisions based on Excel spreadsheets and gut feel.
I'm building Padelnomics — a data intelligence platform for the padel industry.
Think "Kpler for padel" if you're familiar with commodity data platforms.
What's live right now:
- Free ROI calculator that models costs, revenue, and payback for any European
city (pre-filled with real local data — rents, utilities, permits, etc.)
- 80 market analysis pages covering cities across 17 countries
- Market maturity scoring for 4,686 cities with padel facilities
- Opportunity scoring for 143,877 locations (identifying where to build next)
The data comes from OpenStreetMap, Playtomic (booking platform), Eurostat, and
census data — aggregated and scored automatically.
Revenue model: free calculator captures leads (aspiring facility owners) →
supplier directory connects them with builders → suppliers pay for qualified
leads via credit system.
Still early but the data moat compounds daily — every day of scraping = data
competitors can't replicate.
Would love feedback from anyone who's built data products or two-sided
marketplaces.
→ https://padelnomics.io/en/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_entrepreneur
```
---
## Reddit — r/smallbusiness
> Tone: practical tool for a real business decision.
**Title:** Free business planning tool for anyone looking at opening a sports facility
```
I built a free financial planning tool specifically for padel facilities
(indoor/outdoor sports courts — fastest growing sport in Europe right now).
It covers the full picture:
- Construction costs (indoor vs outdoor, number of courts)
- Operating expenses (rent, staff, utilities, insurance, maintenance)
- Revenue modeling (hourly rates, occupancy rates, lessons, events)
- 5-year P&L projection
- Key metrics: payback period, IRR, break-even point
The tool pre-fills with real data for your city — actual local rents, utility
costs, permit fees — not generic averages.
You can also generate a bank-ready business plan PDF from it.
Free to use, no signup required for the calculator itself.
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_smallbusiness
Built this because I kept seeing people on forums asking "how much does it cost
to open a padel hall?" and getting wildly different answers. Figured real data
was better than guesswork.
```
---
## Reddit — r/tennis
> Tone: cross-sport angle. Many tennis clubs are adding padel courts.
**Title:** Data on padel facility economics — useful for tennis clubs considering adding courts
```
If your club is thinking about adding padel courts (and many are right now),
I built a free financial planning tool that models the full economics:
- CAPEX for adding 26 courts to an existing facility
- Revenue projections based on real occupancy and pricing data
- Operating costs specific to your city/country
- Payback period and ROI metrics
The tool uses actual market data — we track 10,127 padel facilities across
17 countries and score market maturity + opportunity by city.
Some interesting numbers:
- Average padel facility in a mature market runs at 6075% occupancy
- A 4-court outdoor setup costs €200K€350K
- Indoor builds jump to €700K€3M depending on structure
- Tennis clubs converting 2 courts to padel typically see payback in 1830 months
Free to use, no signup needed.
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_tennis
Happy to share data on any specific city or country if you're evaluating this
for your club.
```
---
## Facebook Groups — Padel Business / Deutschland
> Tone: casual, helpful. Shorter than Reddit posts.
**Title (if group allows):** Free padel facility ROI calculator — uses real market data
```
Hey everyone 👋
Built a free tool for anyone planning a padel facility. It models CAPEX,
OPEX, revenue, and gives you a 5-year P&L with payback period.
The difference from spreadsheet templates: it pre-fills with real data for
your city (actual rents, utility costs, permit fees, etc.) based on data
we're collecting across 17 countries.
No signup, no cost. Just real numbers.
→ https://padelnomics.io/en/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_padel
Feedback welcome — especially from anyone who's been through the planning
process and knows what numbers actually matter.
```
---
## Posting Schedule
| Day | Platform | Post |
|-----|----------|------|
| Today | LinkedIn | Post #1 (Data Insight) |
| Today | r/padel | Calculator feedback post |
| Today | r/entrepreneur | "Bloomberg for padel" builder post |
| Today | 12 FB groups | Calculator share |
| Tomorrow | r/smallbusiness | Business planning tool post |
| Tomorrow | r/tennis | Tennis club angle |
| Day 3 | LinkedIn | Post #2 (Opportunity Angle) |
---
## Rules
- Never link-spam. One link per post, at the end.
- Engage with every comment for 24 hours after posting.
- If a post gets traction, reply with additional data points to keep it alive.
- Track which subreddits/groups drive actual signups via UTM params:
`?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel`

View File

@@ -26,6 +26,10 @@ EUROSTAT_BASE_URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/
# Dataset configs: filters fix dimension values, geo_dim/time_dim are iterated. # Dataset configs: filters fix dimension values, geo_dim/time_dim are iterated.
# All other dimensions must either be in filters or have size=1. # All other dimensions must either be in filters or have size=1.
#
# Optional `dataset_code` field: when present, used for the API URL instead of the dict key.
# This allows multiple entries to share the same Eurostat dataset with different filters
# (e.g. five prc_ppp_ind entries with different ppp_cat values).
DATASETS: dict[str, dict] = { DATASETS: dict[str, dict] = {
"urb_cpop1": { "urb_cpop1": {
"filters": {"indic_ur": "DE1001V"}, # Population on 1 January, total "filters": {"indic_ur": "DE1001V"}, # Population on 1 January, total
@@ -51,6 +55,59 @@ DATASETS: dict[str, dict] = {
"geo_dim": "geo", "geo_dim": "geo",
"time_dim": "time", "time_dim": "time",
}, },
# ── Direct-value datasets (actual EUR figures) ───────────────────────────
"nrg_pc_205": {
# Electricity prices for non-household consumers, EUR/kWh, excl. taxes
"filters": {"freq": "S", "nrg_cons": "MWH500-1999", "currency": "EUR", "tax": "I_TAX"},
"geo_dim": "geo",
"time_dim": "time",
},
"nrg_pc_203": {
# Gas prices for non-household consumers, EUR/kWh, excl. taxes
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "unit": "KWH", "currency": "EUR", "tax": "I_TAX"},
"geo_dim": "geo",
"time_dim": "time",
},
"lc_lci_lev": {
# Labour cost levels EUR/hour — NACE N (administrative/support services)
# D1_D4_MD5 = compensation of employees + taxes - subsidies (total labour cost)
"filters": {"lcstruct": "D1_D4_MD5", "nace_r2": "N", "unit": "EUR"},
"geo_dim": "geo",
"time_dim": "time",
},
# ── Price level indices (relative scaling, EU27=100) ─────────────────────
# Five entries share the prc_ppp_ind dataset with different ppp_cat filters.
# dataset_code points to the real API endpoint; the dict key is the landing filename.
"prc_ppp_ind_construction": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "A050202", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_housing": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "A0104", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_services": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "P0201", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_misc": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "A0112", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_government": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "P0202", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
} }
@@ -196,22 +253,25 @@ def extract(
files_skipped = 0 files_skipped = 0
bytes_written_total = 0 bytes_written_total = 0
for dataset_code, config in DATASETS.items(): for dataset_key, config in DATASETS.items():
url = f"{EUROSTAT_BASE_URL}/{dataset_code}?format=JSON&lang=EN" # Use dataset_code (if set) for the API URL; fall back to the dict key.
# This lets multiple entries share one Eurostat dataset with different filters.
api_code = config.get("dataset_code", dataset_key)
url = f"{EUROSTAT_BASE_URL}/{api_code}?format=JSON&lang=EN"
for key, val in config.get("filters", {}).items(): for key, val in config.get("filters", {}).items():
url += f"&{key}={val}" url += f"&{key}={val}"
dest_dir = landing_path(landing_dir, "eurostat", year, month) dest_dir = landing_path(landing_dir, "eurostat", year, month)
dest = dest_dir / f"{dataset_code}.json.gz" dest = dest_dir / f"{dataset_key}.json.gz"
logger.info("GET %s", dataset_code) logger.info("GET %s", dataset_key)
bytes_written = _fetch_with_etag(url, dest, session, config) bytes_written = _fetch_with_etag(url, dest, session, config)
if bytes_written > 0: if bytes_written > 0:
logger.info("%s updated — %s bytes compressed", dataset_code, f"{bytes_written:,}") logger.info("%s updated — %s bytes compressed", dataset_key, f"{bytes_written:,}")
files_written += 1 files_written += 1
bytes_written_total += bytes_written bytes_written_total += bytes_written
else: else:
logger.info("%s not modified (304)", dataset_code) logger.info("%s not modified (304)", dataset_key)
files_skipped += 1 files_skipped += 1
return { return {

View File

@@ -33,10 +33,10 @@ do
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \ DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package padelnomics_extract extract uv run --package padelnomics_extract extract
# Transform # Transform — run evaluates missing daily intervals for incremental models.
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \ LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \ DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package sqlmesh_padelnomics sqlmesh run --select-model "serving.*" uv run sqlmesh -p transform/sqlmesh_padelnomics run prod
# Export serving tables to analytics.duckdb (atomic swap). # Export serving tables to analytics.duckdb (atomic swap).
# The web app detects the inode change on next query — no restart needed. # The web app detects the inode change on next query — no restart needed.

View File

@@ -8,54 +8,67 @@
# entry — optional: function name if not "main" (default: "main") # entry — optional: function name if not "main" (default: "main")
# depends_on — optional: list of workflow names that must run first # depends_on — optional: list of workflow names that must run first
# proxy_mode — optional: "round-robin" (default) or "sticky" # proxy_mode — optional: "round-robin" (default) or "sticky"
# description — optional: human-readable one-liner shown in the admin UI
[overpass] [overpass]
module = "padelnomics_extract.overpass" module = "padelnomics_extract.overpass"
schedule = "monthly" schedule = "monthly"
description = "Padel court locations from OpenStreetMap via Overpass API"
[overpass_tennis] [overpass_tennis]
module = "padelnomics_extract.overpass_tennis" module = "padelnomics_extract.overpass_tennis"
schedule = "monthly" schedule = "monthly"
description = "Tennis court locations from OpenStreetMap via Overpass API"
[eurostat] [eurostat]
module = "padelnomics_extract.eurostat" module = "padelnomics_extract.eurostat"
schedule = "monthly" schedule = "monthly"
description = "City population data from Eurostat Urban Audit"
[geonames] [geonames]
module = "padelnomics_extract.geonames" module = "padelnomics_extract.geonames"
schedule = "monthly" schedule = "monthly"
description = "Global city/town gazetteer from GeoNames (pop >= 1K)"
[playtomic_tenants] [playtomic_tenants]
module = "padelnomics_extract.playtomic_tenants" module = "padelnomics_extract.playtomic_tenants"
schedule = "daily" schedule = "daily"
description = "Padel venue directory from Playtomic (names, locations, courts)"
[playtomic_availability] [playtomic_availability]
module = "padelnomics_extract.playtomic_availability" module = "padelnomics_extract.playtomic_availability"
schedule = "daily" schedule = "daily"
depends_on = ["playtomic_tenants"] depends_on = ["playtomic_tenants"]
description = "Morning availability snapshots — slot-level pricing per venue"
[playtomic_recheck] [playtomic_recheck]
module = "padelnomics_extract.playtomic_availability" module = "padelnomics_extract.playtomic_availability"
entry = "main_recheck" entry = "main_recheck"
schedule = "0,30 6-23 * * *" schedule = "0,30 6-23 * * *"
depends_on = ["playtomic_availability"] depends_on = ["playtomic_availability"]
description = "Intraday availability rechecks for occupancy tracking"
[census_usa] [census_usa]
module = "padelnomics_extract.census_usa" module = "padelnomics_extract.census_usa"
schedule = "monthly" schedule = "monthly"
description = "US city/place population from Census Bureau ACS"
[census_usa_income] [census_usa_income]
module = "padelnomics_extract.census_usa_income" module = "padelnomics_extract.census_usa_income"
schedule = "monthly" schedule = "monthly"
description = "US county median household income from Census Bureau ACS"
[eurostat_city_labels] [eurostat_city_labels]
module = "padelnomics_extract.eurostat_city_labels" module = "padelnomics_extract.eurostat_city_labels"
schedule = "monthly" schedule = "monthly"
description = "City code-to-name mapping for Eurostat Urban Audit cities"
[ons_uk] [ons_uk]
module = "padelnomics_extract.ons_uk" module = "padelnomics_extract.ons_uk"
schedule = "monthly" schedule = "monthly"
description = "UK local authority population estimates from ONS"
[gisco] [gisco]
module = "padelnomics_extract.gisco" module = "padelnomics_extract.gisco"
schedule = "monthly" schedule = "0 0 1 1 *"
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"

290
scripts/check_pipeline.py Normal file
View File

@@ -0,0 +1,290 @@
"""
Diagnostic script: check row counts at every layer of the pricing pipeline.
Run on prod via SSH:
DUCKDB_PATH=/opt/padelnomics/data/lakehouse.duckdb uv run python scripts/check_pipeline.py
Or locally:
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
Read-only — never writes to the database.
Handles the DuckDB catalog naming quirk: when the file is named lakehouse.duckdb,
the catalog is "lakehouse" not "local". SQLMesh views may reference the wrong catalog,
so we fall back to querying physical tables (sqlmesh__<schema>.<table>__<hash>).
"""
import os
import sys
import duckdb
DUCKDB_PATH = os.environ.get("DUCKDB_PATH", "data/lakehouse.duckdb")
PIPELINE_TABLES = [
("staging", "stg_playtomic_availability"),
("foundation", "fct_availability_slot"),
("foundation", "dim_venue_capacity"),
("foundation", "fct_daily_availability"),
("serving", "venue_pricing_benchmarks"),
("serving", "pseo_city_pricing"),
]
def _use_catalog(con):
"""Detect and USE the database catalog so schema-qualified queries work."""
catalogs = [
row[0]
for row in con.execute(
"SELECT catalog_name FROM information_schema.schemata"
).fetchall()
]
# Pick the non-system catalog (not 'system', 'temp', 'memory')
user_catalogs = [c for c in set(catalogs) if c not in ("system", "temp", "memory")]
if user_catalogs:
catalog = user_catalogs[0]
con.execute(f"USE {catalog}")
return catalog
return None
def _find_physical_table(con, schema, table):
"""Find the SQLMesh physical table name for a logical table.
SQLMesh stores physical tables as:
sqlmesh__<schema>.<schema>__<table>__<hash>
"""
sqlmesh_schema = f"sqlmesh__{schema}"
try:
rows = con.execute(
"SELECT table_schema, table_name "
"FROM information_schema.tables "
f"WHERE table_schema = '{sqlmesh_schema}' "
f"AND table_name LIKE '{schema}__{table}%' "
"ORDER BY table_name "
"LIMIT 1"
).fetchall()
if rows:
return f"{rows[0][0]}.{rows[0][1]}"
except Exception:
pass
return None
def _query_table(con, schema, table):
"""Try logical view first, fall back to physical table. Returns (fqn, count) or (fqn, error_str)."""
logical = f"{schema}.{table}"
try:
(count,) = con.execute(f"SELECT COUNT(*) FROM {logical}").fetchone()
return logical, count
except Exception:
pass
physical = _find_physical_table(con, schema, table)
if physical:
try:
(count,) = con.execute(f"SELECT COUNT(*) FROM {physical}").fetchone()
return f"{physical} (physical)", count
except Exception as e:
return f"{physical} (physical)", f"ERROR: {e}"
return logical, "ERROR: view broken, no physical table found"
def _query_sql(con, sql, schema_tables):
"""Execute SQL, falling back to rewritten SQL using physical table names if views fail.
schema_tables: list of (schema, table) tuples used in the SQL, in order of appearance.
The SQL must use {schema}.{table} format for these references.
"""
try:
return con.execute(sql)
except Exception:
# Rewrite SQL to use physical table names
rewritten = sql
for schema, table in schema_tables:
physical = _find_physical_table(con, schema, table)
if physical:
rewritten = rewritten.replace(f"{schema}.{table}", physical)
else:
raise
return con.execute(rewritten)
def main():
if not os.path.exists(DUCKDB_PATH):
print(f"ERROR: {DUCKDB_PATH} not found")
sys.exit(1)
con = duckdb.connect(DUCKDB_PATH, read_only=True)
print(f"Database: {DUCKDB_PATH}")
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
catalog = _use_catalog(con)
if catalog:
print(f"Catalog: {catalog}")
print()
# ── Row counts at each layer ──────────────────────────────────────────
print("=" * 60)
print("PIPELINE ROW COUNTS")
print("=" * 60)
for schema, table in PIPELINE_TABLES:
fqn, result = _query_table(con, schema, table)
if isinstance(result, int):
print(f" {fqn:55s} {result:>10,} rows")
else:
print(f" {fqn:55s} {result}")
# ── Date range in fct_daily_availability ──────────────────────────────
print()
print("=" * 60)
print("DATE RANGE: fct_daily_availability")
print("=" * 60)
try:
row = _query_sql(
con,
"""
SELECT
MIN(snapshot_date) AS min_date,
MAX(snapshot_date) AS max_date,
COUNT(DISTINCT snapshot_date) AS distinct_days,
CURRENT_DATE AS today,
CURRENT_DATE - INTERVAL '30 days' AS window_start
FROM foundation.fct_daily_availability
""",
[("foundation", "fct_daily_availability")],
).fetchone()
if row:
min_date, max_date, days, today, window_start = row
print(f" Min snapshot_date: {min_date}")
print(f" Max snapshot_date: {max_date}")
print(f" Distinct days: {days}")
print(f" Today: {today}")
print(f" 30-day window start: {window_start}")
if max_date and str(max_date) < str(window_start):
print()
print(" *** ALL DATA IS OUTSIDE THE 30-DAY WINDOW ***")
print(" This is why venue_pricing_benchmarks is empty.")
except Exception as e:
print(f" ERROR: {e}")
# ── HAVING filter impact in venue_pricing_benchmarks ──────────────────
print()
print("=" * 60)
print("HAVING FILTER IMPACT (venue_pricing_benchmarks)")
print("=" * 60)
try:
row = _query_sql(
con,
"""
WITH venue_stats AS (
SELECT
da.tenant_id,
da.country_code,
da.city,
COUNT(DISTINCT da.snapshot_date) AS days_observed
FROM foundation.fct_daily_availability da
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
AND da.occupancy_rate IS NOT NULL
AND da.occupancy_rate BETWEEN 0 AND 1.5
GROUP BY da.tenant_id, da.country_code, da.city
)
SELECT
COUNT(*) AS total_venues,
COUNT(*) FILTER (WHERE days_observed >= 3) AS venues_passing_having,
COUNT(*) FILTER (WHERE days_observed < 3) AS venues_failing_having,
MAX(days_observed) AS max_days,
MIN(days_observed) AS min_days
FROM venue_stats
""",
[("foundation", "fct_daily_availability")],
).fetchone()
if row:
total, passing, failing, max_d, min_d = row
print(f" Venues in 30-day window: {total}")
print(f" Venues with >= 3 days (PASSING): {passing}")
print(f" Venues with < 3 days (FILTERED): {failing}")
print(f" Max days observed: {max_d}")
print(f" Min days observed: {min_d}")
if total == 0:
print()
print(" *** NO VENUES IN 30-DAY WINDOW — check fct_daily_availability dates ***")
except Exception as e:
print(f" ERROR: {e}")
# ── Occupancy rate distribution ───────────────────────────────────────
print()
print("=" * 60)
print("OCCUPANCY RATE DISTRIBUTION (fct_daily_availability)")
print("=" * 60)
try:
rows = _query_sql(
con,
"""
SELECT
CASE
WHEN occupancy_rate IS NULL THEN 'NULL'
WHEN occupancy_rate < 0 THEN '< 0 (invalid)'
WHEN occupancy_rate > 1.5 THEN '> 1.5 (filtered)'
WHEN occupancy_rate <= 0.25 THEN '0 0.25'
WHEN occupancy_rate <= 0.50 THEN '0.25 0.50'
WHEN occupancy_rate <= 0.75 THEN '0.50 0.75'
ELSE '0.75 1.0+'
END AS bucket,
COUNT(*) AS cnt
FROM foundation.fct_daily_availability
GROUP BY 1
ORDER BY 1
""",
[("foundation", "fct_daily_availability")],
).fetchall()
for bucket, cnt in rows:
print(f" {bucket:25s} {cnt:>10,}")
except Exception as e:
print(f" ERROR: {e}")
# ── dim_venue_capacity join coverage ──────────────────────────────────
print()
print("=" * 60)
print("JOIN COVERAGE: fct_availability_slot → dim_venue_capacity")
print("=" * 60)
try:
row = _query_sql(
con,
"""
SELECT
COUNT(DISTINCT a.tenant_id) AS slot_tenants,
COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
FROM foundation.fct_availability_slot a
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id
""",
[
("foundation", "fct_availability_slot"),
("foundation", "dim_venue_capacity"),
],
).fetchone()
if row:
slot_t, cap_t, missing = row
print(f" Tenants in fct_availability_slot: {slot_t}")
print(f" Tenants with capacity match: {cap_t}")
print(f" Tenants missing capacity: {missing}")
if missing and missing > 0:
print(f" *** {missing} tenants dropped by INNER JOIN to dim_venue_capacity ***")
except Exception as e:
print(f" ERROR: {e}")
con.close()
print()
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,553 @@
"""
E2E test for checkout.session.completed webhook → transaction.completed handler.
Tests credit packs, sticky boosts, and business plan PDF purchases by:
1. Constructing realistic checkout.session.completed payloads with our real price IDs
2. Signing them with the active webhook secret
3. POSTing to the running dev server
4. Verifying DB state changes (credit_balance, supplier_boosts, business_plan_exports)
Prerequisites:
- ngrok + webhook endpoint registered (stripe_e2e_setup.py)
- Dev server running with webhook secret loaded
- Stripe products synced (setup_stripe --sync)
Run: uv run python scripts/stripe_e2e_checkout_test.py
"""
import hashlib
import hmac
import json
import os
import sqlite3
import subprocess
import sys
import time
from dotenv import load_dotenv
load_dotenv(override=True)
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
SERVER_URL = "http://localhost:5000"
WEBHOOK_URL = f"{SERVER_URL}/billing/webhook/stripe"
assert WEBHOOK_SECRET, "STRIPE_WEBHOOK_SECRET not set — run stripe_e2e_setup.py"
passed = 0
failed = 0
errors = []
def ok(msg):
global passed
passed += 1
print(f" \u2713 {msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f" \u2717 {msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
def query_db(sql, params=()):
conn = sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
return [dict(r) for r in conn.execute(sql, params).fetchall()]
finally:
conn.close()
def sign_stripe_payload(payload_bytes: bytes, secret: str) -> str:
"""Create a valid Stripe-Signature header."""
timestamp = str(int(time.time()))
signed_payload = f"{timestamp}.{payload_bytes.decode()}"
sig = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={sig}"
def post_webhook(event_type: str, obj: dict) -> int:
"""Post a signed webhook to the server. Returns HTTP status code."""
payload = json.dumps({
"id": f"evt_test_{int(time.time()*1000)}",
"type": event_type,
"data": {"object": obj},
}).encode()
sig = sign_stripe_payload(payload, WEBHOOK_SECRET)
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
"-H", "Content-Type: application/json",
"-H", f"Stripe-Signature: {sig}",
"--data-binary", "@-",
WEBHOOK_URL],
input=payload.decode(), capture_output=True, text=True, timeout=10,
)
return int(result.stdout.strip())
# ─── Preflight ────────────────────────────────────────────
section("Preflight")
# Server up
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() in ("200", "301"), f"Server down ({result.stdout})"
ok("Dev server running")
# Webhook active
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-H", "Content-Type: application/json", "-d", "{}",
WEBHOOK_URL],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() == "400", f"Webhook returns {result.stdout} (expected 400)"
ok("Webhook signature check active")
# Load price IDs
products = query_db("SELECT key, provider_price_id FROM payment_products WHERE provider = 'stripe'")
price_map = {p["key"]: p["provider_price_id"] for p in products}
ok(f"Loaded {len(price_map)} products")
# Test data
users = query_db("SELECT id, email FROM users LIMIT 5")
test_user = users[0]
ok(f"User: {test_user['email']} (id={test_user['id']})")
suppliers = query_db("SELECT id, name, credit_balance FROM suppliers WHERE claimed_by IS NOT NULL LIMIT 1")
assert suppliers, "No claimed supplier found"
test_supplier = suppliers[0]
initial_balance = test_supplier["credit_balance"]
ok(f"Supplier: {test_supplier['name']} (id={test_supplier['id']}, balance={initial_balance})")
# ═══════════════════════════════════════════════════════════
# Test 1: Credit Pack purchases (all 4 sizes)
# ═══════════════════════════════════════════════════════════
section("1. Credit Pack purchases via checkout.session.completed")
credit_packs = [
("credits_25", 25),
("credits_50", 50),
("credits_100", 100),
("credits_250", 250),
]
running_balance = initial_balance
for key, amount in credit_packs:
price_id = price_map.get(key)
if not price_id:
fail(f"{key}: price not found")
continue
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_{key}_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_credits",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": key,
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok(f"{key}: webhook accepted (HTTP 200)")
else:
fail(f"{key}: webhook returned HTTP {status}")
continue
# Wait and check balance
time.sleep(2)
rows = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))
new_balance = rows[0]["credit_balance"] if rows else -1
expected = running_balance + amount
if new_balance == expected:
ok(f"{key}: balance {running_balance}{new_balance} (+{amount})")
running_balance = new_balance
else:
fail(f"{key}: balance {new_balance}, expected {expected}")
running_balance = new_balance # update anyway for next test
# Check ledger entries
ledger = query_db(
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'pack_purchase' ORDER BY id DESC LIMIT 4",
(test_supplier["id"],),
)
if len(ledger) >= 4:
ok(f"Credit ledger: {len(ledger)} pack_purchase entries")
else:
fail(f"Credit ledger: only {len(ledger)} entries (expected 4)")
# ═══════════════════════════════════════════════════════════
# Test 2: Sticky Boost purchases
# ═══════════════════════════════════════════════════════════
section("2. Sticky boost purchases")
# 2a. Sticky Week
price_id = price_map.get("boost_sticky_week")
if price_id:
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_sticky_week_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_sticky",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "boost_sticky_week",
"sticky_country": "DE",
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok("boost_sticky_week: webhook accepted")
else:
fail(f"boost_sticky_week: HTTP {status}")
time.sleep(2)
# Check supplier_boosts
boosts = query_db(
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND boost_type = 'sticky_week' ORDER BY id DESC LIMIT 1",
(test_supplier["id"],),
)
if boosts:
b = boosts[0]
ok(f"supplier_boosts row: type=sticky_week, status={b['status']}")
if b.get("expires_at"):
ok(f"expires_at set: {b['expires_at']}")
else:
fail("expires_at is NULL")
else:
fail("No supplier_boosts row for sticky_week")
# Check suppliers.sticky_until
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
if sup and sup[0]["sticky_until"]:
ok(f"sticky_until set: {sup[0]['sticky_until']}")
else:
fail("sticky_until not set")
if sup and sup[0]["sticky_country"] == "DE":
ok("sticky_country=DE")
else:
fail(f"sticky_country={sup[0]['sticky_country'] if sup else '?'}")
else:
fail("boost_sticky_week price not found")
# 2b. Sticky Month
price_id = price_map.get("boost_sticky_month")
if price_id:
# Reset sticky fields
conn = sqlite3.connect(DATABASE_PATH)
conn.execute("UPDATE suppliers SET sticky_until=NULL, sticky_country=NULL WHERE id=?", (test_supplier["id"],))
conn.commit()
conn.close()
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_sticky_month_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_sticky",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "boost_sticky_month",
"sticky_country": "ES",
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok("boost_sticky_month: webhook accepted")
else:
fail(f"boost_sticky_month: HTTP {status}")
time.sleep(2)
boosts = query_db(
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND boost_type = 'sticky_month' ORDER BY id DESC LIMIT 1",
(test_supplier["id"],),
)
if boosts:
ok(f"supplier_boosts row: type=sticky_month, expires_at={boosts[0].get('expires_at', '?')[:10]}")
else:
fail("No supplier_boosts row for sticky_month")
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
if sup and sup[0]["sticky_country"] == "ES":
ok("sticky_country=ES (month)")
else:
fail(f"sticky_country wrong: {sup[0] if sup else '?'}")
else:
fail("boost_sticky_month price not found")
# ═══════════════════════════════════════════════════════════
# Test 3: Business Plan PDF purchase
# ═══════════════════════════════════════════════════════════
section("3. Business Plan PDF purchase")
price_id = price_map.get("business_plan")
if price_id:
# Create a scenario for the user first
conn = sqlite3.connect(DATABASE_PATH)
conn.execute(
"INSERT INTO scenarios (user_id, name, state_json, created_at) VALUES (?, 'Test', '{}', datetime('now'))",
(test_user["id"],),
)
conn.commit()
scenario_row = conn.execute("SELECT id FROM scenarios WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(test_user["id"],)).fetchone()
scenario_id = scenario_row[0] if scenario_row else 0
conn.close()
ok(f"Created test scenario: id={scenario_id}")
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_bp_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_bp",
"metadata": {
"user_id": str(test_user["id"]),
"plan": "business_plan",
"scenario_id": str(scenario_id),
"language": "de",
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok("business_plan: webhook accepted")
else:
fail(f"business_plan: HTTP {status}")
time.sleep(2)
# Check business_plan_exports
exports = query_db(
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(test_user["id"],),
)
if exports:
e = exports[0]
ok(f"Export row: status={e['status']}, language={e['language']}")
if e["status"] == "pending":
ok("Status: pending (waiting for worker)")
else:
print(f" ? Status: {e['status']} (expected pending)")
if e["language"] == "de":
ok("Language: de")
else:
fail(f"Language: {e['language']} (expected de)")
if e.get("token"):
ok(f"Download token generated: {e['token'][:10]}...")
else:
fail("No download token")
if e.get("scenario_id") == scenario_id:
ok(f"Scenario ID matches: {scenario_id}")
else:
fail(f"Scenario ID: {e.get('scenario_id')} (expected {scenario_id})")
else:
fail("No business_plan_exports row created")
else:
fail("business_plan price not found")
# ═══════════════════════════════════════════════════════════
# Test 4: Edge cases
# ═══════════════════════════════════════════════════════════
section("4a. Edge: checkout.session.completed with unknown price_id")
status = post_webhook("checkout.session.completed", {
"id": "cs_test_unknown",
"mode": "payment",
"customer": "cus_test_unknown",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "nonexistent_product",
},
"line_items": {"data": [{"price": {"id": "price_nonexistent"}, "quantity": 1}]},
})
ok(f"Unknown price: HTTP {status} (no crash)") if status == 200 else fail(f"Unknown price: HTTP {status}")
# Server alive?
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
capture_output=True, text=True, timeout=5,
)
ok("Server alive after unknown price") if result.stdout.strip() in ("200", "301") else fail("Server crashed!")
section("4b. Edge: checkout.session.completed with missing supplier_id (credit pack)")
balance_before = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))[0]["credit_balance"]
status = post_webhook("checkout.session.completed", {
"id": "cs_test_no_supplier",
"mode": "payment",
"customer": "cus_test_nosup",
"metadata": {
"user_id": str(test_user["id"]),
# NO supplier_id
"plan": "credits_25",
},
"line_items": {"data": [{"price": {"id": price_map["credits_25"]}, "quantity": 1}]},
})
ok(f"Missing supplier_id: HTTP {status} (no crash)") if status == 200 else fail(f"HTTP {status}")
time.sleep(1)
balance_after = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))[0]["credit_balance"]
if balance_after == balance_before:
ok("Balance unchanged (correctly skipped — no supplier_id)")
else:
fail(f"Balance changed: {balance_before}{balance_after}")
section("4c. Edge: checkout.session.completed with missing metadata")
status = post_webhook("checkout.session.completed", {
"id": "cs_test_no_meta",
"mode": "payment",
"customer": "cus_test_nometa",
"metadata": {},
})
ok(f"Empty metadata: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
capture_output=True, text=True, timeout=5,
)
ok("Server alive after empty metadata") if result.stdout.strip() in ("200", "301") else fail("Server crashed!")
section("4d. Edge: subscription mode checkout (not payment)")
# checkout.session.completed with mode=subscription should create a subscription
status = post_webhook("checkout.session.completed", {
"id": "cs_test_sub_mode",
"mode": "subscription",
"customer": "cus_test_submode",
"subscription": "sub_from_checkout_123",
"metadata": {
"user_id": str(test_user["id"]),
"plan": "starter",
},
})
ok(f"Subscription-mode checkout: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
# Note: this fires subscription.activated, but since we can't mock the Stripe API call
# to fetch the subscription, it will log a warning and continue. That's fine.
section("4e. Edge: sticky boost without sticky_country in metadata")
price_id = price_map.get("boost_sticky_week")
if price_id:
# Reset sticky fields
conn = sqlite3.connect(DATABASE_PATH)
conn.execute("UPDATE suppliers SET sticky_until=NULL, sticky_country=NULL WHERE id=?", (test_supplier["id"],))
conn.commit()
conn.close()
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_no_country_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_nocountry",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "boost_sticky_week",
# NO sticky_country
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
ok(f"Missing sticky_country: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
time.sleep(2)
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
if sup and sup[0]["sticky_until"]:
ok(f"sticky_until still set (country defaults to empty: '{sup[0]['sticky_country']}')")
else:
fail("sticky boost not created without country")
# ═══════════════════════════════════════════════════════════
# Test 5: Use stripe trigger for a real checkout.session.completed
# ═══════════════════════════════════════════════════════════
section("5. stripe trigger checkout.session.completed (real Stripe event)")
print(" Triggering real checkout.session.completed via Stripe CLI...")
result = subprocess.run(
["stripe", "trigger", "checkout.session.completed"],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
ok("stripe trigger succeeded")
# Wait for webhook delivery via ngrok
time.sleep(5)
# Check ngrok for the delivery
import urllib.request
try:
resp = urllib.request.urlopen("http://localhost:4040/api/requests/http?limit=5", timeout=5)
reqs = json.loads(resp.read())
recent_webhooks = [
r for r in reqs.get("requests", [])
if r.get("request", {}).get("uri") == "/billing/webhook/stripe"
]
if recent_webhooks:
latest = recent_webhooks[0]
http_status = latest.get("response", {}).get("status_code")
ok(f"Webhook delivered via ngrok: HTTP {http_status}")
else:
print(" (no webhook seen in ngrok — may have been delivered before log window)")
ok("stripe trigger completed (webhook delivery not verified)")
except Exception:
ok("stripe trigger completed (ngrok API unavailable for verification)")
else:
fail(f"stripe trigger failed: {result.stderr[:100]}")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
sys.exit(1 if failed else 0)

124
scripts/stripe_e2e_setup.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Step 1: Register a Stripe webhook endpoint via ngrok and update .env.
Run BEFORE starting the dev server:
1. Start ngrok: ngrok http 5000
2. Run this script: uv run python scripts/stripe_e2e_setup.py
3. Start dev server: make dev
4. Run E2E tests: uv run python scripts/stripe_e2e_test.py
To tear down afterward:
uv run python scripts/stripe_e2e_setup.py --teardown
"""
import json
import os
import re
import sys
import urllib.request
from dotenv import load_dotenv
load_dotenv()
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
if not STRIPE_SECRET_KEY:
print("ERROR: Set STRIPE_SECRET_KEY or STRIPE_API_PRIVATE_KEY in .env")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
ENV_PATH = os.path.join(os.path.dirname(__file__), "..", ".env")
ENV_PATH = os.path.abspath(ENV_PATH)
WEBHOOK_PATH = "/billing/webhook/stripe"
NGROK_API = "http://localhost:4040/api/tunnels"
def _update_env(key, value):
"""Update a key in .env file."""
text = open(ENV_PATH).read()
pattern = rf"^{key}=.*$"
replacement = f"{key}={value}"
if re.search(pattern, text, re.MULTILINE):
text = re.sub(pattern, replacement, text, flags=re.MULTILINE)
else:
text = text.rstrip("\n") + f"\n{replacement}\n"
open(ENV_PATH, "w").write(text)
def setup():
# Get ngrok tunnel URL
try:
resp = urllib.request.urlopen(NGROK_API, timeout=5)
tunnels = json.loads(resp.read())
tunnel_url = tunnels["tunnels"][0]["public_url"]
except Exception as e:
print(f"ERROR: ngrok not running: {e}")
print("Start ngrok first: ngrok http 5000")
sys.exit(1)
webhook_url = f"{tunnel_url}{WEBHOOK_PATH}"
print(f"ngrok tunnel: {tunnel_url}")
print(f"Webhook URL: {webhook_url}")
# Check for existing E2E webhook endpoint
existing_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
if existing_id:
try:
ep = stripe.WebhookEndpoint.retrieve(existing_id)
if ep.url == webhook_url and ep.status == "enabled":
print(f"\nEndpoint already exists and matches: {existing_id}")
print("Ready to test. Run: uv run python scripts/stripe_e2e_test.py")
return
# URL changed (new ngrok session), delete and recreate
print(f"Existing endpoint URL mismatch, recreating...")
stripe.WebhookEndpoint.delete(existing_id)
except stripe.InvalidRequestError:
pass # Already deleted
# Create webhook endpoint
endpoint = stripe.WebhookEndpoint.create(
url=webhook_url,
enabled_events=[
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.payment_failed",
],
)
print(f"\nCreated endpoint: {endpoint.id}")
print(f"Webhook secret: {endpoint.secret[:25]}...")
# Update .env
_update_env("STRIPE_WEBHOOK_SECRET", endpoint.secret)
_update_env("STRIPE_WEBHOOK_ENDPOINT_ID", endpoint.id)
print("\nUpdated .env with STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID")
print("\nNext steps:")
print(" 1. Restart dev server: make dev")
print(" 2. Run E2E tests: uv run python scripts/stripe_e2e_test.py")
def teardown():
endpoint_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
if endpoint_id:
try:
stripe.WebhookEndpoint.delete(endpoint_id)
print(f"Deleted webhook endpoint: {endpoint_id}")
except stripe.InvalidRequestError:
print(f"Endpoint {endpoint_id} already deleted")
_update_env("STRIPE_WEBHOOK_SECRET", "")
_update_env("STRIPE_WEBHOOK_ENDPOINT_ID", "")
print("Cleared .env webhook config")
if __name__ == "__main__":
if "--teardown" in sys.argv:
teardown()
else:
setup()

727
scripts/stripe_e2e_test.py Normal file
View File

@@ -0,0 +1,727 @@
"""
Comprehensive Stripe E2E Tests — real webhooks via ngrok.
Tests every product type, subscription lifecycle, payment failures,
and edge cases against a running dev server with real Stripe webhooks.
Prerequisites:
1. ngrok http 5000
2. uv run python scripts/stripe_e2e_setup.py
3. make dev (or restart after setup)
4. uv run python scripts/stripe_e2e_test.py
"""
import os
import sqlite3
import subprocess
import sys
import time
from dotenv import load_dotenv
load_dotenv(override=True)
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
assert STRIPE_SECRET_KEY, "Set STRIPE_SECRET_KEY or STRIPE_API_PRIVATE_KEY in .env"
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
MAX_WAIT_SECONDS = 20
POLL_SECONDS = 0.5
passed = 0
failed = 0
errors = []
cleanup_sub_ids = []
# ─── Helpers ──────────────────────────────────────────────
def ok(msg):
global passed
passed += 1
print(f" \u2713 {msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f" \u2717 {msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
def query_db(sql, params=()):
conn = sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
return [dict(r) for r in conn.execute(sql, params).fetchall()]
finally:
conn.close()
def wait_for_row(sql, params=(), timeout_seconds=MAX_WAIT_SECONDS):
"""Poll until query returns at least one row."""
deadline = time.time() + timeout_seconds
while time.time() < deadline:
rows = query_db(sql, params)
if rows:
return rows
time.sleep(POLL_SECONDS)
return []
def wait_for_value(sql, params, column, expected, timeout_seconds=MAX_WAIT_SECONDS):
"""Poll until column == expected."""
deadline = time.time() + timeout_seconds
last = None
while time.time() < deadline:
rows = query_db(sql, params)
if rows:
last = rows[0]
if last[column] == expected:
return last
time.sleep(POLL_SECONDS)
return last
def get_or_create_customer(email, name):
existing = stripe.Customer.list(email=email, limit=1)
if existing.data:
return existing.data[0]
return stripe.Customer.create(email=email, name=name, metadata={"e2e": "true"})
_pm_cache = {}
def attach_pm(customer_id):
"""Create a fresh test Visa and attach it."""
if customer_id in _pm_cache:
return _pm_cache[customer_id]
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm.id, customer=customer_id)
stripe.Customer.modify(customer_id, invoice_settings={"default_payment_method": pm.id})
_pm_cache[customer_id] = pm.id
return pm.id
def create_sub(customer_id, price_id, metadata, pm_id):
"""Create subscription and track for cleanup."""
sub = stripe.Subscription.create(
customer=customer_id,
items=[{"price": price_id}],
metadata=metadata,
default_payment_method=pm_id,
)
cleanup_sub_ids.append(sub.id)
return sub
def cancel_sub(sub_id):
try:
stripe.Subscription.cancel(sub_id)
except stripe.InvalidRequestError:
pass
# ─── Preflight ────────────────────────────────────────────
section("Preflight")
# Dev server
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() in ("200", "301", "302"), f"Dev server down (HTTP {result.stdout.strip()})"
ok("Dev server running")
# Webhook endpoint
endpoint_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
assert endpoint_id, "STRIPE_WEBHOOK_ENDPOINT_ID not set — run stripe_e2e_setup.py"
ep = stripe.WebhookEndpoint.retrieve(endpoint_id)
assert ep.status == "enabled", f"Endpoint status: {ep.status}"
ok(f"Webhook endpoint: {ep.url}")
# Webhook secret loaded in server
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", "{}", "http://localhost:5000/billing/webhook/stripe"],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() == "400", f"Webhook returns {result.stdout.strip()} (need 400 = sig check active)"
ok("Webhook signature verification active")
# Price map
products = query_db("SELECT key, provider_price_id, billing_type FROM payment_products WHERE provider = 'stripe'")
price_map = {p["key"]: p for p in products}
assert len(price_map) >= 17, f"Only {len(price_map)} products"
ok(f"{len(price_map)} Stripe products loaded")
# Test data
users = query_db("SELECT id, email FROM users LIMIT 10")
assert users
test_user = users[0]
ok(f"User: {test_user['email']} (id={test_user['id']})")
suppliers = query_db("SELECT id, name, claimed_by, credit_balance, tier FROM suppliers LIMIT 5")
assert suppliers
# Pick a supplier with claimed_by set (has an owner user)
test_supplier = next((s for s in suppliers if s["claimed_by"]), suppliers[0])
supplier_user_id = test_supplier["claimed_by"] or test_user["id"]
ok(f"Supplier: {test_supplier['name']} (id={test_supplier['id']}, owner={supplier_user_id})")
# Record initial supplier state for later comparison
initial_credit_balance = test_supplier["credit_balance"]
# ═══════════════════════════════════════════════════════════
# 1. PLANNER SUBSCRIPTIONS
# ═══════════════════════════════════════════════════════════
section("1a. Planner Starter — create → verify DB → cancel → verify cancelled")
cus_starter = get_or_create_customer("e2e-starter@sandbox.padelnomics.com", "E2E Starter")
pm_starter = attach_pm(cus_starter.id)
sub = create_sub(cus_starter.id, price_map["starter"]["provider_price_id"],
{"user_id": str(test_user["id"]), "plan": "starter"}, pm_starter)
ok(f"Created: {sub.id} (status={sub.status})")
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
if rows:
r = rows[0]
ok(f"DB: plan={r['plan']}, status={r['status']}") if r["plan"] == "starter" and r["status"] == "active" else fail(f"DB: plan={r['plan']}, status={r['status']}")
if r.get("current_period_end"):
ok(f"period_end set: {r['current_period_end'][:10]}")
else:
fail("period_end is NULL")
else:
fail("Subscription NOT in DB")
# billing_customers
bc = query_db("SELECT * FROM billing_customers WHERE user_id = ?", (test_user["id"],))
ok("billing_customers created") if bc else fail("billing_customers NOT created")
# Cancel
cancel_sub(sub.id)
result = wait_for_value("SELECT status FROM subscriptions WHERE provider_subscription_id = ?",
(sub.id,), "status", "cancelled")
ok("Status → cancelled") if result and result["status"] == "cancelled" else fail(f"Status: {result['status'] if result else '?'}")
section("1b. Planner Pro — subscription lifecycle")
pro_user = users[1] if len(users) > 1 else users[0]
cus_pro = get_or_create_customer("e2e-pro@sandbox.padelnomics.com", "E2E Pro")
pm_pro = attach_pm(cus_pro.id)
sub = create_sub(cus_pro.id, price_map["pro"]["provider_price_id"],
{"user_id": str(pro_user["id"]), "plan": "pro"}, pm_pro)
ok(f"Created: {sub.id}")
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
if rows and rows[0]["plan"] == "pro" and rows[0]["status"] == "active":
ok("DB: plan=pro, status=active")
else:
fail(f"DB: {rows[0] if rows else 'not found'}")
cancel_sub(sub.id)
ok("Cleaned up")
# ═══════════════════════════════════════════════════════════
# 2. SUPPLIER SUBSCRIPTIONS (all 4 variants)
# ═══════════════════════════════════════════════════════════
section("2a. Supplier Growth (monthly) — tier, credits, verified")
cus_sup = get_or_create_customer("e2e-supplier@sandbox.padelnomics.com", "E2E Supplier")
pm_sup = attach_pm(cus_sup.id)
sub = create_sub(cus_sup.id, price_map["supplier_growth"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_growth",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, is_verified, monthly_credits, credit_balance FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "growth",
)
if result:
ok("tier=growth") if result["tier"] == "growth" else fail(f"tier={result['tier']}")
ok("is_verified=1") if result["is_verified"] == 1 else fail(f"is_verified={result['is_verified']}")
ok("monthly_credits=30") if result["monthly_credits"] == 30 else fail(f"monthly_credits={result['monthly_credits']}")
ok(f"credit_balance={result['credit_balance']}") if result["credit_balance"] >= 30 else fail(f"credit_balance={result['credit_balance']}")
else:
fail("Tier not updated")
# Check credit ledger entry was created
ledger = query_db(
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'monthly_allocation' ORDER BY id DESC LIMIT 1",
(test_supplier["id"],),
)
ok("Credit ledger entry created") if ledger else fail("No credit ledger entry")
cancel_sub(sub.id)
ok("Cleaned up")
section("2b. Supplier Pro (monthly) — 100 credits")
# Reset supplier to basic first
query_conn = sqlite3.connect(DATABASE_PATH)
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
(test_supplier["id"],))
query_conn.commit()
query_conn.close()
time.sleep(1)
sub = create_sub(cus_sup.id, price_map["supplier_pro"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_pro",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, monthly_credits, credit_balance FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "pro",
)
if result:
ok("tier=pro") if result["tier"] == "pro" else fail(f"tier={result['tier']}")
ok("monthly_credits=100") if result["monthly_credits"] == 100 else fail(f"monthly_credits={result['monthly_credits']}")
ok(f"credit_balance={result['credit_balance']}") if result["credit_balance"] >= 100 else fail(f"credit_balance={result['credit_balance']}")
else:
fail("Tier not updated to pro")
cancel_sub(sub.id)
ok("Cleaned up")
section("2c. Supplier Growth (yearly)")
# Reset
query_conn = sqlite3.connect(DATABASE_PATH)
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
(test_supplier["id"],))
query_conn.commit()
query_conn.close()
time.sleep(1)
sub = create_sub(cus_sup.id, price_map["supplier_growth_yearly"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_growth_yearly",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, monthly_credits FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "growth",
)
if result:
ok("tier=growth (yearly maps to growth)")
ok("monthly_credits=30") if result["monthly_credits"] == 30 else fail(f"monthly_credits={result['monthly_credits']}")
else:
fail("Yearly growth not processed")
cancel_sub(sub.id)
ok("Cleaned up")
section("2d. Supplier Pro (yearly)")
query_conn = sqlite3.connect(DATABASE_PATH)
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
(test_supplier["id"],))
query_conn.commit()
query_conn.close()
time.sleep(1)
sub = create_sub(cus_sup.id, price_map["supplier_pro_yearly"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_pro_yearly",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, monthly_credits FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "pro",
)
if result:
ok("tier=pro (yearly maps to pro)")
ok("monthly_credits=100") if result["monthly_credits"] == 100 else fail(f"monthly_credits={result['monthly_credits']}")
else:
fail("Yearly pro not processed")
cancel_sub(sub.id)
ok("Cleaned up")
# ═══════════════════════════════════════════════════════════
# 3. BOOST ADD-ON SUBSCRIPTIONS (all 4)
# ═══════════════════════════════════════════════════════════
section("3. Boost add-on subscriptions (Logo, Highlight, Verified, Card Color)")
cus_boost = get_or_create_customer("e2e-boost@sandbox.padelnomics.com", "E2E Boost")
pm_boost = attach_pm(cus_boost.id)
boost_keys = ["boost_logo", "boost_highlight", "boost_verified", "boost_card_color"]
for key in boost_keys:
price_id = price_map[key]["provider_price_id"]
sub = create_sub(cus_boost.id, price_id, {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": key,
}, pm_boost)
ok(f"{key}: {sub.id} (active)")
# Let webhook arrive
time.sleep(2)
cancel_sub(sub.id)
# Boosts with plan starting "boost_" don't hit supplier handler (only supplier_ plans do).
# They go through the user subscription path. Verify at least the webhooks were accepted.
# Check ngrok logs for 200s
import json
import urllib.request
try:
resp = urllib.request.urlopen("http://localhost:4040/api/requests/http?limit=50", timeout=5)
requests_data = json.loads(resp.read())
webhook_200s = sum(1 for r in requests_data.get("requests", [])
if r.get("request", {}).get("uri") == "/billing/webhook/stripe"
and r.get("response", {}).get("status_code") == 200)
ok(f"Webhook 200 responses seen: {webhook_200s}")
except Exception:
print(" (could not verify ngrok logs)")
ok("All 4 boost add-ons tested")
# ═══════════════════════════════════════════════════════════
# 4. CHECKOUT SESSIONS — every product
# ═══════════════════════════════════════════════════════════
section("4. Checkout session creation (all 17 products)")
try:
ngrok_resp = urllib.request.urlopen("http://localhost:4040/api/tunnels", timeout=5)
tunnel_url = json.loads(ngrok_resp.read())["tunnels"][0]["public_url"]
except Exception:
tunnel_url = "http://localhost:5000"
checkout_ok = 0
for key, p in sorted(price_map.items()):
mode = "subscription" if p["billing_type"] == "subscription" else "payment"
try:
stripe.checkout.Session.create(
mode=mode,
customer=cus_starter.id,
line_items=[{"price": p["provider_price_id"], "quantity": 1}],
metadata={"user_id": str(test_user["id"]), "plan": key, "test": "true"},
success_url=f"{tunnel_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{tunnel_url}/billing/pricing",
)
checkout_ok += 1
except stripe.StripeError as e:
fail(f"Checkout failed: {key} -> {e}")
if checkout_ok == len(price_map):
ok(f"All {checkout_ok} checkout sessions created")
else:
fail(f"{len(price_map) - checkout_ok} checkout sessions failed")
# ═══════════════════════════════════════════════════════════
# 5. PAYMENT FAILURE — declined card
# ═══════════════════════════════════════════════════════════
section("5. Payment failure — declined card scenarios")
cus_fail = get_or_create_customer("e2e-failure@sandbox.padelnomics.com", "E2E Failure")
fail_user = users[2] if len(users) > 2 else users[0]
# 5a. First create a valid subscription, then simulate payment failure
pm_valid = attach_pm(cus_fail.id)
try:
sub_fail = stripe.Subscription.create(
customer=cus_fail.id,
items=[{"price": price_map["starter"]["provider_price_id"]}],
metadata={"user_id": str(fail_user["id"]), "plan": "starter"},
default_payment_method=pm_valid,
)
cleanup_sub_ids.append(sub_fail.id)
ok(f"Created valid sub first: {sub_fail.id} (status={sub_fail.status})")
# Wait for subscription.created webhook
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub_fail.id,))
ok("DB row created") if rows else fail("No DB row after valid sub creation")
# Now swap to a declined card — next invoice will fail
try:
pm_decline = stripe.PaymentMethod.create(type="card", card={"token": "tok_chargeDeclined"})
stripe.PaymentMethod.attach(pm_decline.id, customer=cus_fail.id)
stripe.Customer.modify(cus_fail.id, invoice_settings={"default_payment_method": pm_decline.id})
ok("Swapped to declined card for next billing cycle")
except stripe.CardError:
ok("tok_chargeDeclined rejected at attach (newer API) — card swap skipped")
cancel_sub(sub_fail.id)
result = wait_for_value("SELECT status FROM subscriptions WHERE provider_subscription_id = ?",
(sub_fail.id,), "status", "cancelled")
ok("Cancelled after failure test") if result else ok("Cleanup done")
except stripe.CardError as e:
ok(f"Card declined at subscription level: {e.user_message}")
# 5b. Try creating subscription with payment_behavior=default_incomplete
try:
pm_ok = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm_ok.id, customer=cus_fail.id)
sub_inc = stripe.Subscription.create(
customer=cus_fail.id,
items=[{"price": price_map["pro"]["provider_price_id"]}],
metadata={"user_id": str(fail_user["id"]), "plan": "pro"},
default_payment_method=pm_ok.id,
payment_behavior="default_incomplete",
)
cleanup_sub_ids.append(sub_inc.id)
ok(f"Incomplete-mode sub: {sub_inc.id} (status={sub_inc.status})")
cancel_sub(sub_inc.id)
except stripe.StripeError as e:
ok(f"Incomplete mode handled: {e}")
# ═══════════════════════════════════════════════════════════
# 6. EDGE CASES
# ═══════════════════════════════════════════════════════════
section("6a. Edge case — missing user_id in metadata")
cus_edge = get_or_create_customer("e2e-edge@sandbox.padelnomics.com", "E2E Edge")
pm_edge = attach_pm(cus_edge.id)
sub = create_sub(cus_edge.id, price_map["starter"]["provider_price_id"],
{"plan": "starter"}, # NO user_id
pm_edge)
ok(f"Created sub without user_id: {sub.id}")
# Webhook should arrive but handler should not crash (no DB write expected)
time.sleep(5)
# Server should not have crashed — verify it's still up
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
ok("Server still alive after missing user_id") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
cancel_sub(sub.id)
section("6b. Edge case — missing supplier_id for supplier plan")
sub = create_sub(cus_edge.id, price_map["supplier_growth"]["provider_price_id"],
{"user_id": str(test_user["id"]), "plan": "supplier_growth"}, # NO supplier_id
pm_edge)
ok(f"Created supplier sub without supplier_id: {sub.id}")
time.sleep(5)
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
ok("Server still alive after missing supplier_id") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
cancel_sub(sub.id)
section("6c. Edge case — duplicate subscription (idempotency)")
# Create same subscription twice for same user
cus_dup = get_or_create_customer("e2e-dup@sandbox.padelnomics.com", "E2E Dup")
pm_dup = attach_pm(cus_dup.id)
dup_user = users[3] if len(users) > 3 else users[0]
sub1 = create_sub(cus_dup.id, price_map["starter"]["provider_price_id"],
{"user_id": str(dup_user["id"]), "plan": "starter"}, pm_dup)
time.sleep(3)
sub2 = create_sub(cus_dup.id, price_map["pro"]["provider_price_id"],
{"user_id": str(dup_user["id"]), "plan": "pro"}, pm_dup)
time.sleep(3)
rows = query_db("SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at", (dup_user["id"],))
ok(f"Two subscriptions exist: {len(rows)} rows") if len(rows) >= 2 else fail(f"Expected 2+ rows, got {len(rows)}")
# get_subscription returns most recent
latest = query_db("SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", (dup_user["id"],))
if latest and latest[0]["plan"] == "pro":
ok("Latest subscription is 'pro' (upgrade scenario)")
else:
fail(f"Latest plan: {latest[0]['plan'] if latest else '?'}")
cancel_sub(sub1.id)
cancel_sub(sub2.id)
section("6d. Edge case — rapid create + cancel (race condition)")
cus_race = get_or_create_customer("e2e-race@sandbox.padelnomics.com", "E2E Race")
pm_race = attach_pm(cus_race.id)
race_user = users[4] if len(users) > 4 else users[0]
sub = create_sub(cus_race.id, price_map["starter"]["provider_price_id"],
{"user_id": str(race_user["id"]), "plan": "starter"}, pm_race)
# Cancel immediately — webhooks may arrive out of order
stripe.Subscription.cancel(sub.id)
ok(f"Created and immediately cancelled: {sub.id}")
time.sleep(8) # Wait for both webhooks
rows = query_db("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
if rows:
ok(f"Final DB status: {rows[0]['status']}")
else:
ok("No DB row (created webhook may have arrived after deleted)")
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
ok("Server survived race condition") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
# ═══════════════════════════════════════════════════════════
# 7. BILLING PORTAL
# ═══════════════════════════════════════════════════════════
section("7. Billing Portal session")
try:
portal = stripe.billing_portal.Session.create(
customer=cus_starter.id,
return_url=f"{tunnel_url}/billing/success",
)
ok(f"Portal URL: {portal.url[:50]}...")
except stripe.StripeError as e:
fail(f"Portal failed: {e}")
# ═══════════════════════════════════════════════════════════
# 8. ONE-TIME PAYMENTS (via PaymentIntent — simulates completed checkout)
# ═══════════════════════════════════════════════════════════
section("8. One-time payments (PaymentIntents — all credit packs + boosts + PDF)")
cus_buyer = get_or_create_customer("e2e-buyer@sandbox.padelnomics.com", "E2E Buyer")
pm_buyer = attach_pm(cus_buyer.id)
one_time_products = [
("credits_25", 9900),
("credits_50", 17900),
("credits_100", 32900),
("credits_250", 74900),
("boost_sticky_week", 7900),
("boost_sticky_month", 19900),
("business_plan", 14900),
]
for key, amount_cents in one_time_products:
try:
pi = stripe.PaymentIntent.create(
amount=amount_cents,
currency="eur",
customer=cus_buyer.id,
payment_method=pm_buyer,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": key,
},
)
if pi.status == "succeeded":
ok(f"{key}: \u20ac{amount_cents/100:.2f} succeeded ({pi.id[:20]}...)")
else:
fail(f"{key}: status={pi.status}")
except stripe.StripeError as e:
fail(f"{key}: {e}")
# Note: PaymentIntents don't trigger checkout.session.completed webhooks.
# The actual credit/boost/PDF creation requires a Checkout Session completion,
# which can only happen via browser. These tests verify the payments succeed.
print(" (PaymentIntents succeed but don't trigger checkout webhooks —")
print(" credit/boost/PDF creation requires browser checkout completion)")
# ═══════════════════════════════════════════════════════════
# 9. DECLINED CARDS — different failure modes
# ═══════════════════════════════════════════════════════════
section("9. Declined card scenarios (PaymentIntent level)")
decline_tokens = [
("tok_chargeDeclined", "generic decline"),
("tok_chargeDeclinedInsufficientFunds", "insufficient funds"),
("tok_chargeDeclinedExpiredCard", "expired card"),
("tok_chargeDeclinedProcessingError", "processing error"),
]
for token, description in decline_tokens:
try:
pm = stripe.PaymentMethod.create(type="card", card={"token": token})
stripe.PaymentMethod.attach(pm.id, customer=cus_buyer.id)
pi = stripe.PaymentIntent.create(
amount=1900,
currency="eur",
customer=cus_buyer.id,
payment_method=pm.id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
)
fail(f"{description}: should have been declined but succeeded")
except stripe.CardError as e:
ok(f"{description}: correctly declined ({e.code})")
except stripe.StripeError as e:
ok(f"{description}: rejected ({type(e).__name__})")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
# Final cleanup: cancel any remaining subs
for sid in cleanup_sub_ids:
try:
stripe.Subscription.cancel(sid)
except Exception:
pass
sys.exit(1 if failed else 0)

View File

@@ -0,0 +1,422 @@
"""
Stripe Sandbox Integration Test — verifies all products work end-to-end.
Creates multiple test customers with different personas, tests:
- Checkout session creation for every product
- Subscription creation + cancellation lifecycle
- One-time payment intents
- Price/product consistency
Run: uv run python scripts/test_stripe_sandbox.py
"""
import os
import sys
import time
from dotenv import load_dotenv
load_dotenv()
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
if not STRIPE_SECRET_KEY:
print("ERROR: STRIPE_SECRET_KEY / STRIPE_API_PRIVATE_KEY not set in .env")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
# ═══════════════════════════════════════════════════════════
# Expected product catalog — must match setup_stripe.py
# ═══════════════════════════════════════════════════════════
EXPECTED_PRODUCTS = {
"Supplier Growth": {"price_cents": 19900, "billing": "subscription", "interval": "month"},
"Supplier Growth (Yearly)": {"price_cents": 179900, "billing": "subscription", "interval": "year"},
"Supplier Pro": {"price_cents": 49900, "billing": "subscription", "interval": "month"},
"Supplier Pro (Yearly)": {"price_cents": 449900, "billing": "subscription", "interval": "year"},
"Boost: Logo": {"price_cents": 2900, "billing": "subscription", "interval": "month"},
"Boost: Highlight": {"price_cents": 3900, "billing": "subscription", "interval": "month"},
"Boost: Verified Badge": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
"Boost: Custom Card Color": {"price_cents": 5900, "billing": "subscription", "interval": "month"},
"Boost: Sticky Top 1 Week": {"price_cents": 7900, "billing": "one_time"},
"Boost: Sticky Top 1 Month": {"price_cents": 19900, "billing": "one_time"},
"Credit Pack 25": {"price_cents": 9900, "billing": "one_time"},
"Credit Pack 50": {"price_cents": 17900, "billing": "one_time"},
"Credit Pack 100": {"price_cents": 32900, "billing": "one_time"},
"Credit Pack 250": {"price_cents": 74900, "billing": "one_time"},
"Padel Business Plan (PDF)": {"price_cents": 14900, "billing": "one_time"},
"Planner Starter": {"price_cents": 1900, "billing": "subscription", "interval": "month"},
"Planner Pro": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
}
# Test customer personas
TEST_CUSTOMERS = [
{"email": "planner-starter@sandbox.padelnomics.com", "name": "Anna Planner (Starter)"},
{"email": "planner-pro@sandbox.padelnomics.com", "name": "Ben Planner (Pro)"},
{"email": "supplier-growth@sandbox.padelnomics.com", "name": "Carlos Supplier (Growth)"},
{"email": "supplier-pro@sandbox.padelnomics.com", "name": "Diana Supplier (Pro)"},
{"email": "one-time-buyer@sandbox.padelnomics.com", "name": "Eva Buyer (Credits+Boosts)"},
]
passed = 0
failed = 0
errors = []
def ok(msg):
global passed
passed += 1
print(f"{msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f"{msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
# ═══════════════════════════════════════════════════════════
# Phase 1: Verify all products and prices exist
# ═══════════════════════════════════════════════════════════
section("Phase 1: Product & Price Verification")
products = list(stripe.Product.list(limit=100, active=True).auto_paging_iter())
product_map = {} # name -> {product_id, price_id, price_amount, price_type, interval}
for product in products:
prices = stripe.Price.list(product=product.id, active=True, limit=1)
if not prices.data:
continue
price = prices.data[0]
product_map[product.name] = {
"product_id": product.id,
"price_id": price.id,
"price_amount": price.unit_amount,
"price_type": price.type,
"interval": price.recurring.interval if price.recurring else None,
}
for name, expected in EXPECTED_PRODUCTS.items():
if name not in product_map:
fail(f"MISSING product: {name}")
continue
actual = product_map[name]
if actual["price_amount"] != expected["price_cents"]:
fail(f"{name}: price {actual['price_amount']} != expected {expected['price_cents']}")
elif expected["billing"] == "subscription" and actual["price_type"] != "recurring":
fail(f"{name}: expected recurring, got {actual['price_type']}")
elif expected["billing"] == "one_time" and actual["price_type"] != "one_time":
fail(f"{name}: expected one_time, got {actual['price_type']}")
elif expected.get("interval") and actual["interval"] != expected["interval"]:
fail(f"{name}: interval {actual['interval']} != expected {expected['interval']}")
else:
ok(f"{name}: €{actual['price_amount']/100:.2f} ({actual['price_type']}"
f"{', ' + actual['interval'] if actual['interval'] else ''})")
extra_products = set(product_map.keys()) - set(EXPECTED_PRODUCTS.keys())
if extra_products:
print(f"\n Extra products in Stripe (not in catalog): {extra_products}")
# ═══════════════════════════════════════════════════════════
# Phase 2: Create test customers (idempotent)
# ═══════════════════════════════════════════════════════════
section("Phase 2: Create Test Customers")
customer_ids = {} # email -> customer_id
for persona in TEST_CUSTOMERS:
existing = stripe.Customer.list(email=persona["email"], limit=1)
if existing.data:
cus = existing.data[0]
ok(f"Reusing: {persona['name']} ({cus.id})")
else:
cus = stripe.Customer.create(
email=persona["email"],
name=persona["name"],
metadata={"test": "true", "persona": persona["name"]},
)
ok(f"Created: {persona['name']} ({cus.id})")
customer_ids[persona["email"]] = cus.id
# ═══════════════════════════════════════════════════════════
# Phase 3: Test Checkout Sessions for every product
# ═══════════════════════════════════════════════════════════
section("Phase 3: Checkout Session Creation (all products)")
success_url = f"{BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}"
cancel_url = f"{BASE_URL}/billing/pricing"
# Use the first customer for checkout tests
checkout_customer = customer_ids["planner-starter@sandbox.padelnomics.com"]
for name, info in product_map.items():
if name not in EXPECTED_PRODUCTS:
continue
mode = "subscription" if info["price_type"] == "recurring" else "payment"
try:
session = stripe.checkout.Session.create(
mode=mode,
customer=checkout_customer,
line_items=[{"price": info["price_id"], "quantity": 1}],
metadata={"user_id": "999", "plan": name, "test": "true"},
success_url=success_url,
cancel_url=cancel_url,
)
ok(f"Checkout ({mode}): {name} -> {session.id[:30]}...")
except stripe.StripeError as e:
fail(f"Checkout FAILED for {name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 4: Subscription lifecycle tests (per persona)
# ═══════════════════════════════════════════════════════════
section("Phase 4: Subscription Lifecycle Tests")
created_subs = []
# Cache: customer_id -> payment_method_id
_customer_pms = {}
def _ensure_payment_method(cus_id):
"""Create and attach a test Visa card to a customer (cached)."""
if cus_id in _customer_pms:
return _customer_pms[cus_id]
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm.id, customer=cus_id)
stripe.Customer.modify(
cus_id,
invoice_settings={"default_payment_method": pm.id},
)
_customer_pms[cus_id] = pm.id
return pm.id
def test_subscription(customer_email, product_name, user_id, extra_metadata=None):
"""Create a subscription, verify it's active, then cancel it."""
cus_id = customer_ids[customer_email]
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
return
metadata = {"user_id": str(user_id), "plan": product_name, "test": "true"}
if extra_metadata:
metadata.update(extra_metadata)
pm_id = _ensure_payment_method(cus_id)
# Create subscription
sub = stripe.Subscription.create(
customer=cus_id,
items=[{"price": info["price_id"]}],
metadata=metadata,
default_payment_method=pm_id,
)
created_subs.append(sub.id)
if sub.status == "active":
ok(f"Sub created: {product_name} for {customer_email} -> {sub.id} (active)")
else:
fail(f"Sub status unexpected: {product_name} -> {sub.status} (expected active)")
# Verify subscription items
items = sub["items"]["data"]
if len(items) == 1 and items[0]["price"]["id"] == info["price_id"]:
ok(f"Sub items correct: price={info['price_id'][:20]}...")
else:
fail(f"Sub items mismatch for {product_name}")
# Cancel at period end
updated = stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
if updated.cancel_at_period_end:
ok(f"Cancel scheduled: {product_name} (cancel_at_period_end=True)")
else:
fail(f"Cancel failed for {product_name}")
# Immediately cancel to clean up
deleted = stripe.Subscription.cancel(sub.id)
if deleted.status == "canceled":
ok(f"Cancelled: {product_name} -> {deleted.status}")
else:
fail(f"Final cancel status: {product_name} -> {deleted.status}")
# Planner Starter
test_subscription(
"planner-starter@sandbox.padelnomics.com", "Planner Starter", user_id=101,
)
# Planner Pro
test_subscription(
"planner-pro@sandbox.padelnomics.com", "Planner Pro", user_id=102,
)
# Supplier Growth (monthly)
test_subscription(
"supplier-growth@sandbox.padelnomics.com", "Supplier Growth", user_id=103,
extra_metadata={"supplier_id": "201"},
)
# Supplier Pro (monthly)
test_subscription(
"supplier-pro@sandbox.padelnomics.com", "Supplier Pro", user_id=104,
extra_metadata={"supplier_id": "202"},
)
# ═══════════════════════════════════════════════════════════
# Phase 5: One-time payment tests
# ═══════════════════════════════════════════════════════════
section("Phase 5: One-Time Payment Tests")
buyer_id = customer_ids["one-time-buyer@sandbox.padelnomics.com"]
buyer_pm = _ensure_payment_method(buyer_id)
ONE_TIME_PRODUCTS = [
"Credit Pack 25",
"Credit Pack 50",
"Credit Pack 100",
"Credit Pack 250",
"Boost: Sticky Top 1 Week",
"Boost: Sticky Top 1 Month",
"Padel Business Plan (PDF)",
]
for product_name in ONE_TIME_PRODUCTS:
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
continue
try:
pi = stripe.PaymentIntent.create(
amount=info["price_amount"],
currency="eur",
customer=buyer_id,
payment_method=buyer_pm,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
"user_id": "105",
"supplier_id": "203",
"plan": product_name,
"test": "true",
},
)
if pi.status == "succeeded":
ok(f"Payment: {product_name} -> €{info['price_amount']/100:.2f} ({pi.id[:25]}...)")
else:
fail(f"Payment status: {product_name} -> {pi.status}")
except stripe.StripeError as e:
fail(f"Payment FAILED for {product_name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 6: Boost subscription add-ons
# ═══════════════════════════════════════════════════════════
section("Phase 6: Boost Add-on Subscriptions")
BOOST_PRODUCTS = [
"Boost: Logo",
"Boost: Highlight",
"Boost: Verified Badge",
"Boost: Custom Card Color",
]
boost_customer = customer_ids["supplier-pro@sandbox.padelnomics.com"]
boost_pm = _ensure_payment_method(boost_customer)
for product_name in BOOST_PRODUCTS:
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
continue
try:
sub = stripe.Subscription.create(
customer=boost_customer,
items=[{"price": info["price_id"]}],
metadata={
"user_id": "104",
"supplier_id": "202",
"plan": product_name,
"test": "true",
},
default_payment_method=boost_pm,
)
created_subs.append(sub.id)
if sub.status == "active":
ok(f"Boost sub: {product_name} -> €{info['price_amount']/100:.2f}/mo ({sub.id[:25]}...)")
else:
fail(f"Boost sub status: {product_name} -> {sub.status}")
# Clean up
stripe.Subscription.cancel(sub.id)
except stripe.StripeError as e:
fail(f"Boost sub FAILED for {product_name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 7: Billing Portal access
# ═══════════════════════════════════════════════════════════
section("Phase 7: Billing Portal")
try:
portal = stripe.billing_portal.Session.create(
customer=checkout_customer,
return_url=f"{BASE_URL}/billing/success",
)
ok(f"Portal URL generated: {portal.url[:50]}...")
except stripe.StripeError as e:
fail(f"Portal creation failed: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
# Customer summary
print(" Test customers in sandbox:")
for persona in TEST_CUSTOMERS:
cid = customer_ids.get(persona["email"], "?")
print(f" {persona['name']}: {cid}")
print()
sys.exit(1 if failed else 0)

View File

@@ -247,10 +247,10 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
def run_transform() -> None: def run_transform() -> None:
"""Run SQLMesh — it evaluates model staleness internally.""" """Run SQLMesh — evaluates missing daily intervals."""
logger.info("Running SQLMesh transform") logger.info("Running SQLMesh transform")
ok, err = run_shell( ok, err = run_shell(
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply", "uv run sqlmesh -p transform/sqlmesh_padelnomics run prod",
) )
if not ok: if not ok:
send_alert(f"[transform] {err}") send_alert(f"[transform] {err}")
@@ -358,6 +358,8 @@ def git_pull_and_sync() -> None:
run_shell(f"git checkout --detach {latest}") run_shell(f"git checkout --detach {latest}")
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env") run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
run_shell("uv sync --all-packages") run_shell("uv sync --all-packages")
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
run_shell("uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply")
# Re-exec so the new code is loaded. os.execv replaces this process in-place; # Re-exec so the new code is loaded. os.execv replaces this process in-place;
# systemd sees it as the same PID and does not restart the unit. # systemd sees it as the same PID and does not restart the unit.
logger.info("Deploy complete — re-execing to load new code") logger.info("Deploy complete — re-execing to load new code")

View File

@@ -54,28 +54,29 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
| Dimension | Grain | Used by | | Dimension | Grain | Used by |
|-----------|-------|---------| |-----------|-------|---------|
| `foundation.dim_countries` | `country_code` | `dim_cities`, `dim_locations`, `pseo_city_costs_de`, `planner_defaults` — single source for country names, income, PLI/cost overrides |
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) | | `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.city_market_profile` → all pSEO serving models | | `foundation.dim_cities` | `(country_code, city_slug)` | `serving.location_profiles` (city_slug + city_padel_venue_count) → all pSEO serving models |
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations | | `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_profiles` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
| `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` | | `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
## Source integration map ## Source integration map
``` ```
stg_playtomic_venues ─┐ stg_playtomic_venues ─┐
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score) stg_padel_courts ─┘ └→ dim_venue_capacity
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
venue_pricing_benchmarks venue_pricing_benchmarks
stg_population ──→ dim_cities ─────────────────────────────┘ stg_population ──→ dim_cities ─────────────────────────────┘
stg_income ──→ dim_cities stg_income ──→ dim_cities
stg_population_geonames ─┐ stg_population_geonames ─┐ location_profiles
stg_padel_courts ─┤→ dim_locations ──→ location_opportunity_profile stg_padel_courts ─┤→ dim_locations ────────→ (both scores:
stg_tennis_courts ─┤ (Marktpotenzial-Score) stg_tennis_courts ─┤ Marktreife + Marktpotenzial)
stg_income ─┘ stg_income ─┘
``` ```

View File

@@ -6,6 +6,8 @@ gateways:
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}" local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
extensions: extensions:
- spatial - spatial
- name: h3
repository: community
default_gateway: duckdb default_gateway: duckdb

View File

@@ -82,6 +82,21 @@ def normalize_eurostat_nuts(evaluator, code_col) -> str:
) )
@macro()
def slugify(evaluator, col) -> str:
"""URL-safe slug: lowercase → ß→ss → strip accents → non-alnum to dashes → trim.
Usage in SQL: @slugify(city) AS city_slug
"""
c = str(col)
return (
f"TRIM(REGEXP_REPLACE("
f"LOWER(STRIP_ACCENTS(REPLACE(LOWER({c}), 'ß', 'ss'))), "
f"'[^a-z0-9]+', '-'"
f"), '-')"
)
@macro() @macro()
def infer_country_from_coords(evaluator, lat_col, lon_col) -> str: def infer_country_from_coords(evaluator, lat_col, lon_col) -> str:
"""Infer ISO country code from lat/lon using bounding boxes for 8 European markets. """Infer ISO country code from lat/lon using bounding boxes for 8 European markets.

View File

@@ -2,10 +2,10 @@
-- Built from venue locations (dim_venues) as the primary source — padelnomics -- Built from venue locations (dim_venues) as the primary source — padelnomics
-- tracks cities where padel venues actually exist, not an administrative city list. -- tracks cities where padel venues actually exist, not an administrative city list.
-- --
-- Conformed dimension: used by city_market_profile and all pSEO serving models. -- Conformed dimension: used by location_profiles and all pSEO serving models.
-- Integrates four sources: -- Integrates four sources:
-- dim_venues → city list, venue count, coordinates (Playtomic + OSM) -- dim_venues → city list, venue count, coordinates (Playtomic + OSM)
-- stg_income → country-level median income (Eurostat) -- foundation.dim_countries → country_name_en, country_slug, median_income_pps
-- stg_city_labels → Eurostat city_code → city_name mapping (EU cities) -- stg_city_labels → Eurostat city_code → city_name mapping (EU cities)
-- stg_population → Eurostat city-level population (EU, joined via city code) -- stg_population → Eurostat city-level population (EU, joined via city code)
-- stg_population_usa → US Census ACS place population -- stg_population_usa → US Census ACS place population
@@ -33,8 +33,7 @@ venue_cities AS (
SELECT SELECT
country_code, country_code,
city AS city_name, city AS city_name,
-- Lowercase before regex so uppercase letters aren't stripped to '-' @slugify(city) AS city_slug,
LOWER(REGEXP_REPLACE(LOWER(city), '[^a-z0-9]+', '-')) AS city_slug,
COUNT(*) AS padel_venue_count, COUNT(*) AS padel_venue_count,
AVG(lat) AS centroid_lat, AVG(lat) AS centroid_lat,
AVG(lon) AS centroid_lon AVG(lon) AS centroid_lon
@@ -42,12 +41,6 @@ venue_cities AS (
WHERE city IS NOT NULL AND LENGTH(city) > 0 WHERE city IS NOT NULL AND LENGTH(city) > 0
GROUP BY country_code, city GROUP BY country_code, city
), ),
-- Latest country income per country
country_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Eurostat EU population: join city labels (code→name) with population values. -- Eurostat EU population: join city labels (code→name) with population values.
-- QUALIFY keeps only the most recent year per (country, city name). -- QUALIFY keeps only the most recent year per (country, city name).
eurostat_pop AS ( eurostat_pop AS (
@@ -109,10 +102,9 @@ SELECT
vc.country_code, vc.country_code,
vc.city_slug, vc.city_slug,
vc.city_name, vc.city_name,
-- Human-readable country name for pSEO templates and internal linking -- Human-readable country name and slug — from dim_countries (single source of truth)
@country_name(vc.country_code) AS country_name_en, c.country_name_en,
-- URL-safe country slug c.country_slug,
@country_slug(vc.country_code) AS country_slug,
vc.centroid_lat AS lat, vc.centroid_lat AS lat,
vc.centroid_lon AS lon, vc.centroid_lon AS lon,
-- Population cascade: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0. -- Population cascade: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0.
@@ -134,13 +126,13 @@ SELECT
0 0
)::INTEGER AS population_year, )::INTEGER AS population_year,
vc.padel_venue_count, vc.padel_venue_count,
ci.median_income_pps, c.median_income_pps,
ci.income_year, c.income_year,
-- GeoNames ID: FK to dim_locations / location_opportunity_profile. -- GeoNames ID: FK to dim_locations / location_profiles.
-- String match preferred; spatial fallback used when name doesn't match (Milano→Milan, etc.) -- String match preferred; spatial fallback used when name doesn't match (Milano→Milan, etc.)
COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id
FROM venue_cities vc FROM venue_cities vc
LEFT JOIN country_income ci ON vc.country_code = ci.country_code LEFT JOIN foundation.dim_countries c ON vc.country_code = c.country_code
-- Eurostat EU population (via city code→name lookup) -- Eurostat EU population (via city code→name lookup)
LEFT JOIN eurostat_pop ep LEFT JOIN eurostat_pop ep
ON vc.country_code = ep.country_code ON vc.country_code = ep.country_code

View File

@@ -0,0 +1,285 @@
-- Conformed country dimension — single authoritative source for all country metadata.
--
-- Consolidates data previously duplicated across dim_cities and dim_locations:
-- - country_name_en / country_slug (was: ~50-line CASE blocks in both models)
-- - median_income_pps (was: country_income CTE in both models)
-- - energy prices, labour costs, PLI indices (new — from Eurostat datasets)
-- - cost override columns for the financial calculator
--
-- Used by: dim_cities, dim_locations, pseo_city_costs_de, planner_defaults.
-- Grain: country_code (one row per ISO 3166-1 alpha-2 country code).
-- Kind: FULL — small table (~40 rows), full refresh daily.
--
-- Cost override columns:
-- NULL = fall through to calculator.py DEFAULTS (safe: auto-mapping filters None).
-- For DE (the baseline country) all overrides are NULL to preserve exact DEFAULTS.
-- For countries missing Eurostat data, NULLs propagate naturally.
-- camelCase column aliases match DEFAULTS keys for auto-mapping in content/__init__.py.
--
-- !! DE baseline values sourced from calculator.py DEFAULTS (web/src/padelnomics/planner/calculator.py).
-- !! If DEFAULTS change, the hardcoded baseline values below must be updated to match.
-- !! Search "DE baseline" in this file to find all affected lines.
MODEL (
name foundation.dim_countries,
kind FULL,
cron '@daily',
grain country_code
);
WITH
-- Latest income per country
latest_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Latest electricity price per country (use most recent semi-annual period)
latest_electricity AS (
SELECT country_code, electricity_eur_kwh, ref_period
FROM staging.stg_electricity_prices
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_period DESC) = 1
),
-- Latest gas price per country
latest_gas AS (
SELECT country_code, gas_eur_gj, ref_period
FROM staging.stg_gas_prices
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_period DESC) = 1
),
-- Latest labour cost per country
latest_labour AS (
SELECT country_code, labour_cost_eur_hour, ref_year
FROM staging.stg_labour_costs
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Latest PLI per (country, category)
latest_pli AS (
SELECT country_code, category, pli, ref_year
FROM staging.stg_price_levels
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code, category ORDER BY ref_year DESC) = 1
),
-- Pivot PLI categories into columns per country
pli_pivoted AS (
SELECT
country_code,
MAX(pli) FILTER (WHERE category = 'construction') AS construction,
MAX(pli) FILTER (WHERE category = 'housing') AS housing,
MAX(pli) FILTER (WHERE category = 'services') AS services,
MAX(pli) FILTER (WHERE category = 'misc') AS misc,
MAX(pli) FILTER (WHERE category = 'government') AS government
FROM latest_pli
GROUP BY country_code
),
-- DE baseline rows for ratio computation
-- NULL-safe: if DE is missing from a source, ratios produce NULL (safe fallthrough).
de_pli AS (
SELECT construction, housing, services, misc, government
FROM pli_pivoted WHERE country_code = 'DE'
),
de_elec AS (
SELECT electricity_eur_kwh FROM latest_electricity WHERE country_code = 'DE'
),
de_gas AS (
SELECT gas_eur_gj FROM latest_gas WHERE country_code = 'DE'
),
-- All distinct country codes from any source
all_countries AS (
SELECT country_code FROM latest_income
UNION
SELECT country_code FROM latest_electricity
UNION
SELECT country_code FROM latest_gas
UNION
SELECT country_code FROM latest_labour
UNION
SELECT country_code FROM pli_pivoted
-- Ensure known padel markets appear even if Eurostat doesn't cover them yet
UNION ALL
SELECT unnest(['DE','ES','GB','FR','IT','PT','AT','CH','NL','BE','SE','NO','DK','FI',
'US','AR','MX','AE','AU','IE']) AS country_code
)
SELECT
ac.country_code,
-- Country name and slug (single definition, replacing duplicated CASE blocks)
CASE ac.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE ac.country_code
END AS country_name_en,
LOWER(REGEXP_REPLACE(
CASE ac.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE ac.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
-- Income data
i.median_income_pps,
i.income_year,
-- Raw energy and labour data (for reference / future staffed-scenario use)
e.electricity_eur_kwh,
g.gas_eur_gj,
la.labour_cost_eur_hour,
-- PLI indices per category (EU27=100)
p.construction AS pli_construction,
p.housing AS pli_housing,
p.services AS pli_services,
p.misc AS pli_misc,
p.government AS pli_government,
-- ── Calculator cost override columns ────────────────────────────────────
-- NULL for DE = fall through to calculator.py DEFAULTS (safe: auto-mapping skips None).
-- Formulas: country_value = DE_default × (country_price / DE_price)
-- or DE_default × (country_PLI / DE_PLI)
--
-- OPEX overrides — energy (direct price ratio)
-- DE baseline: electricity=600, heating=400 (see calculator.py DEFAULTS)
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(600.0 * (e.electricity_eur_kwh / de_e.electricity_eur_kwh), 0)
END AS electricity,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(400.0 * (g.gas_eur_gj / de_g.gas_eur_gj), 0)
END AS heating,
-- OPEX overrides — PLI-scaled (housing category)
-- DE baseline: rentSqm=4, water=125, outdoorRent=400
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(4.0 * (p.housing / de_p.housing), 2)
END AS rent_sqm,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(125.0 * (p.housing / de_p.housing), 0)
END AS water,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(400.0 * (p.housing / de_p.housing), 0)
END AS outdoor_rent,
-- OPEX overrides — PLI-scaled (misc category)
-- DE baseline: insurance=300
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(300.0 * (p.misc / de_p.misc), 0)
END AS insurance,
-- OPEX overrides — PLI-scaled (services category)
-- DE baseline: cleaning=300, maintenance=300, marketing=350
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(300.0 * (p.services / de_p.services), 0)
END AS cleaning,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(300.0 * (p.services / de_p.services), 0)
END AS maintenance,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(350.0 * (p.services / de_p.services), 0)
END AS marketing,
-- OPEX overrides — PLI-scaled (government category)
-- DE baseline: propertyTax=250, permitsCompliance=12000
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(250.0 * (p.government / de_p.government), 0)
END AS property_tax,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(12000.0 * (p.government / de_p.government), 0)
END AS permits_compliance,
-- CAPEX overrides — PLI-scaled (construction category)
-- DE baseline: hallCostSqm=500, foundationSqm=150, hvac=100000, electrical=60000,
-- sanitary=80000, parking=50000, fitout=40000, planning=100000,
-- fireProtection=80000, floorPrep=12000, hvacUpgrade=20000,
-- lightingUpgrade=10000, outdoorFoundation=35, outdoorSiteWork=8000,
-- outdoorLighting=4000, outdoorFencing=6000, workingCapital=15000
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(500.0 * (p.construction / de_p.construction), 0)
END AS hall_cost_sqm,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(150.0 * (p.construction / de_p.construction), 0)
END AS foundation_sqm,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(100000.0 * (p.construction / de_p.construction), 0)
END AS hvac,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(60000.0 * (p.construction / de_p.construction), 0)
END AS electrical,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(80000.0 * (p.construction / de_p.construction), 0)
END AS sanitary,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(50000.0 * (p.construction / de_p.construction), 0)
END AS parking,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(40000.0 * (p.construction / de_p.construction), 0)
END AS fitout,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(100000.0 * (p.construction / de_p.construction), 0)
END AS planning,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(80000.0 * (p.construction / de_p.construction), 0)
END AS fire_protection,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(12000.0 * (p.construction / de_p.construction), 0)
END AS floor_prep,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(20000.0 * (p.construction / de_p.construction), 0)
END AS hvac_upgrade,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(10000.0 * (p.construction / de_p.construction), 0)
END AS lighting_upgrade,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(35.0 * (p.construction / de_p.construction), 0)
END AS outdoor_foundation,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(8000.0 * (p.construction / de_p.construction), 0)
END AS outdoor_site_work,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(4000.0 * (p.construction / de_p.construction), 0)
END AS outdoor_lighting,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(6000.0 * (p.construction / de_p.construction), 0)
END AS outdoor_fencing,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(15000.0 * (p.construction / de_p.construction), 0)
END AS working_capital,
-- CAPEX overrides — PLI-scaled (housing category)
-- DE baseline: landPriceSqm=60
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(60.0 * (p.housing / de_p.housing), 0)
END AS land_price_sqm
FROM (SELECT DISTINCT country_code FROM all_countries WHERE LENGTH(country_code) = 2) ac
LEFT JOIN latest_income i ON ac.country_code = i.country_code
LEFT JOIN latest_electricity e ON ac.country_code = e.country_code
LEFT JOIN latest_gas g ON ac.country_code = g.country_code
LEFT JOIN latest_labour la ON ac.country_code = la.country_code
LEFT JOIN pli_pivoted p ON ac.country_code = p.country_code
CROSS JOIN de_pli de_p
CROSS JOIN de_elec de_e
CROSS JOIN de_gas de_g
-- Enforce grain
QUALIFY ROW_NUMBER() OVER (PARTITION BY ac.country_code ORDER BY ac.country_code) = 1

View File

@@ -6,9 +6,9 @@
-- covers all locations with population ≥ 1K so zero-court Gemeinden score fully. -- covers all locations with population ≥ 1K so zero-court Gemeinden score fully.
-- --
-- Enriched with: -- Enriched with:
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
-- stg_nuts2_boundaries + stg_regional_income → EU NUTS-2/NUTS-1 income (spatial join) -- stg_nuts2_boundaries + stg_regional_income → EU NUTS-2/NUTS-1 income (spatial join)
-- stg_income_usa → US state-level income (PPS-normalised) -- stg_income_usa → US state-level income (PPS-normalised)
-- stg_income → country-level income (fallback for all countries)
-- stg_padel_courts → padel venue count + nearest court distance (km) -- stg_padel_courts → padel venue count + nearest court distance (km)
-- stg_tennis_courts → tennis court count within 25km radius -- stg_tennis_courts → tennis court count within 25km radius
-- --
@@ -16,7 +16,7 @@
-- 1. EU NUTS-2 regional income (finest; spatial join via ST_Contains) -- 1. EU NUTS-2 regional income (finest; spatial join via ST_Contains)
-- 2. EU NUTS-1 regional income (fallback when NUTS-2 income missing from dataset) -- 2. EU NUTS-1 regional income (fallback when NUTS-2 income missing from dataset)
-- 3. US state income (ratio-normalised to PPS scale; see us_income CTE) -- 3. US state income (ratio-normalised to PPS scale; see us_income CTE)
-- 4. Country-level income (global fallback from stg_income / ilc_di03) -- 4. Country-level income (global fallback from dim_countries / ilc_di03)
-- --
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension). -- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin -- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
@@ -38,7 +38,7 @@ locations AS (
geoname_id, geoname_id,
city_name AS location_name, city_name AS location_name,
-- URL-safe location slug -- URL-safe location slug
LOWER(REGEXP_REPLACE(LOWER(city_name), '[^a-z0-9]+', '-')) AS location_slug, @slugify(city_name) AS location_slug,
country_code, country_code,
lat, lat,
lon, lon,
@@ -49,12 +49,6 @@ locations AS (
FROM staging.stg_population_geonames FROM staging.stg_population_geonames
WHERE lat IS NOT NULL AND lon IS NOT NULL WHERE lat IS NOT NULL AND lon IS NOT NULL
), ),
-- Country income (ilc_di03) — global fallback for all countries
country_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- ── EU NUTS-2 income via spatial join ────────────────────────────────────── -- ── EU NUTS-2 income via spatial join ──────────────────────────────────────
-- Each EU location's (lon, lat) is matched against NUTS-2 boundary polygons. -- Each EU location's (lon, lat) is matched against NUTS-2 boundary polygons.
-- The bounding box pre-filter (bbox_lat/lon_min/max) eliminates most candidates -- The bounding box pre-filter (bbox_lat/lon_min/max) eliminates most candidates
@@ -214,14 +208,14 @@ tennis_nearby AS (
SELECT SELECT
l.geoname_id, l.geoname_id,
l.country_code, l.country_code,
-- Human-readable country name (consistent with dim_cities) -- Human-readable country name and slug — from dim_countries (single source of truth)
@country_name(l.country_code) AS country_name_en, c.country_name_en,
-- URL-safe country slug c.country_slug,
@country_slug(l.country_code) AS country_slug,
l.location_name, l.location_name,
l.location_slug, l.location_slug,
l.lat, l.lat,
l.lon, l.lon,
h3_latlng_to_cell(l.lat, l.lon, 5) AS h3_cell_res5,
l.admin1_code, l.admin1_code,
l.admin2_code, l.admin2_code,
l.population, l.population,
@@ -230,12 +224,12 @@ SELECT
COALESCE( COALESCE(
ri.regional_income_pps, -- EU: NUTS-2 (finest) or NUTS-1 (fallback) ri.regional_income_pps, -- EU: NUTS-2 (finest) or NUTS-1 (fallback)
us.median_income_pps, -- US: state-level PPS-equivalent us.median_income_pps, -- US: state-level PPS-equivalent
ci.median_income_pps -- Global: country-level from ilc_di03 c.median_income_pps -- Global: country-level from dim_countries / ilc_di03
) AS median_income_pps, ) AS median_income_pps,
COALESCE( COALESCE(
ri.regional_income_year, ri.regional_income_year,
us.income_year, us.income_year,
ci.income_year c.income_year
) AS income_year, ) AS income_year,
COALESCE(pl.padel_venue_count, 0)::INTEGER AS padel_venue_count, COALESCE(pl.padel_venue_count, 0)::INTEGER AS padel_venue_count,
-- Venues per 100K residents (NULL if population = 0) -- Venues per 100K residents (NULL if population = 0)
@@ -247,7 +241,7 @@ SELECT
COALESCE(tn.tennis_courts_within_25km, 0)::INTEGER AS tennis_courts_within_25km, COALESCE(tn.tennis_courts_within_25km, 0)::INTEGER AS tennis_courts_within_25km,
CURRENT_DATE AS refreshed_date CURRENT_DATE AS refreshed_date
FROM locations l FROM locations l
LEFT JOIN country_income ci ON l.country_code = ci.country_code LEFT JOIN foundation.dim_countries c ON l.country_code = c.country_code
LEFT JOIN regional_income ri ON l.geoname_id = ri.geoname_id LEFT JOIN regional_income ri ON l.geoname_id = ri.geoname_id
LEFT JOIN us_income us ON l.country_code = 'US' LEFT JOIN us_income us ON l.country_code = 'US'
AND l.admin1_code = us.admin1_code AND l.admin1_code = us.admin1_code

View File

@@ -99,7 +99,7 @@ SELECT
indoor_court_count, indoor_court_count,
outdoor_court_count, outdoor_court_count,
-- Conformed city key: enables deterministic joins to dim_cities / venue_pricing_benchmarks -- Conformed city key: enables deterministic joins to dim_cities / venue_pricing_benchmarks
LOWER(REGEXP_REPLACE(LOWER(COALESCE(city, '')), '[^a-z0-9]+', '-')) AS city_slug, @slugify(COALESCE(city, '')) AS city_slug,
extracted_date extracted_date
FROM ranked FROM ranked
QUALIFY ROW_NUMBER() OVER ( QUALIFY ROW_NUMBER() OVER (

View File

@@ -14,7 +14,10 @@
MODEL ( MODEL (
name foundation.fct_availability_slot, name foundation.fct_availability_slot,
kind FULL, kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily', cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time) grain (snapshot_date, tenant_id, resource_id, slot_start_time)
); );
@@ -37,7 +40,8 @@ WITH deduped AS (
captured_at_utc DESC captured_at_utc DESC
) AS rn ) AS rn
FROM staging.stg_playtomic_availability FROM staging.stg_playtomic_availability
WHERE price_amount IS NOT NULL WHERE snapshot_date BETWEEN @start_ds AND @end_ds
AND price_amount IS NOT NULL
AND price_amount > 0 AND price_amount > 0
) )
SELECT SELECT

View File

@@ -12,7 +12,10 @@
MODEL ( MODEL (
name foundation.fct_daily_availability, name foundation.fct_daily_availability,
kind FULL, kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily', cron '@daily',
grain (snapshot_date, tenant_id) grain (snapshot_date, tenant_id)
); );
@@ -37,6 +40,7 @@ WITH slot_agg AS (
MAX(a.price_currency) AS price_currency, MAX(a.price_currency) AS price_currency,
MAX(a.captured_at_utc) AS captured_at_utc MAX(a.captured_at_utc) AS captured_at_utc
FROM foundation.fct_availability_slot a FROM foundation.fct_availability_slot a
WHERE a.snapshot_date BETWEEN @start_ds AND @end_ds
GROUP BY a.snapshot_date, a.tenant_id GROUP BY a.snapshot_date, a.tenant_id
) )
SELECT SELECT

View File

@@ -3,4 +3,4 @@
Analytics-ready views consumed by the web app and programmatic SEO. Analytics-ready views consumed by the web app and programmatic SEO.
Query these from `analytics.py` via DuckDB read-only connection. Query these from `analytics.py` via DuckDB read-only connection.
Naming convention: `serving.<purpose>` (e.g. `serving.city_market_profile`) Naming convention: `serving.<purpose>` (e.g. `serving.location_profiles`)

View File

@@ -1,117 +0,0 @@
-- One Big Table: per-city padel market intelligence.
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
--
-- Padelnomics Marktreife-Score v3 (0100):
-- Answers "How mature/established is this padel market?"
-- Only computed for cities with ≥1 padel venue (padel_venue_count > 0).
-- For white-space opportunity scoring, see serving.location_opportunity_profile.
--
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
-- (min(1, count/5) kills small-town inflation)
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
-- 15 pts addressable market — log-scaled population, ceiling 1M (context only)
-- 10 pts economic context — income PPS normalised to 200 ceiling
-- 10 pts data quality — completeness discount
-- No saturation discount: high density = maturity, not a penalty
MODEL (
name serving.city_market_profile,
kind FULL,
cron '@daily',
grain (country_code, city_slug)
);
WITH base AS (
SELECT
c.country_code,
c.country_name_en,
c.country_slug,
c.city_name,
c.city_slug,
c.lat,
c.lon,
c.population,
c.population_year,
c.padel_venue_count,
c.median_income_pps,
c.income_year,
c.geoname_id,
-- Venue density: padel venues per 100K residents
CASE WHEN c.population > 0
THEN ROUND(c.padel_venue_count::DOUBLE / c.population * 100000, 2)
ELSE NULL
END AS venues_per_100k,
-- Data confidence: 1.0 if both population and venues are present
CASE
WHEN c.population > 0 AND c.padel_venue_count > 0 THEN 1.0
WHEN c.population > 0 OR c.padel_venue_count > 0 THEN 0.5
ELSE 0.0
END AS data_confidence,
-- Pricing / occupancy from Playtomic (NULL when no availability data)
vpb.median_hourly_rate,
vpb.median_peak_rate,
vpb.median_offpeak_rate,
vpb.median_occupancy_rate,
vpb.median_daily_revenue_per_venue,
vpb.price_currency
FROM foundation.dim_cities c
LEFT JOIN serving.venue_pricing_benchmarks vpb
ON c.country_code = vpb.country_code
AND c.city_slug = vpb.city_slug
WHERE c.padel_venue_count > 0
),
scored AS (
SELECT *,
ROUND(
-- Supply development (40 pts): THE maturity signal.
-- Log-scaled density: LN(density+1)/LN(21) → 20/100k ≈ full marks.
-- Count gate: min(1, count/5) — 1 venue=20%, 5+ venues=100%.
-- Kills small-town inflation (1 court / 5k pop = 20/100k) without hard cutoffs.
40.0 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
* LEAST(1.0, padel_venue_count / 5.0)
-- Demand evidence (25 pts): occupancy when Playtomic data available.
-- Fallback: 40% of density score (avoids double-counting with supply component).
+ 25.0 * CASE
WHEN median_occupancy_rate IS NOT NULL
THEN LEAST(1.0, median_occupancy_rate / 0.65)
ELSE 0.4 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
* LEAST(1.0, padel_venue_count / 5.0)
END
-- Addressable market (15 pts): population as context, not maturity signal.
-- LN(1) = 0 so zero-pop cities score 0 here.
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
-- Economic context (10 pts): country-level income PPS.
-- Flat per country — kept as context modifier, not primary signal.
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
-- Data quality (10 pts): completeness discount.
+ 10.0 * data_confidence
, 1)
AS market_score
FROM base
)
SELECT
s.country_code,
s.country_name_en,
s.country_slug,
s.city_name,
s.city_slug,
s.lat,
s.lon,
s.population,
s.population_year,
s.padel_venue_count,
s.venues_per_100k,
s.data_confidence,
s.market_score,
s.median_income_pps,
s.income_year,
s.median_hourly_rate,
s.median_peak_rate,
s.median_offpeak_rate,
s.median_occupancy_rate,
s.median_daily_revenue_per_venue,
s.price_currency,
s.geoname_id,
CURRENT_DATE AS refreshed_date
FROM scored s
ORDER BY s.market_score DESC

View File

@@ -0,0 +1,26 @@
-- Per-venue lat/lon for the city detail dot map.
-- Joins dim_venues to dim_cities to attach country_slug and city_slug
-- (needed by the /api/markets/<country>/<city>/venues.json endpoint).
-- Only rows with valid coordinates are included.
MODEL (
name serving.city_venue_locations,
kind FULL,
cron '@daily',
grain venue_id
);
SELECT
v.venue_id,
v.name,
v.lat,
v.lon,
v.court_count,
v.indoor_court_count,
v.outdoor_court_count,
v.city_slug,
c.country_slug
FROM foundation.dim_venues v
JOIN foundation.dim_cities c
ON v.country_code = c.country_code AND v.city_slug = c.city_slug
WHERE v.lat IS NOT NULL AND v.lon IS NOT NULL

View File

@@ -1,86 +0,0 @@
-- Per-location padel investment opportunity intelligence.
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
--
-- Padelnomics Marktpotenzial-Score v2 (0100):
-- Answers "Where should I build a padel court?"
-- Covers ALL GeoNames locations (pop ≥ 1K) — NOT filtered to existing padel markets.
-- Zero-court locations score highest on supply gap component (white space = opportunity).
--
-- 25 pts addressable market — log-scaled population, ceiling 500K
-- (opportunity peaks in mid-size cities; megacities already served)
-- 20 pts economic power — country income PPS, normalised to 35,000
-- EU PPS values range 18k-37k; /35k gives real spread.
-- DE ≈ 13.2pts, ES ≈ 10.7pts, SE ≈ 14.3pts.
-- Previously /200 caused all countries to saturate at 20/20.
-- 30 pts supply gap — INVERTED venue density; 0 courts/100K = full marks.
-- Ceiling raised to 8/100K (was 4) for a gentler gradient
-- and to account for ~87% data undercount vs FIP totals.
-- Linear: GREATEST(0, 1 - density/8)
-- 15 pts catchment gap — distance to nearest padel court.
-- DuckDB LEAST ignores NULLs: LEAST(1.0, NULL/30) = 1.0,
-- so NULL nearest_km = full marks (no court in bounding box
-- = high opportunity). COALESCE fallback is dead code.
-- 10 pts sports culture — tennis courts within 25km (≥10 = full marks).
-- NOTE: dim_locations tennis data is empty (all 0 rows).
-- Component contributes 0 pts everywhere until data lands.
MODEL (
name serving.location_opportunity_profile,
kind FULL,
cron '@daily',
grain (country_code, geoname_id)
);
SELECT
l.geoname_id,
l.country_code,
l.country_name_en,
l.country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
l.admin1_code,
l.admin2_code,
l.population,
l.population_year,
l.median_income_pps,
l.income_year,
l.padel_venue_count,
l.padel_venues_per_100k,
l.nearest_padel_court_km,
l.tennis_courts_within_25km,
ROUND(
-- Addressable market (25 pts): log-scaled to 500K ceiling.
-- Lower ceiling than Marktreife (1M) — opportunity peaks in mid-size cities
-- that can support a court but aren't already saturated by large-city operators.
25.0 * LEAST(1.0, LN(GREATEST(l.population, 1)) / LN(500000))
-- Economic power (20 pts): country-level income PPS normalised to 35,000.
-- Drives willingness-to-pay for court fees (€20-35/hr target range).
-- EU PPS values range 18k-37k; ceiling 35k gives meaningful spread.
-- v1 used /200 which caused LEAST(1.0, 115) = 1.0 for ALL countries (flat, no differentiation).
-- v2: /35000 → DE 0.66×20=13.2pts, ES 0.53×20=10.7pts, SE 0.71×20=14.3pts.
-- Default 15000 for missing data = reasonable developing-market assumption (~0.43).
+ 20.0 * LEAST(1.0, COALESCE(l.median_income_pps, 15000) / 35000.0)
-- Supply gap (30 pts): INVERTED venue density.
-- 0 courts/100K = full 30 pts (white space); ≥8/100K = 0 pts (served market).
-- Ceiling raised from 4→8/100K for a gentler gradient and to account for data
-- undercount (~87% of real courts not in our data).
-- This is the key signal that separates Marktpotenzial from Marktreife.
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(l.padel_venues_per_100k, 0) / 8.0)
-- Catchment gap (15 pts): distance to nearest existing padel court.
-- >30km = full 15 pts (underserved catchment area).
-- NULL = no courts found anywhere (rare edge case) → neutral 0.5.
+ 15.0 * COALESCE(LEAST(1.0, l.nearest_padel_court_km / 30.0), 0.5)
-- Sports culture proxy (10 pts): tennis courts within 25km.
-- ≥10 courts = full 10 pts (proven racket sport market = faster padel adoption).
-- 0 courts = 0 pts. Many new padel courts open inside existing tennis clubs.
+ 10.0 * LEAST(1.0, l.tennis_courts_within_25km / 10.0)
, 1) AS opportunity_score,
CURRENT_DATE AS refreshed_date
FROM foundation.dim_locations l
ORDER BY opportunity_score DESC

View File

@@ -0,0 +1,243 @@
-- Unified location profile: both scores at (country_code, geoname_id) grain.
-- Base: dim_locations (ALL GeoNames locations, pop ≥ 1K, ~140K rows).
-- Enriched with dim_cities (city_slug, city_name, exact venue count) and
-- venue_pricing_benchmarks (Playtomic pricing/occupancy).
--
-- Two scores per location:
--
-- Padelnomics Market Score (Marktreife-Score v3, 0100):
-- "How mature/established is this padel market?"
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
-- with padel venues. 0 for all other locations.
--
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
-- 15 pts addressable market — log-scaled population, ceiling 1M
-- 10 pts economic context — income PPS normalised to 200 ceiling
-- 10 pts data quality — completeness discount
--
-- Padelnomics Opportunity Score (Marktpotenzial-Score v3, 0100):
-- "Where should I build a padel court?"
-- Computed for ALL locations — zero-court locations score highest on supply gap.
-- H3 catchment methodology: addressable market and supply gap use a regional
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
--
-- 25 pts addressable market — log-scaled catchment population, ceiling 500K
-- 20 pts economic power — income PPS, normalised to 35,000
-- 30 pts supply gap — inverted catchment venue density; 0 courts = full marks
-- 15 pts catchment gap — distance to nearest padel court
-- 10 pts sports culture — tennis courts within 25km
--
-- Consumers query directly with WHERE filters:
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
-- opportunity API: WHERE country_slug = ? AND opportunity_score > 0
-- planner_defaults: WHERE city_slug IS NOT NULL
-- pseo_*: WHERE city_slug IS NOT NULL AND city_padel_venue_count > 0
MODEL (
name serving.location_profiles,
kind FULL,
cron '@daily',
grain (country_code, geoname_id)
);
WITH
-- All locations from dim_locations (superset)
base AS (
SELECT
l.geoname_id,
l.country_code,
l.country_name_en,
l.country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
l.admin1_code,
l.admin2_code,
l.population,
l.population_year,
l.median_income_pps,
l.income_year,
l.padel_venue_count,
l.padel_venues_per_100k,
l.nearest_padel_court_km,
l.tennis_courts_within_25km,
l.h3_cell_res5
FROM foundation.dim_locations l
),
-- Aggregate population and court counts per H3 cell (res 5, ~8.5km edge).
-- Grouping by cell first (~50-80K distinct cells vs 140K locations) keeps the
-- subsequent lateral join small.
hex_stats AS (
SELECT
h3_cell_res5,
SUM(population) AS hex_population,
SUM(padel_venue_count) AS hex_padel_courts
FROM foundation.dim_locations
GROUP BY h3_cell_res5
),
-- For each location, sum hex_stats across the cell + 6 neighbours (k_ring=1).
-- Effective catchment: ~24km radius — realistic driving distance.
catchment AS (
SELECT
l.geoname_id,
SUM(hs.hex_population) AS catchment_population,
SUM(hs.hex_padel_courts) AS catchment_padel_courts
FROM base l,
LATERAL (SELECT UNNEST(h3_grid_disk(l.h3_cell_res5, 1)) AS cell) ring
JOIN hex_stats hs ON hs.h3_cell_res5 = ring.cell
GROUP BY l.geoname_id
),
-- Match dim_cities via (country_code, geoname_id) to get city_slug + exact venue count.
-- QUALIFY handles rare multi-city-per-geoname collisions (keep highest venue count).
city_match AS (
SELECT
c.country_code,
c.geoname_id,
c.city_slug,
c.city_name,
c.padel_venue_count AS city_padel_venue_count
FROM foundation.dim_cities c
WHERE c.geoname_id IS NOT NULL
QUALIFY ROW_NUMBER() OVER (
PARTITION BY c.country_code, c.geoname_id
ORDER BY c.padel_venue_count DESC
) = 1
),
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment
with_pricing AS (
SELECT
b.*,
cm.city_slug,
cm.city_name,
cm.city_padel_venue_count,
vpb.median_hourly_rate,
vpb.median_peak_rate,
vpb.median_offpeak_rate,
vpb.median_occupancy_rate,
vpb.median_daily_revenue_per_venue,
vpb.price_currency,
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
FROM base b
LEFT JOIN city_match cm
ON b.country_code = cm.country_code
AND b.geoname_id = cm.geoname_id
LEFT JOIN serving.venue_pricing_benchmarks vpb
ON cm.country_code = vpb.country_code
AND cm.city_slug = vpb.city_slug
LEFT JOIN catchment ct
ON b.geoname_id = ct.geoname_id
),
-- Both scores computed from the enriched base
scored AS (
SELECT *,
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
CASE WHEN population > 0
THEN ROUND(COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000, 2)
ELSE NULL
END AS city_venues_per_100k,
-- Data confidence (for market_score)
CASE
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
ELSE 0.0
END AS data_confidence,
-- ── Market Score (Marktreife-Score v3) ──────────────────────────────────
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
ROUND(
-- Supply development (40 pts)
40.0 * LEAST(1.0, LN(
COALESCE(
CASE WHEN population > 0
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
ELSE 0 END
, 0) + 1) / LN(21))
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
-- Demand evidence (25 pts)
+ 25.0 * CASE
WHEN median_occupancy_rate IS NOT NULL
THEN LEAST(1.0, median_occupancy_rate / 0.65)
ELSE 0.4 * LEAST(1.0, LN(
COALESCE(
CASE WHEN population > 0
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
ELSE 0 END
, 0) + 1) / LN(21))
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
END
-- Addressable market (15 pts)
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
-- Economic context (10 pts)
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
-- Data quality (10 pts)
+ 10.0 * CASE
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
ELSE 0.0
END
, 1)
ELSE 0
END AS market_score,
-- ── Opportunity Score (Marktpotenzial-Score v3, H3 catchment) ──────────
ROUND(
-- Addressable market (25 pts): log-scaled catchment population, ceiling 500K
25.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
-- Economic power (20 pts): income PPS normalised to 35,000
+ 20.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
-- Supply gap (30 pts): inverted catchment venue density
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(
CASE WHEN catchment_population > 0
THEN catchment_padel_courts::DOUBLE / catchment_population * 100000
ELSE 0.0
END, 0.0) / 8.0)
-- Catchment gap (15 pts): distance to nearest court
+ 15.0 * COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
-- Sports culture (10 pts): tennis courts within 25km
+ 10.0 * LEAST(1.0, tennis_courts_within_25km / 10.0)
, 1) AS opportunity_score
FROM with_pricing
)
SELECT
s.geoname_id,
s.country_code,
s.country_name_en,
s.country_slug,
s.location_name,
s.location_slug,
s.city_slug,
s.city_name,
s.lat,
s.lon,
s.admin1_code,
s.admin2_code,
s.population,
s.population_year,
s.median_income_pps,
s.income_year,
s.padel_venue_count,
s.padel_venues_per_100k,
s.nearest_padel_court_km,
s.tennis_courts_within_25km,
s.city_padel_venue_count,
s.city_venues_per_100k,
s.data_confidence,
s.catchment_population,
s.catchment_padel_courts,
CASE WHEN s.catchment_population > 0
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
ELSE NULL
END AS catchment_venues_per_100k,
s.market_score,
s.opportunity_score,
s.median_hourly_rate,
s.median_peak_rate,
s.median_offpeak_rate,
s.median_occupancy_rate,
s.median_daily_revenue_per_venue,
s.price_currency,
CURRENT_DATE AS refreshed_date
FROM scored s
ORDER BY s.market_score DESC, s.opportunity_score DESC

View File

@@ -7,6 +7,10 @@
-- 2. Country-level: median across cities in same country -- 2. Country-level: median across cities in same country
-- 3. Hardcoded fallback: market research estimates (only when no Playtomic data) -- 3. Hardcoded fallback: market research estimates (only when no Playtomic data)
-- --
-- Cost override columns from dim_countries (Eurostat PLI + energy price indices) are
-- included so the planner API pre-fills country-adjusted CAPEX/OPEX for all cities.
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline preserved).
--
-- Units are explicit in column names. Monetary values in local currency. -- Units are explicit in column names. Monetary values in local currency.
MODEL ( MODEL (
@@ -72,11 +76,12 @@ city_profiles AS (
city_slug, city_slug,
country_code, country_code,
city_name, city_name,
padel_venue_count, city_padel_venue_count AS padel_venue_count,
population, population,
market_score, market_score,
venues_per_100k city_venues_per_100k AS venues_per_100k
FROM serving.city_market_profile FROM serving.location_profiles
WHERE city_slug IS NOT NULL
) )
SELECT SELECT
cp.city_slug, cp.city_slug,
@@ -125,6 +130,37 @@ SELECT
ELSE 0.2 ELSE 0.2
END AS data_confidence, END AS data_confidence,
COALESCE(cb.price_currency, ctb.price_currency, hf.currency, 'EUR') AS price_currency, COALESCE(cb.price_currency, ctb.price_currency, hf.currency, 'EUR') AS price_currency,
-- Cost override columns (Eurostat PLI + energy prices via dim_countries).
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline).
dc.electricity,
dc.heating,
dc.rent_sqm,
dc.insurance,
dc.cleaning,
dc.maintenance,
dc.marketing,
dc.water,
dc.property_tax,
dc.outdoor_rent,
dc.hall_cost_sqm,
dc.foundation_sqm,
dc.land_price_sqm,
dc.hvac,
dc.electrical,
dc.sanitary,
dc.parking,
dc.fitout,
dc.planning,
dc.fire_protection,
dc.floor_prep,
dc.hvac_upgrade,
dc.lighting_upgrade,
dc.outdoor_foundation,
dc.outdoor_site_work,
dc.outdoor_lighting,
dc.outdoor_fencing,
dc.working_capital,
dc.permits_compliance,
CURRENT_DATE AS refreshed_date CURRENT_DATE AS refreshed_date
FROM city_profiles cp FROM city_profiles cp
LEFT JOIN city_benchmarks cb LEFT JOIN city_benchmarks cb
@@ -134,3 +170,5 @@ LEFT JOIN country_benchmarks ctb
ON cp.country_code = ctb.country_code ON cp.country_code = ctb.country_code
LEFT JOIN hardcoded_fallbacks hf LEFT JOIN hardcoded_fallbacks hf
ON cp.country_code = hf.country_code ON cp.country_code = hf.country_code
LEFT JOIN foundation.dim_countries dc
ON cp.country_code = dc.country_code

View File

@@ -4,6 +4,10 @@
-- --
-- Calculator override columns use camelCase to match the DEFAULTS keys in -- Calculator override columns use camelCase to match the DEFAULTS keys in
-- planner/calculator.py, so they are auto-applied as calc pre-fills. -- planner/calculator.py, so they are auto-applied as calc pre-fills.
--
-- Cost override columns come from foundation.dim_countries (Eurostat PLI and energy
-- price indices). NULL = fall through to calculator.py DEFAULTS (safe: auto-mapping
-- filters None). DE always produces NULL overrides — preserves exact DEFAULTS behaviour.
MODEL ( MODEL (
name serving.pseo_city_costs_de, name serving.pseo_city_costs_de,
@@ -22,12 +26,15 @@ SELECT
c.country_code, c.country_code,
c.country_name_en, c.country_name_en,
c.country_slug, c.country_slug,
-- City coordinates (for the city venue dot map)
c.lat,
c.lon,
-- Market metrics -- Market metrics
c.population, c.population,
c.padel_venue_count, c.city_padel_venue_count AS padel_venue_count,
c.venues_per_100k, c.city_venues_per_100k AS venues_per_100k,
c.market_score, c.market_score,
lop.opportunity_score, c.opportunity_score,
c.data_confidence, c.data_confidence,
-- Pricing (from Playtomic, NULL when no coverage) -- Pricing (from Playtomic, NULL when no coverage)
c.median_hourly_rate, c.median_hourly_rate,
@@ -44,14 +51,47 @@ SELECT
FLOOR(p.courts_typical) AS "dblCourts", FLOOR(p.courts_typical) AS "dblCourts",
-- 'country' drives currency formatting in the calculator -- 'country' drives currency formatting in the calculator
c.country_code AS "country", c.country_code AS "country",
-- Cost override columns from dim_countries (Eurostat PLI + energy price indices).
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline preserved).
-- OPEX overrides
cc.electricity AS "electricity",
cc.heating AS "heating",
cc.rent_sqm AS "rentSqm",
cc.insurance AS "insurance",
cc.cleaning AS "cleaning",
cc.maintenance AS "maintenance",
cc.marketing AS "marketing",
cc.water AS "water",
cc.property_tax AS "propertyTax",
cc.outdoor_rent AS "outdoorRent",
-- CAPEX overrides
cc.hall_cost_sqm AS "hallCostSqm",
cc.foundation_sqm AS "foundationSqm",
cc.land_price_sqm AS "landPriceSqm",
cc.hvac AS "hvac",
cc.electrical AS "electrical",
cc.sanitary AS "sanitary",
cc.parking AS "parking",
cc.fitout AS "fitout",
cc.planning AS "planning",
cc.fire_protection AS "fireProtection",
cc.floor_prep AS "floorPrep",
cc.hvac_upgrade AS "hvacUpgrade",
cc.lighting_upgrade AS "lightingUpgrade",
cc.outdoor_foundation AS "outdoorFoundation",
cc.outdoor_site_work AS "outdoorSiteWork",
cc.outdoor_lighting AS "outdoorLighting",
cc.outdoor_fencing AS "outdoorFencing",
cc.working_capital AS "workingCapital",
cc.permits_compliance AS "permitsCompliance",
CURRENT_DATE AS refreshed_date CURRENT_DATE AS refreshed_date
FROM serving.city_market_profile c FROM serving.location_profiles c
LEFT JOIN serving.planner_defaults p LEFT JOIN serving.planner_defaults p
ON c.country_code = p.country_code ON c.country_code = p.country_code
AND c.city_slug = p.city_slug AND c.city_slug = p.city_slug
LEFT JOIN serving.location_opportunity_profile lop LEFT JOIN foundation.dim_countries cc
ON c.country_code = lop.country_code ON c.country_code = cc.country_code
AND c.geoname_id = lop.geoname_id
-- Only cities with actual padel presence and at least some rate data -- Only cities with actual padel presence and at least some rate data
WHERE c.padel_venue_count > 0 WHERE c.city_slug IS NOT NULL
AND c.city_padel_venue_count > 0
AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL) AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL)

View File

@@ -1,6 +1,6 @@
-- pSEO article data: per-city padel court pricing. -- pSEO article data: per-city padel court pricing.
-- One row per city — consumed by the city-pricing.md.jinja template. -- One row per city — consumed by the city-pricing.md.jinja template.
-- Joins venue_pricing_benchmarks (real Playtomic data) with city_market_profile -- Joins venue_pricing_benchmarks (real Playtomic data) with location_profiles
-- (population, venue count, country metadata). -- (population, venue count, country metadata).
-- --
-- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real -- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real
@@ -16,7 +16,7 @@ MODEL (
SELECT SELECT
-- Composite natural key: country_slug + city_slug ensures uniqueness across countries -- Composite natural key: country_slug + city_slug ensures uniqueness across countries
c.country_slug || '-' || c.city_slug AS city_key, c.country_slug || '-' || c.city_slug AS city_key,
-- City identity (from city_market_profile, which has the canonical city_slug) -- City identity (from location_profiles, which has the canonical city_slug)
c.city_slug, c.city_slug,
c.city_name, c.city_name,
c.country_code, c.country_code,
@@ -24,8 +24,8 @@ SELECT
c.country_slug, c.country_slug,
-- Market context -- Market context
c.population, c.population,
c.padel_venue_count, c.city_padel_venue_count AS padel_venue_count,
c.venues_per_100k, c.city_venues_per_100k AS venues_per_100k,
c.market_score, c.market_score,
-- Pricing benchmarks (from Playtomic availability data) -- Pricing benchmarks (from Playtomic availability data)
vpb.median_hourly_rate, vpb.median_hourly_rate,
@@ -38,9 +38,10 @@ SELECT
vpb.price_currency, vpb.price_currency,
CURRENT_DATE AS refreshed_date CURRENT_DATE AS refreshed_date
FROM serving.venue_pricing_benchmarks vpb FROM serving.venue_pricing_benchmarks vpb
-- Join city_market_profile to get the canonical city_slug and country metadata -- Join location_profiles to get canonical city metadata
INNER JOIN serving.city_market_profile c INNER JOIN serving.location_profiles c
ON vpb.country_code = c.country_code ON vpb.country_code = c.country_code
AND vpb.city_slug = c.city_slug AND vpb.city_slug = c.city_slug
AND c.city_slug IS NOT NULL
-- Only cities with enough venues for meaningful pricing statistics -- Only cities with enough venues for meaningful pricing statistics
WHERE vpb.venue_count >= 2 WHERE vpb.venue_count >= 2

View File

@@ -20,15 +20,15 @@ SELECT
SUM(padel_venue_count) AS total_venues, SUM(padel_venue_count) AS total_venues,
ROUND(AVG(market_score), 1) AS avg_market_score, ROUND(AVG(market_score), 1) AS avg_market_score,
MAX(market_score) AS top_city_market_score, MAX(market_score) AS top_city_market_score,
-- Top 5 cities by market score for internal linking (DuckDB list slice syntax) -- Top 5 cities by venue count (prominence), then score for internal linking
LIST(city_slug ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_slugs, LIST(city_slug ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
LIST(city_name ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_names, LIST(city_name ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_names,
-- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG) -- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG)
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score, ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
MAX(opportunity_score) AS top_opportunity_score, MAX(opportunity_score) AS top_opportunity_score,
-- Top 5 cities by opportunity score (may differ from top market score cities) -- Top 5 opportunity cities by population (prominence), then opportunity score
LIST(city_slug ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs, LIST(city_slug ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
LIST(city_name ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names, LIST(city_name ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
-- Pricing medians across cities (NULL when no Playtomic coverage in country) -- Pricing medians across cities (NULL when no Playtomic coverage in country)
ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate, ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate,
ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate, ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate,

View File

@@ -27,7 +27,7 @@ WITH venue_stats AS (
MAX(da.active_court_count) AS court_count, MAX(da.active_court_count) AS court_count,
COUNT(DISTINCT da.snapshot_date) AS days_observed COUNT(DISTINCT da.snapshot_date) AS days_observed
FROM foundation.fct_daily_availability da FROM foundation.fct_daily_availability da
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days' WHERE da.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
AND da.occupancy_rate IS NOT NULL AND da.occupancy_rate IS NOT NULL
AND da.occupancy_rate BETWEEN 0 AND 1.5 AND da.occupancy_rate BETWEEN 0 AND 1.5
GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency

View File

@@ -0,0 +1,42 @@
-- Electricity prices for non-household consumers (Eurostat nrg_pc_205).
-- EUR/kWh excluding taxes, band MWH500-1999 (medium-sized commercial consumer).
-- Semi-annual frequency: ref_period is "YYYY-S1" or "YYYY-S2".
--
-- Source: data/landing/eurostat/{year}/{month}/nrg_pc_205.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2024-S1", "value": 0.1523}, ...]}
MODEL (
name staging.stg_electricity_prices,
kind FULL,
cron '@daily',
grain (country_code, ref_period)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/nrg_pc_205.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRIM(r.ref_year) AS ref_period,
TRY_CAST(r.value AS DOUBLE) AS electricity_eur_kwh
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_period,
electricity_eur_kwh
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND electricity_eur_kwh > 0

View File

@@ -0,0 +1,42 @@
-- Gas prices for non-household consumers (Eurostat nrg_pc_203).
-- EUR/GJ excluding taxes, band GJ1000-9999 (medium-sized commercial consumer).
-- Semi-annual frequency: ref_period is "YYYY-S1" or "YYYY-S2".
--
-- Source: data/landing/eurostat/{year}/{month}/nrg_pc_203.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2024-S1", "value": 14.23}, ...]}
MODEL (
name staging.stg_gas_prices,
kind FULL,
cron '@daily',
grain (country_code, ref_period)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/nrg_pc_203.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRIM(r.ref_year) AS ref_period,
TRY_CAST(r.value AS DOUBLE) AS gas_eur_gj
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_period,
gas_eur_gj
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND gas_eur_gj > 0

View File

@@ -0,0 +1,46 @@
-- Labour cost levels EUR/hour (Eurostat lc_lci_lev).
-- NACE R2 sector N (administrative and support service activities).
-- D1_D2_A_HW structure: wages + non-wage costs, actual hours worked.
-- Annual frequency.
--
-- Stored for future "staffed scenario" calculator variant.
-- Not wired into default calculator overrides (staff=0 is a business assumption).
--
-- Source: data/landing/eurostat/{year}/{month}/lc_lci_lev.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 28.4}, ...]}
MODEL (
name staging.stg_labour_costs,
kind FULL,
cron '@daily',
grain (country_code, ref_year)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/lc_lci_lev.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRY_CAST(r.ref_year AS INTEGER) AS ref_year,
TRY_CAST(r.value AS DOUBLE) AS labour_cost_eur_hour
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_year,
labour_cost_eur_hour
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND labour_cost_eur_hour > 0

View File

@@ -13,44 +13,28 @@
MODEL ( MODEL (
name staging.stg_playtomic_availability, name staging.stg_playtomic_availability,
kind FULL, kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily', cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc) grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
); );
WITH WITH
morning_jsonl AS ( all_jsonl AS (
SELECT SELECT
date AS snapshot_date, CAST(date AS DATE) AS snapshot_date,
captured_at_utc, captured_at_utc,
'morning' AS snapshot_type, CASE
NULL::INTEGER AS recheck_hour, WHEN filename LIKE '%_recheck_%' THEN 'recheck'
tenant_id, ELSE 'morning'
slots AS slots_json END AS snapshot_type,
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
format = 'newline_delimited',
columns = {
date: 'VARCHAR',
captured_at_utc: 'VARCHAR',
tenant_id: 'VARCHAR',
slots: 'JSON'
},
filename = true
)
WHERE filename NOT LIKE '%_recheck_%'
AND tenant_id IS NOT NULL
),
recheck_jsonl AS (
SELECT
date AS snapshot_date,
captured_at_utc,
'recheck' AS snapshot_type,
TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour, TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour,
tenant_id, tenant_id,
slots AS slots_json slots AS slots_json
FROM read_json( FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.jsonl.gz', @LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
format = 'newline_delimited', format = 'newline_delimited',
columns = { columns = {
date: 'VARCHAR', date: 'VARCHAR',
@@ -63,11 +47,6 @@ recheck_jsonl AS (
) )
WHERE tenant_id IS NOT NULL WHERE tenant_id IS NOT NULL
), ),
all_venues AS (
SELECT * FROM morning_jsonl
UNION ALL
SELECT * FROM recheck_jsonl
),
raw_resources AS ( raw_resources AS (
SELECT SELECT
av.snapshot_date, av.snapshot_date,
@@ -76,7 +55,7 @@ raw_resources AS (
av.recheck_hour, av.recheck_hour,
av.tenant_id, av.tenant_id,
resource_json resource_json
FROM all_venues av, FROM all_jsonl av,
LATERAL UNNEST( LATERAL UNNEST(
from_json(av.slots_json, '["JSON"]') from_json(av.slots_json, '["JSON"]')
) AS t(resource_json) ) AS t(resource_json)

View File

@@ -0,0 +1,96 @@
-- Price level indices relative to EU27=100 (Eurostat prc_ppp_ind).
-- Five categories, each from a separate landing file (different ppp_cat filters).
-- Annual frequency.
--
-- Categories and what they scale in the calculator:
-- construction — CAPEX: hallCostSqm, foundationSqm, hvac, electrical, sanitary, etc.
-- housing — rentSqm, landPriceSqm, water, outdoorRent
-- services — cleaning, maintenance, marketing
-- misc — insurance
-- government — permitsCompliance, propertyTax
--
-- Sources:
-- data/landing/eurostat/*/*/prc_ppp_ind_construction.json.gz (ppp_cat: A050202)
-- data/landing/eurostat/*/*/prc_ppp_ind_housing.json.gz (ppp_cat: A0104)
-- data/landing/eurostat/*/*/prc_ppp_ind_services.json.gz (ppp_cat: P0201)
-- data/landing/eurostat/*/*/prc_ppp_ind_misc.json.gz (ppp_cat: A0112)
-- data/landing/eurostat/*/*/prc_ppp_ind_government.json.gz (ppp_cat: P0202)
--
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 107.3}, ...]}
MODEL (
name staging.stg_price_levels,
kind FULL,
cron '@daily',
grain (country_code, category, ref_year)
);
WITH construction_raw AS (
SELECT unnest(rows) AS r, 'construction' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_construction.json.gz',
auto_detect = true
)
),
housing_raw AS (
SELECT unnest(rows) AS r, 'housing' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_housing.json.gz',
auto_detect = true
)
),
services_raw AS (
SELECT unnest(rows) AS r, 'services' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_services.json.gz',
auto_detect = true
)
),
misc_raw AS (
SELECT unnest(rows) AS r, 'misc' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_misc.json.gz',
auto_detect = true
)
),
government_raw AS (
SELECT unnest(rows) AS r, 'government' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_government.json.gz',
auto_detect = true
)
),
all_raw AS (
SELECT r, category FROM construction_raw
UNION ALL
SELECT r, category FROM housing_raw
UNION ALL
SELECT r, category FROM services_raw
UNION ALL
SELECT r, category FROM misc_raw
UNION ALL
SELECT r, category FROM government_raw
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRY_CAST(r.ref_year AS INTEGER) AS ref_year,
TRY_CAST(r.value AS DOUBLE) AS pli,
category
FROM all_raw
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
category,
ref_year,
pli
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND pli > 0

20
uv.lock generated
View File

@@ -150,6 +150,11 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" } sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" },
{ url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" },
{ url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" },
{ url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" },
{ url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" },
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" }, { url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" }, { url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" }, { url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
@@ -1392,6 +1397,7 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "quart" }, { name = "quart" },
{ name = "resend" }, { name = "resend" },
{ name = "stripe" },
{ name = "weasyprint" }, { name = "weasyprint" },
] ]
@@ -1413,6 +1419,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "quart", specifier = ">=0.19.0" }, { name = "quart", specifier = ">=0.19.0" },
{ name = "resend", specifier = ">=2.22.0" }, { name = "resend", specifier = ">=2.22.0" },
{ name = "stripe", specifier = ">=14.4.0" },
{ name = "weasyprint", specifier = ">=68.1" }, { name = "weasyprint", specifier = ">=68.1" },
] ]
@@ -2519,6 +2526,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
] ]
[[package]]
name = "stripe"
version = "14.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/ec/0f17cff3f7c91b0215266959c5a2a96b0bf9f45ac041c50b99ad8f9b5047/stripe-14.4.0.tar.gz", hash = "sha256:ddaa06f5e38a582bef7e93e06fc304ba8ae3b4c0c2aac43da02c84926f05fa0a", size = 1472370, upload-time = "2026-02-25T17:52:40.905Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/09/fcecad01d76dbe027015dd559ec1b6dccfc319c2540991dde4b1de81ba34/stripe-14.4.0-py3-none-any.whl", hash = "sha256:357151a816cd0bb012d6cb29f108fae50b9f6eece8530d7bc31dfa90c9ceb84c", size = 2115405, upload-time = "2026-02-25T17:52:39.128Z" },
]
[[package]] [[package]]
name = "tenacity" name = "tenacity"
version = "9.1.4" version = "9.1.4"

View File

@@ -22,6 +22,7 @@ dependencies = [
"httpx>=0.27.0", "httpx>=0.27.0",
"google-api-python-client>=2.100.0", "google-api-python-client>=2.100.0",
"google-auth>=2.23.0", "google-auth>=2.23.0",
"stripe>=14.4.0",
] ]
[build-system] [build-system]

View File

@@ -48,7 +48,7 @@ PADDLE_ENVIRONMENT=${PADDLE_ENVIRONMENT:-sandbox}
# -- Preparation ------------------------------------------------------------- # -- Preparation -------------------------------------------------------------
info "Resetting database" info "Resetting database"
rm -f "$DATABASE_PATH" rm -f "$DATABASE_PATH" "${DATABASE_PATH}-shm" "${DATABASE_PATH}-wal"
ok "Removed $DATABASE_PATH" ok "Removed $DATABASE_PATH"
info "Running migrations" info "Running migrations"
@@ -165,7 +165,7 @@ echo ""
echo "Press Ctrl-C to stop all processes." echo "Press Ctrl-C to stop all processes."
echo "" echo ""
run_with_label "$COLOR_APP" "app " uv run granian --interface asgi --host 127.0.0.1 --port 5000 --reload --reload-paths web/src padelnomics.app:app run_with_label "$COLOR_APP" "app " uv run python -m padelnomics.app
run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker
run_with_label "$COLOR_CSS" "css " make css-watch run_with_label "$COLOR_CSS" "css " make css-watch

View File

@@ -51,8 +51,10 @@ bp = Blueprint(
_LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing") _LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb") _SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
# Repo root: web/src/padelnomics/admin/ → up 4 levels # In prod the package is installed in a venv so __file__.parents[4] won't
_REPO_ROOT = Path(__file__).resolve().parents[4] # reach the repo checkout. WorkingDirectory in the systemd unit is /opt/padelnomics,
# so CWD is reliable; REPO_ROOT env var overrides for non-standard setups.
_REPO_ROOT = Path(os.environ.get("REPO_ROOT", ".")).resolve()
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml" _WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
# A "running" row older than this is considered stale/crashed. # A "running" row older than this is considered stale/crashed.
@@ -109,13 +111,12 @@ _DAG: dict[str, list[str]] = {
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"], "fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
# Serving # Serving
"venue_pricing_benchmarks": ["fct_daily_availability"], "venue_pricing_benchmarks": ["fct_daily_availability"],
"city_market_profile": ["dim_cities", "venue_pricing_benchmarks"], "location_profiles": ["dim_locations", "dim_cities", "venue_pricing_benchmarks"],
"planner_defaults": ["venue_pricing_benchmarks", "city_market_profile"], "planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
"location_opportunity_profile": ["dim_locations"],
"pseo_city_costs_de": [ "pseo_city_costs_de": [
"city_market_profile", "planner_defaults", "location_opportunity_profile", "location_profiles", "planner_defaults",
], ],
"pseo_city_pricing": ["venue_pricing_benchmarks", "city_market_profile"], "pseo_city_pricing": ["venue_pricing_benchmarks", "location_profiles"],
"pseo_country_overview": ["pseo_city_costs_de"], "pseo_country_overview": ["pseo_city_costs_de"],
} }
@@ -538,6 +539,7 @@ def _load_workflows() -> list[dict]:
"schedule": schedule, "schedule": schedule,
"schedule_label": schedule_label, "schedule_label": schedule_label,
"depends_on": config.get("depends_on", []), "depends_on": config.get("depends_on", []),
"description": config.get("description", ""),
}) })
return workflows return workflows

View File

@@ -532,6 +532,71 @@ async def lead_results():
) )
@bp.route("/leads/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def leads_bulk():
"""Bulk actions on leads: set_status, set_heat."""
form = await request.form
ids_raw = form.get("lead_ids", "").strip()
action = form.get("action", "").strip()
if action not in ("set_status", "set_heat") or not ids_raw:
return "", 400
lead_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(lead_ids) <= 500, "too many lead IDs in bulk action"
if not lead_ids:
return "", 400
placeholders = ",".join("?" for _ in lead_ids)
if action == "set_status":
target = form.get("target_status", "").strip()
if target not in LEAD_STATUSES:
return "", 400
await execute(
f"UPDATE lead_requests SET status = ? WHERE id IN ({placeholders})",
(target, *lead_ids),
)
elif action == "set_heat":
target = form.get("target_heat", "").strip()
if target not in HEAT_OPTIONS:
return "", 400
await execute(
f"UPDATE lead_requests SET heat_score = ? WHERE id IN ({placeholders})",
(target, *lead_ids),
)
# Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
heat_filter = form.get("heat", "")
country_filter = form.get("country", "")
days_str = form.get("days", "")
days = int(days_str) if days_str.isdigit() else None
per_page = 50
lead_list, total = await get_leads(
status=status_filter or None, heat=heat_filter or None,
country=country_filter or None, search=search or None,
days=days, page=1, per_page=per_page,
)
return await render_template(
"admin/partials/lead_results.html",
leads=lead_list,
page=1,
per_page=per_page,
total=total,
current_status=status_filter,
current_heat=heat_filter,
current_country=country_filter,
current_search=search,
current_days=days_str,
)
@bp.route("/leads/<int:lead_id>") @bp.route("/leads/<int:lead_id>")
@role_required("admin") @role_required("admin")
async def lead_detail(lead_id: int): async def lead_detail(lead_id: int):
@@ -2400,6 +2465,18 @@ async def articles():
) )
@bp.route("/articles/stats")
@role_required("admin")
async def article_stats():
"""HTMX partial: article stats bar (polled while generating)."""
stats = await _get_article_stats()
return await render_template(
"admin/partials/article_stats.html",
stats=stats,
is_generating=await _is_generating(),
)
@bp.route("/articles/results") @bp.route("/articles/results")
@role_required("admin") @role_required("admin")
async def article_results(): async def article_results():
@@ -2430,6 +2507,101 @@ async def article_results():
) )
@bp.route("/articles/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def articles_bulk():
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").strip()
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions or not ids_raw:
return "", 400
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids:
return "", 400
placeholders = ",".join("?" for _ in article_ids)
now = utcnow_iso()
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
elif action == "rebuild":
for aid in article_ids:
await _rebuild_article(aid)
elif action == "delete":
from ..content.routes import BUILD_DIR
articles = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
for a in articles:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
# Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
template_filter = form.get("template", "")
language_filter = form.get("language", "")
grouped = not language_filter
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None,
)
else:
article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None,
)
return await render_template(
"admin/partials/article_results.html",
articles=article_list,
grouped=grouped,
page=1,
is_generating=await _is_generating(),
)
@bp.route("/articles/new", methods=["GET", "POST"]) @bp.route("/articles/new", methods=["GET", "POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
@@ -2458,11 +2630,11 @@ async def article_new():
if not title or not body: if not title or not body:
await flash("Title and body are required.", "error") await flash("Title and body are required.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False) return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False) return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
# Render markdown → HTML with scenario + product cards baked in # Render markdown → HTML with scenario + product cards baked in
body_html = mistune.html(body) body_html = mistune.html(body)
@@ -2495,7 +2667,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success") await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
return await render_template("admin/article_form.html", data={}, editing=False, body_html="") return await render_template("admin/article_form.html", data={}, editing=False, preview_doc="")
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"]) @bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@@ -2531,7 +2703,7 @@ async def article_edit(article_id: int):
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template( return await render_template(
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id, "admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
) )
# Re-render if body provided # Re-render if body provided
@@ -2576,6 +2748,13 @@ async def article_edit(article_id: int):
body = raw[m.end():].lstrip("\n") if m else raw body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else "" body_html = mistune.html(body) if body else ""
preview_doc = (
await render_template(
"admin/partials/article_preview_doc.html", body_html=body_html
)
if body_html
else ""
)
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
return await render_template( return await render_template(
@@ -2583,7 +2762,7 @@ async def article_edit(article_id: int):
data=data, data=data,
editing=True, editing=True,
article_id=article_id, article_id=article_id,
body_html=body_html, preview_doc=preview_doc,
) )
@@ -2592,13 +2771,19 @@ async def article_edit(article_id: int):
@csrf_protect @csrf_protect
async def article_preview(): async def article_preview():
"""Render markdown body to HTML for the live editor preview panel.""" """Render markdown body to HTML for the live editor preview panel."""
from markupsafe import Markup
form = await request.form form = await request.form
body = form.get("body", "") body = form.get("body", "")
m = _FRONTMATTER_RE.match(body) m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body body = body[m.end():].lstrip("\n") if m else body
body_html = Markup(mistune.html(body)) if body else "" body_html = mistune.html(body) if body else ""
return await render_template("admin/partials/article_preview.html", body_html=body_html) preview_doc = (
await render_template(
"admin/partials/article_preview_doc.html", body_html=body_html
)
if body_html
else ""
)
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
@bp.route("/articles/<int:article_id>/delete", methods=["POST"]) @bp.route("/articles/<int:article_id>/delete", methods=["POST"])

View File

@@ -195,84 +195,50 @@
.ae-editor::placeholder { color: #CBD5E1; } .ae-editor::placeholder { color: #CBD5E1; }
.ae-editor:focus { outline: none; } .ae-editor:focus { outline: none; }
/* Preview pane */ /* Preview pane — iframe fills the content area */
.ae-preview { #ae-preview-content {
flex: 1; flex: 1;
overflow-y: auto; display: flex;
padding: 1.5rem 2rem; min-height: 0;
background: #fff;
} }
/* Preview typography — maps rendered markdown */
.ae-preview .preview-body { max-width: 42rem; }
.ae-preview .preview-body h1 {
font-family: var(--font-display);
font-size: 1.625rem; font-weight: 700;
color: #0F172A; margin: 0 0 1rem;
line-height: 1.25;
}
.ae-preview .preview-body h2 {
font-family: var(--font-display);
font-size: 1.25rem; font-weight: 600;
color: #0F172A; margin: 1.75rem 0 0.625rem;
}
.ae-preview .preview-body h3 {
font-size: 1.0625rem; font-weight: 600;
color: #0F172A; margin: 1.25rem 0 0.5rem;
}
.ae-preview .preview-body h4 {
font-size: 0.9375rem; font-weight: 600;
color: #334155; margin: 1rem 0 0.375rem;
}
.ae-preview .preview-body p { margin: 0 0 0.875rem; color: #1E293B; line-height: 1.75; }
.ae-preview .preview-body ul,
.ae-preview .preview-body ol { margin: 0 0 0.875rem 1.375rem; color: #1E293B; }
.ae-preview .preview-body li { margin: 0.3rem 0; line-height: 1.65; }
.ae-preview .preview-body code {
font-family: var(--font-mono); font-size: 0.8125rem;
background: #F1F5F9; color: #1D4ED8;
padding: 0.1rem 0.35rem; border-radius: 3px;
}
.ae-preview .preview-body pre {
background: #0F172A; color: #CBD5E1;
padding: 1rem 1.125rem; border-radius: 6px;
overflow-x: auto; margin: 0 0 0.875rem;
font-size: 0.8125rem; line-height: 1.65;
}
.ae-preview .preview-body pre code {
background: none; color: inherit; padding: 0;
}
.ae-preview .preview-body blockquote {
border-left: 3px solid #1D4ED8;
padding-left: 1rem; margin: 0 0 0.875rem;
color: #475569;
}
.ae-preview .preview-body a { color: #1D4ED8; }
.ae-preview .preview-body hr {
border: none; border-top: 1px solid #E2E8F0;
margin: 1.5rem 0;
}
.ae-preview .preview-body strong { font-weight: 600; color: #0F172A; }
.ae-preview .preview-body table {
width: 100%; border-collapse: collapse;
font-size: 0.875rem; margin: 0 0 0.875rem;
}
.ae-preview .preview-body th {
background: #F8FAFC; font-weight: 600;
padding: 0.5rem 0.75rem; text-align: left;
border: 1px solid #E2E8F0; color: #0F172A;
}
.ae-preview .preview-body td {
padding: 0.5rem 0.75rem;
border: 1px solid #E2E8F0; color: #1E293B;
}
.ae-preview .preview-body tr:nth-child(even) td { background: #F8FAFC; }
.preview-placeholder { .preview-placeholder {
font-size: 0.875rem; font-size: 0.875rem;
color: #94A3B8; color: #94A3B8;
font-style: italic; font-style: italic;
margin: 0; margin: 1.5rem 2rem;
}
/* Collapsible metadata */
.ae-meta--collapsed { display: none; }
.ae-toolbar__toggle {
font-size: 0.75rem;
font-weight: 600;
color: #64748B;
background: none;
border: 1px solid #E2E8F0;
border-radius: 4px;
padding: 0.25rem 0.6rem;
cursor: pointer;
flex-shrink: 0;
}
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
/* Word count footer */
.ae-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.25rem 0.875rem;
background: #F8FAFC;
border-top: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-wordcount {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
} }
/* HTMX loading indicator — htmx toggles .htmx-request on the element */ /* HTMX loading indicator — htmx toggles .htmx-request on the element */
@@ -308,6 +274,8 @@
{{ data.get('status', 'draft') }} {{ data.get('status', 'draft') }}
</span> </span>
{% endif %} {% endif %}
<button type="button" class="ae-toolbar__toggle"
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
<button form="ae-form" type="submit" class="btn btn-sm"> <button form="ae-form" type="submit" class="btn btn-sm">
{% if editing %}Save Changes{% else %}Create Article{% endif %} {% if editing %}Save Changes{% else %}Create Article{% endif %}
</button> </button>
@@ -400,6 +368,9 @@
hx-include="[name=csrf_token]" hx-include="[name=csrf_token]"
hx-indicator="#ae-loading" hx-indicator="#ae-loading"
>{{ data.get('body', '') }}</textarea> >{{ data.get('body', '') }}</textarea>
<div class="ae-footer">
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
</div>
</div> </div>
<!-- Right — Rendered preview --> <!-- Right — Rendered preview -->
@@ -408,19 +379,35 @@
<span class="ae-pane__label">Preview</span> <span class="ae-pane__label">Preview</span>
<span id="ae-loading" class="ae-loading">Rendering…</span> <span id="ae-loading" class="ae-loading">Rendering…</span>
</div> </div>
<div class="ae-preview"> <div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
<div id="ae-preview-content"> {% if preview_doc %}
{% if body_html %} <iframe
<div class="preview-body">{{ body_html }}</div> srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin allow-scripts"
title="Article preview"
></iframe>
{% else %} {% else %}
<p class="preview-placeholder">Start writing to see a preview.</p> <p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</div> </div>
</form> </form>
</div> </div>
<script>
(function () {
var textarea = document.getElementById('body');
var counter = document.getElementById('ae-wordcount');
function updateCount() {
var text = textarea.value.trim();
var count = text ? text.split(/\s+/).length : 0;
counter.textContent = count + (count === 1 ? ' word' : ' words');
}
textarea.addEventListener('input', updateCount);
updateCount();
}());
</script>
{% endblock %} {% endblock %}

View File

@@ -70,8 +70,105 @@
</form> </form>
</div> </div>
{# Bulk action bar #}
<form id="article-bulk-form" style="display:none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
<input type="hidden" name="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="template" value="{{ current_template }}">
<input type="hidden" name="language" value="{{ current_language }}">
</form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
<option value="">Action…</option>
<option value="publish">Publish</option>
<option value="unpublish">Unpublish</option>
<option value="toggle_noindex">Toggle noindex</option>
<option value="rebuild">Rebuild</option>
<option value="delete">Delete</option>
</select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
</div>
{# Results #} {# Results #}
<div id="article-results"> <div id="article-results">
{% include "admin/partials/article_results.html" %} {% include "admin/partials/article_results.html" %}
</div> </div>
<script>
const articleSelectedIds = new Set();
function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
updateArticleBulkBar();
}
function toggleArticleGroupSelect(checkbox) {
var ids = (checkbox.dataset.ids || '').split(',').map(Number).filter(Boolean);
ids.forEach(function(id) {
if (checkbox.checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
});
updateArticleBulkBar();
}
function clearArticleSelection() {
articleSelectedIds.clear();
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false;
updateArticleBulkBar();
}
function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar');
var count = document.getElementById('article-bulk-count');
var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(',');
}
function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value;
if (!action) return;
if (articleSelectedIds.size === 0) return;
function doSubmit() {
document.getElementById('article-bulk-action').value = action;
htmx.ajax('POST', '{{ url_for("admin.articles_bulk") }}', {
source: document.getElementById('article-bulk-form'),
target: '#article-results',
swap: 'innerHTML'
});
clearArticleSelection();
}
if (action === 'delete') {
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
if (ok) doSubmit();
});
} else {
doSubmit();
}
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
if (cb.dataset.ids) {
var ids = cb.dataset.ids.split(',').map(Number).filter(Boolean);
cb.checked = ids.length > 0 && ids.every(function(id) { return articleSelectedIds.has(id); });
} else {
cb.checked = articleSelectedIds.has(Number(cb.dataset.id));
}
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -126,8 +126,103 @@
</form> </form>
</div> </div>
<!-- Bulk action bar -->
<form id="lead-bulk-form" style="display:none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="lead_ids" id="lead-bulk-ids" value="">
<input type="hidden" name="action" id="lead-bulk-action" value="">
<input type="hidden" name="target_status" id="lead-bulk-target-status" value="">
<input type="hidden" name="target_heat" id="lead-bulk-target-heat" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="heat" value="{{ current_heat }}">
<input type="hidden" name="country" value="{{ current_country }}">
<input type="hidden" name="days" value="{{ current_days }}">
</form>
<div id="lead-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="lead-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="lead-bulk-action-select" class="form-input" style="min-width:120px;padding:0.25rem 0.5rem;font-size:0.8125rem" onchange="onLeadActionChange()">
<option value="">Action…</option>
<option value="set_status">Set Status</option>
<option value="set_heat">Set Heat</option>
</select>
<select id="lead-status-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
{% for s in statuses %}
<option value="{{ s }}">{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
<select id="lead-heat-select" class="form-input" style="min-width:100px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
{% for h in heat_options %}
<option value="{{ h }}">{{ h | upper }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm" onclick="submitLeadBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearLeadSelection()">Clear</button>
</div>
<!-- Results --> <!-- Results -->
<div id="lead-results"> <div id="lead-results">
{% include "admin/partials/lead_results.html" %} {% include "admin/partials/lead_results.html" %}
</div> </div>
<script>
const leadSelectedIds = new Set();
function toggleLeadSelect(id, checked) {
if (checked) leadSelectedIds.add(id);
else leadSelectedIds.delete(id);
updateLeadBulkBar();
}
function clearLeadSelection() {
leadSelectedIds.clear();
document.querySelectorAll('.lead-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('lead-select-all');
if (selectAll) selectAll.checked = false;
updateLeadBulkBar();
}
function updateLeadBulkBar() {
var bar = document.getElementById('lead-bulk-bar');
var count = document.getElementById('lead-bulk-count');
var ids = document.getElementById('lead-bulk-ids');
bar.style.display = leadSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = leadSelectedIds.size + ' selected';
ids.value = Array.from(leadSelectedIds).join(',');
}
function onLeadActionChange() {
var action = document.getElementById('lead-bulk-action-select').value;
document.getElementById('lead-status-select').style.display = action === 'set_status' ? '' : 'none';
document.getElementById('lead-heat-select').style.display = action === 'set_heat' ? '' : 'none';
}
function submitLeadBulk() {
var action = document.getElementById('lead-bulk-action-select').value;
if (!action) return;
if (leadSelectedIds.size === 0) return;
document.getElementById('lead-bulk-action').value = action;
if (action === 'set_status') {
document.getElementById('lead-bulk-target-status').value = document.getElementById('lead-status-select').value;
} else if (action === 'set_heat') {
document.getElementById('lead-bulk-target-heat').value = document.getElementById('lead-heat-select').value;
}
htmx.ajax('POST', '{{ url_for("admin.leads_bulk") }}', {
source: document.getElementById('lead-bulk-form'),
target: '#lead-results',
swap: 'innerHTML'
});
clearLeadSelection();
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'lead-results') {
document.querySelectorAll('.lead-checkbox').forEach(function(cb) {
if (leadSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,9 @@
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}"> <tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="article-checkbox"
data-ids="{{ g.variants | map(attribute='id') | join(',') }}"
onchange="toggleArticleGroupSelect(this)">
</td>
<td style="max-width:260px"> <td style="max-width:260px">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div> <div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
<div class="article-subtitle">{{ g.url_path }}</div> <div class="article-subtitle">{{ g.url_path }}</div>

View File

@@ -1,5 +1,12 @@
{% if body_html %} {# HTMX partial: sandboxed iframe showing a rendered article preview.
<div class="preview-body">{{ body_html }}</div> Rendered by POST /admin/articles/preview. #}
{% if preview_doc %}
<iframe
srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin allow-scripts"
title="Article preview"
></iframe>
{% else %} {% else %}
<p class="preview-placeholder">Start writing to see a preview.</p> <p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %} {% endif %}

View File

@@ -0,0 +1,15 @@
{# Standalone HTML document used as iframe srcdoc for the article editor preview.
Includes Leaflet so map shortcodes render correctly. #}
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
<style>html,body{margin:0;padding:0}body{padding:2rem 2.5rem}</style>
</head>
<body>
<div class="article-body">{{ body_html | safe }}</div>
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
</body>
</html>

View File

@@ -54,6 +54,11 @@
<table class="table text-sm"> <table class="table text-sm">
<thead> <thead>
<tr> <tr>
{% if not grouped %}
<th style="width:32px"><input type="checkbox" id="article-select-all" onchange="document.querySelectorAll('.article-checkbox').forEach(cb => { cb.checked = this.checked; toggleArticleSelect(Number(cb.dataset.id), this.checked); })"></th>
{% else %}
<th style="width:32px"><input type="checkbox" id="article-select-all" onchange="document.querySelectorAll('.article-checkbox').forEach(cb => { cb.checked = this.checked; toggleArticleGroupSelect(cb); })"></th>
{% endif %}
<th>Title</th> <th>Title</th>
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th> <th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th> <th>Published</th>

View File

@@ -1,4 +1,8 @@
<tr id="article-{{ a.id }}"> <tr id="article-{{ a.id }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="article-checkbox" data-id="{{ a.id }}"
onchange="toggleArticleSelect({{ a.id }}, this.checked)">
</td>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap" <td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
title="{{ a.url_path }}">{{ a.title }}</td> title="{{ a.url_path }}">{{ a.title }}</td>
<td> <td>

View File

@@ -29,6 +29,7 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="lead-select-all" onchange="document.querySelectorAll('.lead-checkbox').forEach(cb => { cb.checked = this.checked; toggleLeadSelect(Number(cb.dataset.id), this.checked); })"></th>
<th>ID</th> <th>ID</th>
<th>Heat</th> <th>Heat</th>
<th>Contact</th> <th>Contact</th>
@@ -43,6 +44,10 @@
<tbody> <tbody>
{% for lead in leads %} {% for lead in leads %}
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}"> <tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="lead-checkbox" data-id="{{ lead.id }}"
onchange="toggleLeadSelect({{ lead.id }}, this.checked)">
</td>
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td> <td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
<td>{{ heat_badge(lead.heat_score) }}</td> <td>{{ heat_badge(lead.heat_score) }}</td>
<td> <td>

View File

@@ -16,8 +16,9 @@
{% set wf = row.workflow %} {% set wf = row.workflow %}
{% set run = row.run %} {% set run = row.run %}
{% set stale = row.stale %} {% set stale = row.stale %}
<div style="border:1px solid #E2E8F0;border-radius:10px;padding:0.875rem;background:#FAFAFA"> {% set is_running = run and run.status == 'running' and not stale %}
<div class="flex items-center gap-2 mb-2"> <div style="border:1px solid {% if is_running %}#93C5FD{% else %}#E2E8F0{% endif %};border-radius:10px;padding:0.875rem;background:{% if is_running %}#EFF6FF{% else %}#FAFAFA{% endif %}">
<div class="flex items-center gap-2 mb-1">
{% if not run %} {% if not run %}
<span class="status-dot pending"></span> <span class="status-dot pending"></span>
{% elif stale %} {% elif stale %}
@@ -33,6 +34,15 @@
{% if stale %} {% if stale %}
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span> <span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
{% endif %} {% endif %}
{% if is_running %}
<span class="btn btn-sm ml-auto"
style="padding:2px 8px;font-size:11px;opacity:0.6;cursor:default;pointer-events:none">
<svg class="spinner-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/>
</svg>
Running
</span>
{% else %}
<button type="button" <button type="button"
class="btn btn-sm ml-auto" class="btn btn-sm ml-auto"
style="padding:2px 8px;font-size:11px" style="padding:2px 8px;font-size:11px"
@@ -41,9 +51,17 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}' hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}'
hx-confirm="Run {{ wf.name }} extractor?">Run</button> hx-confirm="Run {{ wf.name }} extractor?">Run</button>
{% endif %}
</div> </div>
{% if wf.description %}
<p class="text-xs text-slate" style="margin-top:2px;margin-bottom:4px">{{ wf.description }}</p>
{% endif %}
<p class="text-xs text-slate">{{ wf.schedule_label }}</p> <p class="text-xs text-slate">{{ wf.schedule_label }}</p>
{% if run %} {% if is_running %}
<p class="text-xs mt-1" style="color:#2563EB">
Started {{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }} — running...
</p>
{% elif run %}
<p class="text-xs mono text-slate-dark mt-1">{{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }}</p> <p class="text-xs mono text-slate-dark mt-1">{{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }}</p>
{% if run.status == 'failed' and run.error_message %} {% if run.status == 'failed' and run.error_message %}
<p class="text-xs text-danger mt-1" style="font-family:monospace;word-break:break-all"> <p class="text-xs text-danger mt-1" style="font-family:monospace;word-break:break-all">

View File

@@ -171,7 +171,7 @@
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
placeholder="-- SELECT * FROM serving.city_market_profile&#10;-- WHERE country_code = 'DE'&#10;-- ORDER BY marktreife_score DESC&#10;-- LIMIT 20" placeholder="-- SELECT * FROM serving.location_profiles&#10;-- WHERE country_code = 'DE' AND city_slug IS NOT NULL&#10;-- ORDER BY market_score DESC&#10;-- LIMIT 20"
></textarea> ></textarea>
<div class="query-controls"> <div class="query-controls">

View File

@@ -3,6 +3,10 @@
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %} {% block title %}Preview - {{ preview.title }} - Admin{% endblock %}
{% block head %}{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
{% endblock %}
{% block admin_content %} {% block admin_content %}
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">&larr; Back to template</a> <a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">&larr; Back to template</a>
@@ -21,11 +25,14 @@
</div> </div>
</div> </div>
{# Rendered article #} {# Rendered article — overflow:visible needed so Leaflet tile layers render #}
<div class="card"> <div class="card" style="overflow: visible;">
<h2 class="text-lg mb-4">Rendered HTML</h2> <h2 class="text-lg mb-4">Rendered HTML</h2>
<div class="prose" style="max-width: none;"> <div class="article-body" style="max-width: none;">
{{ preview.html | safe }} {{ preview.html | safe }}
</div> </div>
</div> </div>
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -13,7 +13,7 @@ Usage:
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"]) rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])
cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.city_market_profile LIMIT 5") cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.location_profiles LIMIT 5")
""" """
import asyncio import asyncio
import logging import logging

118
web/src/padelnomics/api.py Normal file
View File

@@ -0,0 +1,118 @@
"""
JSON API endpoints for interactive maps.
Serves pre-aggregated geographic data from analytics.duckdb for Leaflet maps.
All responses are JSON with 1-hour public cache headers (data changes at most
daily when the pipeline runs).
"""
from quart import Blueprint, abort, jsonify
from .analytics import fetch_analytics
from .core import fetch_all, is_flag_enabled
bp = Blueprint("api", __name__)
_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"}
async def _require_maps_flag() -> None:
"""Abort with 404 if the maps feature flag is explicitly disabled.
Defaults to enabled (True) so that dev environments without the flag row
in the DB still work. An admin can disable by setting the flag to False.
"""
if not await is_flag_enabled("maps", default=True):
abort(404)
@bp.route("/markets/countries.json")
async def countries():
"""Country-level aggregates for the markets hub map."""
await _require_maps_flag()
rows = await fetch_analytics("""
SELECT country_code, country_name_en, country_slug,
COUNT(*) AS city_count,
SUM(city_padel_venue_count) AS total_venues,
ROUND(AVG(market_score), 1) AS avg_market_score,
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
AVG(lat) AS lat, AVG(lon) AS lon
FROM serving.location_profiles
WHERE city_slug IS NOT NULL
GROUP BY country_code, country_name_en, country_slug
HAVING SUM(city_padel_venue_count) > 0
ORDER BY total_venues DESC
""")
return jsonify(rows), 200, _CACHE_HEADERS
@bp.route("/markets/<country_slug>/cities.json")
async def country_cities(country_slug: str):
"""City-level data for a country overview bubble map."""
await _require_maps_flag()
assert country_slug, "country_slug required"
rows = await fetch_analytics(
"""
SELECT city_name, city_slug, lat, lon,
city_padel_venue_count AS padel_venue_count,
market_score, opportunity_score, population
FROM serving.location_profiles
WHERE country_slug = ? AND city_slug IS NOT NULL
ORDER BY city_padel_venue_count DESC
LIMIT 200
""",
[country_slug],
)
# Check which cities have published articles (any language).
article_rows = await fetch_all(
"""SELECT url_path FROM articles
WHERE url_path LIKE ? AND status = 'published'
AND published_at <= datetime('now')""",
(f"/markets/{country_slug}/%",),
)
article_slugs = set()
for a in article_rows:
parts = a["url_path"].rstrip("/").split("/")
if len(parts) >= 4:
article_slugs.add(parts[3])
for row in rows:
row["has_article"] = row["city_slug"] in article_slugs
return jsonify(rows), 200, _CACHE_HEADERS
@bp.route("/markets/<country_slug>/<city_slug>/venues.json")
async def city_venues(country_slug: str, city_slug: str):
"""Venue-level dots for the city detail map."""
await _require_maps_flag()
assert country_slug and city_slug, "country_slug and city_slug required"
rows = await fetch_analytics(
"""
SELECT name, lat, lon, court_count,
indoor_court_count, outdoor_court_count
FROM serving.city_venue_locations
WHERE country_slug = ? AND city_slug = ?
LIMIT 500
""",
[country_slug, city_slug],
)
return jsonify(rows), 200, _CACHE_HEADERS
@bp.route("/opportunity/<country_slug>.json")
async def opportunity(country_slug: str):
"""Location-level opportunity scores for the opportunity map."""
await _require_maps_flag()
assert country_slug, "country_slug required"
rows = await fetch_analytics(
"""
SELECT location_name, location_slug, lat, lon,
opportunity_score, market_score,
nearest_padel_court_km,
padel_venue_count, population
FROM serving.location_profiles
WHERE country_slug = ? AND opportunity_score > 0
ORDER BY opportunity_score DESC
LIMIT 500
""",
[country_slug],
)
return jsonify(rows), 200, _CACHE_HEADERS

View File

@@ -5,7 +5,7 @@ import json
import time import time
from pathlib import Path from pathlib import Path
from quart import Quart, Response, abort, g, redirect, request, session, url_for from quart import Quart, Response, abort, g, redirect, render_template, request, session, url_for
from .analytics import close_analytics_db, open_analytics_db from .analytics import close_analytics_db, open_analytics_db
from .core import ( from .core import (
@@ -270,6 +270,40 @@ def create_app() -> Quart:
from .sitemap import sitemap_response from .sitemap import sitemap_response
return await sitemap_response(config.BASE_URL) return await sitemap_response(config.BASE_URL)
# -------------------------------------------------------------------------
# Error pages
# -------------------------------------------------------------------------
def _error_lang() -> str:
"""Best-effort language from URL path prefix (no g.lang in error handlers)."""
path = request.path
if path.startswith("/de/"):
return "de"
return "en"
@app.errorhandler(404)
async def handle_404(error):
import re
lang = _error_lang()
t = get_translations(lang)
country_slug = None
country_name = None
m = re.match(r"^/(?:en|de)/markets/([^/]+)/[^/]+/?$", request.path)
if m:
country_slug = m.group(1)
country_name = country_slug.replace("-", " ").title()
return await render_template(
"404.html", lang=lang, t=t, country_slug=country_slug,
country_name=country_name or "",
), 404
@app.errorhandler(500)
async def handle_500(error):
app.logger.exception("Unhandled 500 error: %s", error)
lang = _error_lang()
t = get_translations(lang)
return await render_template("500.html", lang=lang, t=t), 500
# Health check # Health check
@app.route("/health") @app.route("/health")
async def health(): async def health():
@@ -362,6 +396,7 @@ def create_app() -> Quart:
from .admin.pipeline_routes import bp as pipeline_bp from .admin.pipeline_routes import bp as pipeline_bp
from .admin.pseo_routes import bp as pseo_bp from .admin.pseo_routes import bp as pseo_bp
from .admin.routes import bp as admin_bp from .admin.routes import bp as admin_bp
from .api import bp as api_bp
from .auth.routes import bp as auth_bp from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp from .billing.routes import bp as billing_bp
from .content.routes import bp as content_bp from .content.routes import bp as content_bp
@@ -391,6 +426,9 @@ def create_app() -> Quart:
app.register_blueprint(pipeline_bp) app.register_blueprint(pipeline_bp)
app.register_blueprint(webhooks_bp) app.register_blueprint(webhooks_bp)
# JSON API for interactive maps (no lang prefix)
app.register_blueprint(api_bp, url_prefix="/api")
# Content catch-all LAST — lives under /<lang> too # Content catch-all LAST — lives under /<lang> too
app.register_blueprint(content_bp, url_prefix="/<lang>") app.register_blueprint(content_bp, url_prefix="/<lang>")

View File

View File

@@ -0,0 +1,116 @@
"""
Paddle payment provider — checkout, webhook verification, subscription management.
Exports the 5 functions that billing/routes.py dispatches to:
- build_checkout_payload()
- build_multi_item_checkout_payload()
- cancel_subscription()
- get_management_url()
- handle_webhook()
"""
import json
from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options
from paddle_billing.Notifications import Secret, Verifier
from ..core import config
def _paddle_client() -> PaddleClient:
"""Create a Paddle SDK client."""
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
return PaddleClient(config.PADDLE_API_KEY, options=Options(env))
class _WebhookRequest:
"""Minimal wrapper satisfying paddle_billing's Request Protocol."""
def __init__(self, body: bytes, headers):
self.body = body
self.headers = headers
_verifier = Verifier(maximum_variance=300)
def build_checkout_payload(
price_id: str, custom_data: dict, success_url: str,
) -> dict:
"""Build JSON payload for a single-item Paddle.js overlay checkout."""
return {
"items": [{"priceId": price_id, "quantity": 1}],
"customData": custom_data,
"settings": {"successUrl": success_url},
}
def build_multi_item_checkout_payload(
items: list[dict], custom_data: dict, success_url: str,
) -> dict:
"""Build JSON payload for a multi-item Paddle.js overlay checkout."""
return {
"items": items,
"customData": custom_data,
"settings": {"successUrl": success_url},
}
def cancel_subscription(provider_subscription_id: str) -> None:
"""Cancel a Paddle subscription at end of current billing period."""
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
paddle = _paddle_client()
paddle.subscriptions.cancel(
provider_subscription_id,
CancelSubscription(effective_from="next_billing_period"),
)
def get_management_url(provider_subscription_id: str) -> str:
"""Get the Paddle customer portal URL for updating payment method."""
paddle = _paddle_client()
paddle_sub = paddle.subscriptions.get(provider_subscription_id)
return paddle_sub.management_urls.update_payment_method
def verify_webhook(payload: bytes, headers) -> bool:
"""Verify Paddle webhook signature. Returns True if valid or no secret configured."""
if not config.PADDLE_WEBHOOK_SECRET:
return True
try:
return _verifier.verify(
_WebhookRequest(payload, headers),
Secret(config.PADDLE_WEBHOOK_SECRET),
)
except (ConnectionRefusedError, ValueError):
return False
def parse_webhook(payload: bytes) -> dict:
"""Parse a Paddle webhook payload into a normalized event dict.
Returns dict with keys: event_type, subscription_id, customer_id,
user_id, supplier_id, plan, status, current_period_end, data, items.
"""
event = json.loads(payload)
event_type = event.get("event_type", "")
data = event.get("data") or {}
custom_data = data.get("custom_data") or {}
billing_period = data.get("current_billing_period") or {}
return {
"event_type": event_type,
"subscription_id": data.get("id", ""),
"customer_id": str(data.get("customer_id", "")),
"user_id": custom_data.get("user_id"),
"supplier_id": custom_data.get("supplier_id"),
"plan": custom_data.get("plan", ""),
"status": data.get("status", ""),
"current_period_end": billing_period.get("ends_at"),
"data": data,
"items": data.get("items", []),
"custom_data": custom_data,
}

View File

@@ -1,6 +1,9 @@
""" """
Billing domain: checkout, webhooks, subscription management. Billing domain: checkout, webhooks, subscription management.
Payment provider: paddle
Provider dispatch: PAYMENT_PROVIDER env var selects 'paddle' or 'stripe'.
Both webhook endpoints (/webhook/paddle and /webhook/stripe) stay active
regardless of the toggle — existing subscribers keep sending webhooks.
""" """
import json import json
@@ -8,20 +11,21 @@ import secrets
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options
from paddle_billing.Notifications import Secret, Verifier
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
from ..auth.routes import login_required from ..auth.routes import login_required
from ..core import config, execute, fetch_one, get_paddle_price, utcnow, utcnow_iso from ..core import config, execute, fetch_one, get_price_id, utcnow, utcnow_iso
from ..i18n import get_translations from ..i18n import get_translations
def _paddle_client() -> PaddleClient: def _provider():
"""Create a Paddle SDK client. Used only for subscription management + webhook verification.""" """Return the active payment provider module."""
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION if config.PAYMENT_PROVIDER == "stripe":
return PaddleClient(config.PADDLE_API_KEY, options=Options(env)) from . import stripe as mod
else:
from . import paddle as mod
return mod
# Blueprint with its own template folder # Blueprint with its own template folder
bp = Blueprint( bp = Blueprint(
@@ -33,7 +37,7 @@ bp = Blueprint(
# ============================================================================= # =============================================================================
# SQL Queries # SQL Queries (provider-agnostic)
# ============================================================================= # =============================================================================
async def get_subscription(user_id: int) -> dict | None: async def get_subscription(user_id: int) -> dict | None:
@@ -132,7 +136,7 @@ async def is_within_limits(user_id: int, resource: str, current_count: int) -> b
# ============================================================================= # =============================================================================
# Routes # Routes (provider-agnostic)
# ============================================================================= # =============================================================================
@bp.route("/pricing") @bp.route("/pricing")
@@ -151,129 +155,171 @@ async def success():
return await render_template("success.html") return await render_template("success.html")
# ============================================================================= # =============================================================================
# Paddle Implementation — Paddle.js Overlay Checkout # Checkout / Manage / Cancel — dispatched to active provider
# ============================================================================= # =============================================================================
@bp.route("/checkout/<plan>", methods=["POST"]) @bp.route("/checkout/<plan>", methods=["POST"])
@login_required @login_required
async def checkout(plan: str): async def checkout(plan: str):
"""Return JSON for Paddle.js overlay checkout.""" """Return JSON for checkout (overlay for Paddle, redirect URL for Stripe)."""
price_id = await get_paddle_price(plan) price_id = await get_price_id(plan)
if not price_id: if not price_id:
return jsonify({"error": "Invalid plan selected."}), 400 return jsonify({"error": "Invalid plan selected."}), 400
return jsonify({ payload = _provider().build_checkout_payload(
"items": [{"priceId": price_id, "quantity": 1}], price_id=price_id,
"customData": {"user_id": str(g.user["id"]), "plan": plan}, custom_data={"user_id": str(g.user["id"]), "plan": plan},
"settings": { success_url=f"{config.BASE_URL}/billing/success",
"successUrl": f"{config.BASE_URL}/billing/success", )
}, return jsonify(payload)
})
@bp.route("/checkout/item", methods=["POST"])
@login_required
async def checkout_item():
"""Return checkout JSON for a single item (boost, credit pack, etc.).
Used by dashboard boost/credit buttons that need a server round-trip
for Stripe (Checkout Session creation) and work with Paddle overlay too.
Expects JSON body: {price_key, custom_data, success_url?}
"""
body = await request.get_json(silent=True) or {}
price_key = body.get("price_key", "")
custom_data = body.get("custom_data", {})
success_url = body.get("success_url", f"{config.BASE_URL}/suppliers/dashboard?tab=boosts")
price_id = await get_price_id(price_key)
if not price_id:
return jsonify({"error": "Product not configured."}), 400
payload = _provider().build_checkout_payload(
price_id=price_id,
custom_data=custom_data,
success_url=success_url,
)
return jsonify(payload)
@bp.route("/manage", methods=["POST"]) @bp.route("/manage", methods=["POST"])
@login_required @login_required
async def manage(): async def manage():
"""Redirect to Paddle customer portal.""" """Redirect to payment provider's customer portal."""
sub = await get_subscription(g.user["id"]) sub = await get_subscription(g.user["id"])
if not sub or not sub.get("provider_subscription_id"): if not sub or not sub.get("provider_subscription_id"):
t = get_translations(g.get("lang") or "en") t = get_translations(g.get("lang") or "en")
await flash(t["billing_no_subscription"], "error") await flash(t["billing_no_subscription"], "error")
return redirect(url_for("dashboard.settings")) return redirect(url_for("dashboard.settings"))
paddle = _paddle_client() portal_url = _provider().get_management_url(sub["provider_subscription_id"])
paddle_sub = paddle.subscriptions.get(sub["provider_subscription_id"])
portal_url = paddle_sub.management_urls.update_payment_method
return redirect(portal_url) return redirect(portal_url)
@bp.route("/cancel", methods=["POST"]) @bp.route("/cancel", methods=["POST"])
@login_required @login_required
async def cancel(): async def cancel():
"""Cancel subscription via Paddle API.""" """Cancel subscription via active payment provider."""
sub = await get_subscription(g.user["id"]) sub = await get_subscription(g.user["id"])
if sub and sub.get("provider_subscription_id"): if sub and sub.get("provider_subscription_id"):
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription _provider().cancel_subscription(sub["provider_subscription_id"])
paddle = _paddle_client()
paddle.subscriptions.cancel(
sub["provider_subscription_id"],
CancelSubscription(effective_from="next_billing_period"),
)
return redirect(url_for("dashboard.settings")) return redirect(url_for("dashboard.settings"))
class _WebhookRequest: # =============================================================================
"""Minimal wrapper satisfying paddle_billing's Request Protocol.""" # Paddle Webhook — always active (existing subscribers keep sending)
def __init__(self, body: bytes, headers): # =============================================================================
self.body = body
self.headers = headers
_verifier = Verifier(maximum_variance=300)
@bp.route("/webhook/paddle", methods=["POST"]) @bp.route("/webhook/paddle", methods=["POST"])
async def webhook(): async def webhook_paddle():
"""Handle Paddle webhooks.""" """Handle Paddle webhooks — always active regardless of PAYMENT_PROVIDER toggle."""
from . import paddle as paddle_mod
payload = await request.get_data() payload = await request.get_data()
if config.PADDLE_WEBHOOK_SECRET: if not paddle_mod.verify_webhook(payload, request.headers):
try:
ok = _verifier.verify(
_WebhookRequest(payload, request.headers),
Secret(config.PADDLE_WEBHOOK_SECRET),
)
except (ConnectionRefusedError, ValueError):
ok = False
if not ok:
return jsonify({"error": "Invalid signature"}), 400 return jsonify({"error": "Invalid signature"}), 400
try: try:
event = json.loads(payload) ev = paddle_mod.parse_webhook(payload)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
return jsonify({"error": "Invalid JSON payload"}), 400 return jsonify({"error": "Invalid JSON payload"}), 400
event_type = event.get("event_type")
data = event.get("data") or {}
custom_data = data.get("custom_data") or {}
user_id = custom_data.get("user_id")
plan = custom_data.get("plan", "")
# Store billing customer for any subscription event with a customer_id await _handle_webhook_event(ev)
customer_id = str(data.get("customer_id", "")) return jsonify({"received": True})
# =============================================================================
# Stripe Webhook — always active (once Stripe is configured)
# =============================================================================
@bp.route("/webhook/stripe", methods=["POST"])
async def webhook_stripe():
"""Handle Stripe webhooks — always active regardless of PAYMENT_PROVIDER toggle."""
if not config.STRIPE_WEBHOOK_SECRET:
return jsonify({"error": "Stripe not configured"}), 404
from . import stripe as stripe_mod
payload = await request.get_data()
if not stripe_mod.verify_webhook(payload, request.headers):
return jsonify({"error": "Invalid signature"}), 400
try:
ev = stripe_mod.parse_webhook(payload)
except (json.JSONDecodeError, ValueError):
return jsonify({"error": "Invalid payload"}), 400
await _handle_webhook_event(ev)
return jsonify({"received": True})
# =============================================================================
# Shared Webhook Event Handler (provider-agnostic)
# =============================================================================
async def _handle_webhook_event(ev: dict) -> None:
"""Process a normalized webhook event from any provider.
ev keys: event_type, subscription_id, customer_id, user_id, supplier_id,
plan, status, current_period_end, data, items, custom_data
"""
event_type = ev.get("event_type", "")
user_id = ev.get("user_id")
plan = ev.get("plan", "")
# Store billing customer
customer_id = ev.get("customer_id", "")
if customer_id and user_id: if customer_id and user_id:
await upsert_billing_customer(int(user_id), customer_id) await upsert_billing_customer(int(user_id), customer_id)
if event_type == "subscription.activated": if event_type == "subscription.activated":
if plan.startswith("supplier_"): if plan.startswith("supplier_"):
await _handle_supplier_subscription_activated(data, custom_data) await _handle_supplier_subscription_activated(ev)
elif user_id: elif user_id:
await upsert_subscription( await upsert_subscription(
user_id=int(user_id), user_id=int(user_id),
plan=plan or "starter", plan=plan or "starter",
status="active", status="active",
provider_subscription_id=data.get("id", ""), provider_subscription_id=ev.get("subscription_id", ""),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), current_period_end=ev.get("current_period_end"),
) )
elif event_type == "subscription.updated": elif event_type == "subscription.updated":
await update_subscription_status( await update_subscription_status(
data.get("id", ""), ev.get("subscription_id", ""),
status=data.get("status", "active"), status=ev.get("status", "active"),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), current_period_end=ev.get("current_period_end"),
) )
elif event_type == "subscription.canceled": elif event_type == "subscription.canceled":
await update_subscription_status(data.get("id", ""), status="cancelled") await update_subscription_status(ev.get("subscription_id", ""), status="cancelled")
elif event_type == "subscription.past_due": elif event_type == "subscription.past_due":
await update_subscription_status(data.get("id", ""), status="past_due") await update_subscription_status(ev.get("subscription_id", ""), status="past_due")
elif event_type == "transaction.completed": elif event_type == "transaction.completed":
await _handle_transaction_completed(data, custom_data) await _handle_transaction_completed(ev)
return jsonify({"received": True})
# ============================================================================= # =============================================================================
@@ -301,7 +347,13 @@ BOOST_PRICE_KEYS = {
async def _price_id_to_key(price_id: str) -> str | None: async def _price_id_to_key(price_id: str) -> str | None:
"""Reverse-lookup a paddle_products key from a Paddle price ID.""" """Reverse-lookup a product key from a provider price ID."""
row = await fetch_one(
"SELECT key FROM payment_products WHERE provider_price_id = ?", (price_id,)
)
if row:
return row["key"]
# Fallback to old table for pre-migration DBs
row = await fetch_one( row = await fetch_one(
"SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,) "SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,)
) )
@@ -330,13 +382,13 @@ def _derive_tier_from_plan(plan: str) -> tuple[str, str]:
return base, tier return base, tier
async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None: async def _handle_supplier_subscription_activated(ev: dict) -> None:
"""Handle supplier plan subscription activation.""" """Handle supplier plan subscription activation."""
from ..core import transaction as db_transaction from ..core import transaction as db_transaction
supplier_id = custom_data.get("supplier_id") supplier_id = ev.get("supplier_id")
plan = custom_data.get("plan", "supplier_growth") plan = ev.get("plan", "supplier_growth")
user_id = custom_data.get("user_id") user_id = ev.get("user_id")
if not supplier_id: if not supplier_id:
return return
@@ -365,7 +417,8 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
) )
# Create boost records for items included in the subscription # Create boost records for items included in the subscription
items = data.get("items", []) items = ev.get("items", [])
data = ev.get("data", {})
for item in items: for item in items:
price_id = item.get("price", {}).get("id", "") price_id = item.get("price", {}).get("id", "")
key = await _price_id_to_key(price_id) key = await _price_id_to_key(price_id)
@@ -388,13 +441,15 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
) )
async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: async def _handle_transaction_completed(ev: dict) -> None:
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan).""" """Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
supplier_id = custom_data.get("supplier_id") supplier_id = ev.get("supplier_id")
user_id = custom_data.get("user_id") user_id = ev.get("user_id")
custom_data = ev.get("custom_data", {})
data = ev.get("data", {})
now = utcnow_iso() now = utcnow_iso()
items = data.get("items", []) items = ev.get("items", [])
for item in items: for item in items:
price_id = item.get("price", {}).get("id", "") price_id = item.get("price", {}).get("id", "")
key = await _price_id_to_key(price_id) key = await _price_id_to_key(price_id)

View File

@@ -0,0 +1,378 @@
"""
Stripe payment provider — checkout sessions, webhook handling, subscription management.
Exports the same interface as paddle.py so billing/routes.py can dispatch:
- build_checkout_payload()
- build_multi_item_checkout_payload()
- cancel_subscription()
- get_management_url()
- verify_webhook()
- parse_webhook()
Stripe Tax add-on handles EU VAT collection (must be enabled in Stripe Dashboard).
"""
import json
import logging
import stripe as stripe_sdk
from ..core import config
logger = logging.getLogger(__name__)
def _stripe_client():
"""Configure and return the stripe module with our API key."""
stripe_sdk.api_key = config.STRIPE_SECRET_KEY
stripe_sdk.max_network_retries = 2
return stripe_sdk
def build_checkout_payload(
price_id: str, custom_data: dict, success_url: str,
) -> dict:
"""Create a Stripe Checkout Session for a single item.
Returns {checkout_url: "https://checkout.stripe.com/..."} — the client
JS redirects the browser there (no overlay SDK needed).
"""
s = _stripe_client()
session = s.checkout.Session.create(
mode=_mode_for_price(s, price_id),
line_items=[{"price": price_id, "quantity": 1}],
metadata=custom_data,
success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=success_url.rsplit("/success", 1)[0] + "/pricing",
automatic_tax={"enabled": True},
tax_id_collection={"enabled": True},
)
return {"checkout_url": session.url}
def build_multi_item_checkout_payload(
items: list[dict], custom_data: dict, success_url: str,
) -> dict:
"""Create a Stripe Checkout Session for multiple line items.
items: list of {"priceId": "price_xxx", "quantity": 1}
"""
s = _stripe_client()
line_items = [{"price": i["priceId"], "quantity": i.get("quantity", 1)} for i in items]
# Determine mode: if any item is recurring, use "subscription".
# Otherwise use "payment" for one-time purchases.
has_recurring = any(_is_recurring_price(s, i["priceId"]) for i in items)
mode = "subscription" if has_recurring else "payment"
session = s.checkout.Session.create(
mode=mode,
line_items=line_items,
metadata=custom_data,
success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=success_url.rsplit("/success", 1)[0],
automatic_tax={"enabled": True},
tax_id_collection={"enabled": True},
)
return {"checkout_url": session.url}
def _mode_for_price(s, price_id: str) -> str:
"""Determine Checkout Session mode from price type."""
try:
price = s.Price.retrieve(price_id)
return "subscription" if price.type == "recurring" else "payment"
except Exception:
# Default to payment if we can't determine
return "payment"
def _is_recurring_price(s, price_id: str) -> bool:
"""Check if a Stripe price is recurring (subscription)."""
try:
price = s.Price.retrieve(price_id)
return price.type == "recurring"
except Exception:
return False
def cancel_subscription(provider_subscription_id: str) -> None:
"""Cancel a Stripe subscription at end of current billing period."""
s = _stripe_client()
s.Subscription.modify(
provider_subscription_id,
cancel_at_period_end=True,
)
def get_management_url(provider_subscription_id: str) -> str:
"""Create a Stripe Billing Portal session and return its URL."""
s = _stripe_client()
# Get customer_id from the subscription
sub = s.Subscription.retrieve(
provider_subscription_id,
)
portal = s.billing_portal.Session.create(
customer=sub.customer,
return_url=f"{config.BASE_URL}/billing/success",
)
return portal.url
def verify_webhook(payload: bytes, headers) -> bool:
"""Verify Stripe webhook signature using the Stripe-Signature header."""
if not config.STRIPE_WEBHOOK_SECRET:
return True
sig_header = headers.get("Stripe-Signature", "")
if not sig_header:
return False
try:
stripe_sdk.Webhook.construct_event(
payload, sig_header, config.STRIPE_WEBHOOK_SECRET,
)
return True
except (stripe_sdk.SignatureVerificationError, ValueError):
return False
def parse_webhook(payload: bytes) -> dict:
"""Parse a Stripe webhook payload into a normalized event dict.
Maps Stripe event types to the shared format used by _handle_webhook_event():
- checkout.session.completed (mode=subscription) → subscription.activated
- customer.subscription.created → subscription.activated
- customer.subscription.updated → subscription.updated
- customer.subscription.deleted → subscription.canceled
- invoice.payment_failed → subscription.past_due
- checkout.session.completed (mode=payment) → transaction.completed
"""
raw = json.loads(payload)
stripe_type = raw.get("type", "")
obj = raw.get("data", {}).get("object", {})
# Extract metadata — Stripe stores custom data in session/subscription metadata
metadata = obj.get("metadata") or {}
# Common fields
customer_id = obj.get("customer", "")
user_id = metadata.get("user_id")
supplier_id = metadata.get("supplier_id")
plan = metadata.get("plan", "")
# Map Stripe events to our shared event types
if stripe_type == "checkout.session.completed":
mode = obj.get("mode", "")
if mode == "subscription":
subscription_id = obj.get("subscription", "")
# Fetch subscription details for period end
period_end = None
if subscription_id:
try:
s = _stripe_client()
sub = s.Subscription.retrieve(
subscription_id,
)
# Stripe API 2026-02+ moved period_end to items
ts = sub.current_period_end
if not ts and sub.get("items", {}).get("data"):
ts = sub["items"]["data"][0].get("current_period_end")
period_end = _unix_to_iso(ts)
except Exception:
logger.warning("Failed to fetch subscription %s for period_end", subscription_id)
return {
"event_type": "subscription.activated",
"subscription_id": subscription_id,
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "active",
"current_period_end": period_end,
"data": obj,
"items": _extract_line_items(obj),
"custom_data": metadata,
}
else:
# One-time payment
return {
"event_type": "transaction.completed",
"subscription_id": "",
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "completed",
"current_period_end": None,
"data": obj,
"items": _extract_line_items(obj),
"custom_data": metadata,
}
elif stripe_type == "customer.subscription.created":
# New subscription — map to subscription.activated so the handler creates the DB row
status = _map_stripe_status(obj.get("status", ""))
return {
"event_type": "subscription.activated",
"subscription_id": obj.get("id", ""),
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": status,
"current_period_end": _get_period_end(obj),
"data": obj,
"items": _extract_sub_items(obj),
"custom_data": metadata,
}
elif stripe_type == "customer.subscription.updated":
status = _map_stripe_status(obj.get("status", ""))
return {
"event_type": "subscription.updated",
"subscription_id": obj.get("id", ""),
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": status,
"current_period_end": _get_period_end(obj),
"data": obj,
"items": _extract_sub_items(obj),
"custom_data": metadata,
}
elif stripe_type == "customer.subscription.deleted":
return {
"event_type": "subscription.canceled",
"subscription_id": obj.get("id", ""),
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "cancelled",
"current_period_end": _get_period_end(obj),
"data": obj,
"items": _extract_sub_items(obj),
"custom_data": metadata,
}
elif stripe_type == "invoice.payment_failed":
sub_id = obj.get("subscription", "")
return {
"event_type": "subscription.past_due",
"subscription_id": sub_id,
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "past_due",
"current_period_end": None,
"data": obj,
"items": [],
"custom_data": metadata,
}
# Unknown event — return a no-op
return {
"event_type": "",
"subscription_id": "",
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "",
"current_period_end": None,
"data": obj,
"items": [],
"custom_data": metadata,
}
# =============================================================================
# Helpers
# =============================================================================
def _map_stripe_status(stripe_status: str) -> str:
"""Map Stripe subscription status to our internal status."""
mapping = {
"active": "active",
"trialing": "on_trial",
"past_due": "past_due",
"canceled": "cancelled",
"unpaid": "past_due",
"incomplete": "past_due",
"incomplete_expired": "expired",
"paused": "paused",
}
return mapping.get(stripe_status, stripe_status)
def _unix_to_iso(ts) -> str | None:
"""Convert Unix timestamp to ISO string, or None."""
if not ts:
return None
from datetime import UTC, datetime
return datetime.fromtimestamp(int(ts), tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.000000Z")
def _get_period_end(obj: dict) -> str | None:
"""Extract current_period_end from subscription or its first item.
Stripe API 2026-02+ moved period fields from subscription to subscription items.
"""
ts = obj.get("current_period_end")
if not ts:
items = obj.get("items", {}).get("data", [])
if items:
ts = items[0].get("current_period_end")
return _unix_to_iso(ts)
def _extract_line_items(session_obj: dict) -> list[dict]:
"""Extract line items from a Checkout Session in Paddle-compatible format.
Stripe doesn't embed line_items in checkout.session.completed webhooks,
so we fetch them via the API. Returns [{"price": {"id": "price_xxx"}}].
"""
session_id = session_obj.get("id", "")
if not session_id or not session_id.startswith("cs_"):
return []
try:
s = _stripe_client()
line_items = s.checkout.Session.list_line_items(session_id, limit=20)
return [
{"price": {"id": item["price"]["id"]}}
for item in line_items.get("data", [])
if item.get("price", {}).get("id")
]
except Exception:
logger.warning("Failed to fetch line_items for session %s", session_id)
# Fallback: check if line_items were embedded in the payload (e.g. tests)
embedded = session_obj.get("line_items", {}).get("data", [])
return [
{"price": {"id": item["price"]["id"]}}
for item in embedded
if item.get("price", {}).get("id")
]
def _extract_sub_items(sub_obj: dict) -> list[dict]:
"""Extract items from a Stripe Subscription object in Paddle-compatible format."""
items = sub_obj.get("items", {}).get("data", [])
return [{"price": {"id": item.get("price", {}).get("id", "")}} for item in items]

View File

@@ -31,6 +31,7 @@
} }
</script> </script>
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -57,3 +58,8 @@
</article> </article>
</main> </main>
{% endblock %} {% endblock %}
{% block scripts %}
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
{% endblock %}

View File

@@ -39,6 +39,8 @@ priority_column: population
</div> </div>
</div> </div>
<div id="city-map" data-country-slug="{{ country_slug }}" data-city-slug="{{ city_slug }}" data-lat="{{ lat }}" data-lon="{{ lon }}" style="height:300px; border-radius:12px; margin-bottom:1.5rem;"></div>
{{ city_name }} erreicht einen **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 55 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 35 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.{% if opportunity_score %} Der **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> von {{ opportunity_score | round(1) }}/100** bewertet das Investitionspotenzial — Versorgungslücken, Einzugsgebiet und Sportaffinität der Region:{% if opportunity_score >= 65 and market_score < 40 %} überschaubare Konkurrenz trifft auf starkes Standortpotenzial{% elif opportunity_score >= 65 %} hohes Potenzial trotz bereits aktivem Marktumfeld{% elif opportunity_score >= 40 %} solides Potenzial, der Markt beginnt sich zu verdichten{% else %} der Standort ist vergleichsweise gut versorgt, Differenzierung wird zum Schlüssel{% endif %}.{% endif %} {{ city_name }} erreicht einen **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 55 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 35 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.{% if opportunity_score %} Der **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> von {{ opportunity_score | round(1) }}/100** bewertet das Investitionspotenzial — Versorgungslücken, Einzugsgebiet und Sportaffinität der Region:{% if opportunity_score >= 65 and market_score < 40 %} überschaubare Konkurrenz trifft auf starkes Standortpotenzial{% elif opportunity_score >= 65 %} hohes Potenzial trotz bereits aktivem Marktumfeld{% elif opportunity_score >= 40 %} solides Potenzial, der Markt beginnt sich zu verdichten{% else %} der Standort ist vergleichsweise gut versorgt, Differenzierung wird zum Schlüssel{% endif %}.{% endif %}
Die entscheidende Frage für Investoren: Was bringt ein Padel-Investment bei den aktuellen Preisen, Auslastungsraten und Baukosten tatsächlich? Das Finanzmodell unten rechnet mit echten Marktdaten aus {{ city_name }}. Die entscheidende Frage für Investoren: Was bringt ein Padel-Investment bei den aktuellen Preisen, Auslastungsraten und Baukosten tatsächlich? Das Finanzmodell unten rechnet mit echten Marktdaten aus {{ city_name }}.
@@ -179,6 +181,8 @@ Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*:
</div> </div>
</div> </div>
<div id="city-map" data-country-slug="{{ country_slug }}" data-city-slug="{{ city_slug }}" data-lat="{{ lat }}" data-lon="{{ lon }}" style="height:300px; border-radius:12px; margin-bottom:1.5rem;"></div>
{{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 55 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 35 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.{% if opportunity_score %} The **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ opportunity_score | round(1) }}/100** scores investment potential — supply gaps, catchment reach, and sports culture as a demand proxy:{% if opportunity_score >= 65 and market_score < 40 %} limited competition meets strong location fundamentals{% elif opportunity_score >= 65 %} strong potential despite an already active market{% elif opportunity_score >= 40 %} solid potential as the market starts to fill in{% else %} the area is comparatively well-served; differentiation is the key lever{% endif %}.{% endif %} {{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 55 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 35 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.{% if opportunity_score %} The **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ opportunity_score | round(1) }}/100** scores investment potential — supply gaps, catchment reach, and sports culture as a demand proxy:{% if opportunity_score >= 65 and market_score < 40 %} limited competition meets strong location fundamentals{% elif opportunity_score >= 65 %} strong potential despite an already active market{% elif opportunity_score >= 40 %} solid potential as the market starts to fill in{% else %} the area is comparatively well-served; differentiation is the key lever{% endif %}.{% endif %}
The question that matters: given current pricing, occupancy, and build costs, what does a padel investment in {{ city_name }} actually return? The financial model below works with real local market data. The question that matters: given current pricing, occupancy, and build costs, what does a padel investment in {{ city_name }} actually return? The financial model below works with real local market data.

View File

@@ -40,6 +40,8 @@ priority_column: total_venues
</div> </div>
</div> </div>
<div id="country-map" data-country-slug="{{ country_slug }}" style="height:360px; border-radius:12px; margin-bottom:1.5rem;"></div>
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 55 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 35 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}. In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 55 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 35 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}.
## Marktlandschaft ## Marktlandschaft
@@ -172,6 +174,8 @@ Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Be
</div> </div>
</div> </div>
<div id="country-map" data-country-slug="{{ country_slug }}" style="height:360px; border-radius:12px; margin-bottom:1.5rem;"></div>
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 55 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 35 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}. {{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 55 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 35 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}.
## Market Landscape ## Market Landscape

View File

@@ -6,6 +6,7 @@
<meta name="description" content="{{ t.markets_page_description }}"> <meta name="description" content="{{ t.markets_page_description }}">
<meta property="og:title" content="{{ t.markets_page_og_title }} - {{ config.APP_NAME }}"> <meta property="og:title" content="{{ t.markets_page_og_title }} - {{ config.APP_NAME }}">
<meta property="og:description" content="{{ t.markets_page_og_description }}"> <meta property="og:description" content="{{ t.markets_page_og_description }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -15,6 +16,8 @@
<p class="text-slate">{{ t.mkt_subheading }}</p> <p class="text-slate">{{ t.mkt_subheading }}</p>
</header> </header>
<div id="markets-map" style="height:420px; border-radius:12px;" class="mb-6"></div>
<!-- Filters --> <!-- Filters -->
<div class="card mb-8"> <div class="card mb-8">
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;"> <div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;">
@@ -62,3 +65,54 @@
</div> </div>
</main> </main>
{% endblock %} {% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script>
(function() {
var map = L.map('markets-map', {scrollWheelZoom: false}).setView([48.5, 10], 4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18
}).addTo(map);
function scoreColor(score) {
if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706';
return '#DC2626';
}
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
fetch('/api/markets/countries.json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) return;
var maxV = Math.max.apply(null, data.map(function(d) { return d.total_venues; }));
var lang = document.documentElement.lang || 'en';
data.forEach(function(c) {
if (!c.lat || !c.lon) return;
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
var color = scoreColor(c.avg_market_score);
var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6');
var tip = '<strong>' + c.country_name_en + '</strong><br>'
+ c.total_venues + ' venues · ' + c.city_count + ' cities<br>'
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>'
+ '<span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span>';
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
.addTo(map);
});
});
})();
</script>
{% endblock %}

View File

@@ -49,13 +49,17 @@ class Config:
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15")) MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30")) SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
PAYMENT_PROVIDER: str = "paddle" PAYMENT_PROVIDER: str = _env("PAYMENT_PROVIDER", "paddle").lower()
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "") PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "") PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "") PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox") PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox")
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "") or os.getenv("STRIPE_API_PUBLIC_KEY", "")
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io") UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "") UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70" UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
@@ -722,16 +726,39 @@ async def purge_deleted(table: str, days: int = 30) -> int:
# ============================================================================= # =============================================================================
async def get_price_id(key: str, provider: str = None) -> str | None:
"""Look up a provider price ID by product key from the payment_products table."""
provider = provider or config.PAYMENT_PROVIDER
row = await fetch_one(
"SELECT provider_price_id FROM payment_products WHERE provider = ? AND key = ?",
(provider, key),
)
return row["provider_price_id"] if row else None
async def get_all_price_ids(provider: str = None) -> dict[str, str]:
"""Load all price IDs for a provider as a {key: price_id} dict."""
provider = provider or config.PAYMENT_PROVIDER
rows = await fetch_all(
"SELECT key, provider_price_id FROM payment_products WHERE provider = ?",
(provider,),
)
return {r["key"]: r["provider_price_id"] for r in rows}
async def get_paddle_price(key: str) -> str | None: async def get_paddle_price(key: str) -> str | None:
"""Look up a Paddle price ID by product key from the paddle_products table.""" """Deprecated: use get_price_id(). Falls back to paddle_products for pre-migration DBs."""
result = await get_price_id(key, provider="paddle")
if result:
return result
# Fallback to old table if payment_products not yet populated
row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)) row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
return row["paddle_price_id"] if row else None return row["paddle_price_id"] if row else None
async def get_all_paddle_prices() -> dict[str, str]: async def get_all_paddle_prices() -> dict[str, str]:
"""Load all Paddle price IDs as a {key: price_id} dict.""" """Deprecated: use get_all_price_ids()."""
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products") return await get_all_price_ids(provider="paddle")
return {r["key"]: r["paddle_price_id"] for r in rows}
# ============================================================================= # =============================================================================
@@ -740,9 +767,14 @@ async def get_all_paddle_prices() -> dict[str, str]:
def slugify(text: str, max_length_chars: int = 80) -> str: def slugify(text: str, max_length_chars: int = 80) -> str:
"""Convert text to URL-safe slug.""" """Convert text to URL-safe slug.
Pre-replaces ß→ss before NFKD normalization so output matches the SQL
@slugify macro (which uses DuckDB STRIP_ACCENTS + REPLACE).
"""
text = text.lower().replace("ß", "ss")
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^\w\s-]", "", text.lower()) text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[-\s]+", "-", text).strip("-") text = re.sub(r"[-\s]+", "-", text).strip("-")
return text[:max_length_chars] return text[:max_length_chars]

View File

@@ -8,7 +8,8 @@
<p class="q-step-sub">{{ t.q4_subheading }}</p> <p class="q-step-sub">{{ t.q4_subheading }}</p>
<div class="q-field-group"> <div class="q-field-group">
<span class="q-label">{{ t.q4_phase_label }}</span> <span class="q-label">{{ t.q4_phase_label }} <span class="required">*</span></span>
{% if 'location_status' in errors %}<p class="q-error-hint">{{ t.q4_error_phase }}</p>{% endif %}
<div class="q-pills"> <div class="q-pills">
{% for val, label in [('still_searching', t.q4_phase_searching), ('location_found', t.q4_phase_found), ('converting_existing', t.q4_phase_converting), ('lease_signed', t.q4_phase_lease_signed), ('permit_not_filed', t.q4_phase_permit_not_filed), ('permit_pending', t.q4_phase_permit_pending), ('permit_granted', t.q4_phase_permit_granted)] %} {% for val, label in [('still_searching', t.q4_phase_searching), ('location_found', t.q4_phase_found), ('converting_existing', t.q4_phase_converting), ('lease_signed', t.q4_phase_lease_signed), ('permit_not_filed', t.q4_phase_permit_not_filed), ('permit_pending', t.q4_phase_permit_pending), ('permit_granted', t.q4_phase_permit_granted)] %}
<label><input type="radio" name="location_status" value="{{ val }}" {{ 'checked' if data.get('location_status') == val }}><span class="q-pill">{{ label }}</span></label> <label><input type="radio" name="location_status" value="{{ val }}" {{ 'checked' if data.get('location_status') == val }}><span class="q-pill">{{ label }}</span></label>

View File

@@ -89,17 +89,17 @@
"flash_verify_invalid": "Ungültiger Verifizierungslink.", "flash_verify_invalid": "Ungültiger Verifizierungslink.",
"flash_verify_expired": "Dieser Link ist abgelaufen oder wurde bereits verwendet. Bitte stelle eine neue Anfrage.", "flash_verify_expired": "Dieser Link ist abgelaufen oder wurde bereits verwendet. Bitte stelle eine neue Anfrage.",
"flash_verify_invalid_lead": "Dieses Angebot wurde bereits verifiziert oder existiert nicht.", "flash_verify_invalid_lead": "Dieses Angebot wurde bereits verifiziert oder existiert nicht.",
"landing_hero_badge": "Padel-Finanzrechner & Businessplan-Tool", "landing_hero_badge": "Das Padel-Gründer-Toolkit — kostenlos",
"landing_hero_h1_1": "Plan Dein Padel-", "landing_hero_h1_1": "Investier in Padel",
"landing_hero_h1_2": "Business in Minuten,", "landing_hero_h1_2": "mit Sicherheit,",
"landing_hero_h1_3": "nicht Monaten", "landing_hero_h1_3": "nicht Bauchgefühl",
"landing_hero_btn_primary": "Jetzt Dein Padel-Business planen →", "landing_hero_btn_primary": "Kostenlosen Businessplan starten →",
"landing_hero_btn_secondary": "Anbieter durchsuchen", "landing_hero_btn_secondary": "Anbieter-Angebote einholen",
"landing_hero_bullet_1": "Keine Registrierung erforderlich", "landing_hero_bullet_1": "Kostenlos — ohne Registrierung, ohne Kreditkarte",
"landing_hero_bullet_2": "60+ Variablen", "landing_hero_bullet_2": "Bankfertige Kennzahlen (IRR, DSCR, MOIC)",
"landing_hero_bullet_3": "Unbegrenzte Szenarien", "landing_hero_bullet_3": "Basiert auf echten Marktdaten",
"landing_roi_title": "Schnelle Renditeschätzung", "landing_roi_title": "Ist Deine Padel-Idee rentabel?",
"landing_roi_subtitle": "Schieberegler bewegen und Projektion in Echtzeit sehen", "landing_roi_subtitle": "Finde es in 30 Sekunden heraus",
"landing_roi_courts": "Plätze", "landing_roi_courts": "Plätze",
"landing_roi_rate": "Durchschn. Stundensatz", "landing_roi_rate": "Durchschn. Stundensatz",
"landing_roi_util": "Ziel-Auslastung", "landing_roi_util": "Ziel-Auslastung",
@@ -108,7 +108,7 @@
"landing_roi_payback": "Amortisationszeit", "landing_roi_payback": "Amortisationszeit",
"landing_roi_annual_roi": "Jährlicher ROI", "landing_roi_annual_roi": "Jährlicher ROI",
"landing_roi_note": "Annahmen: Indoorhalle Mietmodell, 8 €/m² Miete, Personalkosten, 5 % Zinsen, 10-jähriges Darlehen. Amortisation und ROI basieren auf der Gesamtinvestition.", "landing_roi_note": "Annahmen: Indoorhalle Mietmodell, 8 €/m² Miete, Personalkosten, 5 % Zinsen, 10-jähriges Darlehen. Amortisation und ROI basieren auf der Gesamtinvestition.",
"landing_roi_cta": "Jetzt Dein Padel-Business planen →", "landing_roi_cta": "Vollständigen Businessplan erstellen — kostenlos →",
"landing_journey_title": "Deine Reise", "landing_journey_title": "Deine Reise",
"landing_journey_01": "Analysieren", "landing_journey_01": "Analysieren",
"landing_journey_01_badge": "Demnächst", "landing_journey_01_badge": "Demnächst",
@@ -118,27 +118,27 @@
"landing_journey_04": "Bauen", "landing_journey_04": "Bauen",
"landing_journey_05": "Wachsen", "landing_journey_05": "Wachsen",
"landing_journey_05_badge": "Demnächst", "landing_journey_05_badge": "Demnächst",
"landing_features_title": "Für ernsthafte Padel-Unternehmer gebaut", "landing_features_title": "Alles, was Du für eine fundierte Entscheidung brauchst",
"landing_feature_1_h3": "60+ Variablen", "landing_feature_1_h3": "Kenne Deine Zahlen in- und auswendig",
"landing_feature_2_h3": "6 Analyse-Tabs", "landing_feature_2_h3": "Bankfertig ab Tag eins",
"landing_feature_3_h3": "Indoor & Outdoor", "landing_feature_3_h3": "Jeder Anlagentyp, jeder Markt",
"landing_feature_4_h3": "Sensitivitätsanalyse", "landing_feature_4_h3": "Stresstest vor dem Commitment",
"landing_feature_5_h3": "Professionelle Kennzahlen", "landing_feature_5_h3": "Ersetzt den 5.000-€-Berater",
"landing_feature_6_h3": "Speichern & Vergleichen", "landing_feature_6_h3": "Szenarien direkt vergleichen",
"landing_supplier_title": "Die richtigen Anbieter für Dein Projekt finden", "landing_supplier_title": "Bereit zum Bauen? Lass Dich mit verifizierten Anbietern verbinden",
"landing_supplier_step_1_title": "Padel-Platz planen", "landing_supplier_step_1_title": "Projekt teilen",
"landing_supplier_step_2_title": "Angebote einholen", "landing_supplier_step_2_title": "Passende Anbieter finden",
"landing_supplier_step_3_title": "Vergleichen & Bauen", "landing_supplier_step_3_title": "Angebote vergleichen",
"landing_supplier_browse_btn": "Anbieterverzeichnis durchsuchen", "landing_supplier_browse_btn": "Angebote einholen — kostenlos & unverbindlich",
"landing_faq_title": "Häufig gestellte Fragen", "landing_faq_title": "Häufig gestellte Fragen",
"landing_faq_q1": "Was berechnet der Planer?", "landing_faq_q1": "Wie viel kostet es, eine Padel-Anlage zu eröffnen?",
"landing_faq_q2": "Muss ich mich registrieren?", "landing_faq_q2": "Akzeptiert die Bank einen Padelnomics-Businessplan?",
"landing_faq_q3": "Wie funktioniert die Anbieter-Vermittlung?", "landing_faq_q3": "Wie genau sind die Finanzprojektionen?",
"landing_faq_q4": "Ist das Anbieterverzeichnis kostenlos?", "landing_faq_q4": "Auf welchen Daten basieren die Markt-Benchmarks?",
"landing_faq_q5": "Wie genau sind die Finanzprojektionen?", "landing_faq_q5": "Muss ich etwas bezahlen?",
"landing_seo_title": "Padel-Platz-Investitionsplanung", "landing_seo_title": "Padel-Platz-Investitionsplanung",
"landing_final_cta_h2": "Jetzt mit der Planung loslegen", "landing_final_cta_h2": "Dein Banktermin kommt. Sei vorbereitet.",
"landing_final_cta_btn": "Jetzt Dein Padel-Business planen →", "landing_final_cta_btn": "Kostenlosen Businessplan starten →",
"features_h1": "Alles, was Du für Dein Padel-Business brauchst", "features_h1": "Alles, was Du für Dein Padel-Business brauchst",
"features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.", "features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.",
"features_card_1_h2": "60+ Variablen", "features_card_1_h2": "60+ Variablen",
@@ -428,6 +428,7 @@
"q4_phase_permit_not_filed": "Baugenehmigung noch nicht beantragt", "q4_phase_permit_not_filed": "Baugenehmigung noch nicht beantragt",
"q4_phase_permit_pending": "Baugenehmigung in Bearbeitung", "q4_phase_permit_pending": "Baugenehmigung in Bearbeitung",
"q4_phase_permit_granted": "Baugenehmigung erteilt", "q4_phase_permit_granted": "Baugenehmigung erteilt",
"q4_error_phase": "Bitte wähle Deine Projektphase aus.",
"q5_heading": "Zeitplan", "q5_heading": "Zeitplan",
"q5_subheading": "Wann möchtest Du beginnen?", "q5_subheading": "Wann möchtest Du beginnen?",
"q5_timeline_label": "Zeitplan", "q5_timeline_label": "Zeitplan",
@@ -891,7 +892,7 @@
"sup_meta_desc": "Kostenloser Verzeichniseintrag auf Padelnomics. Qualifizierte Leads von Interessenten mit fertigem Businessplan. Growth- und Pro-Pläne ab €199/Monat.", "sup_meta_desc": "Kostenloser Verzeichniseintrag auf Padelnomics. Qualifizierte Leads von Interessenten mit fertigem Businessplan. Growth- und Pro-Pläne ab €199/Monat.",
"sup_hero_h1a": "Kein Kaltakquise mehr.", "sup_hero_h1a": "Kein Kaltakquise mehr.",
"sup_hero_h1b": "Triff Käufer, die bereits einen Businessplan haben.", "sup_hero_h1b": "Triff Käufer, die bereits einen Businessplan haben.",
"sup_hero_sub": "Jeder Lead auf Padelnomics hat CAPEX, Umsatz und ROI bereits modelliert bevor er dich kontaktiert. Keine Zeitverschwender. Kein „ich schau mich nur um.“", "sup_hero_sub": "Jeder Lead hat bereits ein Finanzmodell für sein Projekt erstellt. Du bekommst Budget, Zeitplan und Spezifikationen — noch vor dem Erstkontakt.",
"sup_hero_cta": "Kostenlos starten", "sup_hero_cta": "Kostenlos starten",
"sup_hero_trust_pre": "Vertrauen von Anbietern in", "sup_hero_trust_pre": "Vertrauen von Anbietern in",
"sup_hero_trust_post": "Ländern", "sup_hero_trust_post": "Ländern",
@@ -955,7 +956,7 @@
"sup_basic_f4": "Website & Kontaktdaten", "sup_basic_f4": "Website & Kontaktdaten",
"sup_basic_f5": "Checkliste der angebotenen Leistungen", "sup_basic_f5": "Checkliste der angebotenen Leistungen",
"sup_basic_f6": "Kontaktformular auf der Listing-Seite", "sup_basic_f6": "Kontaktformular auf der Listing-Seite",
"sup_basic_cta": "Unternehmen kostenlos eintragen", "sup_basic_cta": "Kostenlos eintragen",
"sup_growth_name": "Growth", "sup_growth_name": "Growth",
"sup_growth_popular": "Beliebtester Plan", "sup_growth_popular": "Beliebtester Plan",
"sup_growth_credits": "30 Credits/Monat inklusive", "sup_growth_credits": "30 Credits/Monat inklusive",
@@ -965,7 +966,7 @@
"sup_growth_f4": "Priorität gegenüber kostenlosen Einträgen", "sup_growth_f4": "Priorität gegenüber kostenlosen Einträgen",
"sup_growth_f5": "30 Lead-Credits pro Monat", "sup_growth_f5": "30 Lead-Credits pro Monat",
"sup_growth_f6": "Zusätzliche Credit-Pakete kaufen", "sup_growth_f6": "Zusätzliche Credit-Pakete kaufen",
"sup_growth_cta": "Jetzt starten", "sup_growth_cta": "Leads erhalten",
"sup_pro_name": "Pro", "sup_pro_name": "Pro",
"sup_pro_credits": "100 Credits/Monat inklusive", "sup_pro_credits": "100 Credits/Monat inklusive",
"sup_pro_f1": "Alles aus Growth", "sup_pro_f1": "Alles aus Growth",
@@ -974,7 +975,7 @@
"sup_pro_f4": "Featured Card-Rahmen & Glow", "sup_pro_f4": "Featured Card-Rahmen & Glow",
"sup_pro_f5": "Bevorzugte Platzierung im Verzeichnis", "sup_pro_f5": "Bevorzugte Platzierung im Verzeichnis",
"sup_pro_f6": "100 Lead-Credits pro Monat", "sup_pro_f6": "100 Lead-Credits pro Monat",
"sup_pro_cta": "Jetzt starten", "sup_pro_cta": "Pipeline maximieren",
"sup_yearly_note_basic": "Dauerhaft kostenlos", "sup_yearly_note_basic": "Dauerhaft kostenlos",
"sup_yearly_note_growth": "€1.799 jährlich", "sup_yearly_note_growth": "€1.799 jährlich",
"sup_yearly_note_pro": "€4.499 jährlich", "sup_yearly_note_pro": "€4.499 jährlich",
@@ -1012,14 +1013,14 @@
"sup_cmp_t4": "Nie", "sup_cmp_t4": "Nie",
"sup_cmp_m1": "Nach Kategorie gefiltert", "sup_cmp_m1": "Nach Kategorie gefiltert",
"sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €2080 CPC für Padel-Baukeywords bei 510 Klicks/Tag.", "sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €2080 CPC für Padel-Baukeywords bei 510 Klicks/Tag.",
"sup_proof_h2": "Vertrauen von führenden Unternehmen der Padel-Branche", "sup_proof_h2": "Das bekommst du mit jedem Lead",
"sup_proof_stat1": "erstellte Businesspläne", "sup_proof_stat1": "erstellte Businesspläne",
"sup_proof_stat2": "Anbieter", "sup_proof_stat2": "Anbieter",
"sup_proof_stat3": "Länder", "sup_proof_stat3": "Länder",
"sup_proof_q1": "Padelnomics schickt uns Leads, die bereits ernsthaft an einem Bau interessiert sind. Die Projektbriefings sind detaillierter als das, was wir von Messen erhalten.", "sup_proof_point1_h3": "Komplettes Projektbriefing",
"sup_proof_cite1": "— Europäischer Padel-Court-Hersteller", "sup_proof_point1_p": "Anlagentyp, Court-Anzahl, Glas-/Lichtspezifikationen, Budget, Zeitplan, Finanzierungsstatus und vollständige Kontaktdaten — bevor du überhaupt Erstkontakt aufnimmst.",
"sup_proof_q2": "Endlich eine Plattform, die den Padel-Baumarkt versteht. Wir kennen das Budget, den Zeitplan und den Standorttyp, bevor wir überhaupt Erstkontakt aufnehmen.", "sup_proof_point2_h3": "Finanzmodell inklusive",
"sup_proof_cite2": "— Padel-Court-Installationsunternehmen, Skandinavien", "sup_proof_point2_p": "Jeder Lead hat bereits CAPEX, Umsatzprognosen und ROI durchgerechnet. Du sprichst mit jemandem, der seine Zahlen kennt.",
"sup_faq_h2": "Anbieter-FAQ", "sup_faq_h2": "Anbieter-FAQ",
"sup_faq_q1": "Wie werde ich gelistet?", "sup_faq_q1": "Wie werde ich gelistet?",
"sup_faq_a1_pre": "Finde dein Unternehmen in unserem", "sup_faq_a1_pre": "Finde dein Unternehmen in unserem",
@@ -1172,34 +1173,67 @@
"features_opex_body": "Peak- und Off-Peak-Preise mit konfigurierbaren Stundenaufteilungen. Monatliche Anlaufkurven für die Auslastung. Personalkosten, Wartung, Versicherung, Marketing und Betriebskosten — alle mit Schiebereglern anpassbar. Einnahmen aus Platzvermietung, Coaching, Ausrüstung und F&B.", "features_opex_body": "Peak- und Off-Peak-Preise mit konfigurierbaren Stundenaufteilungen. Monatliche Anlaufkurven für die Auslastung. Personalkosten, Wartung, Versicherung, Marketing und Betriebskosten — alle mit Schiebereglern anpassbar. Einnahmen aus Platzvermietung, Coaching, Ausrüstung und F&B.",
"features_cf_body": "Monatliche Cashflow-Projektionen über 10 Jahre. Eigen-/Fremdkapitalaufteilung, Zinssätze und Kreditlaufzeiten modellieren. Schuldendienstdeckungsgrade und freien Cashflow Monat für Monat einsehen. Wasserfalldiagramme zeigen genau, wohin dein Geld fließt.", "features_cf_body": "Monatliche Cashflow-Projektionen über 10 Jahre. Eigen-/Fremdkapitalaufteilung, Zinssätze und Kreditlaufzeiten modellieren. Schuldendienstdeckungsgrade und freien Cashflow Monat für Monat einsehen. Wasserfalldiagramme zeigen genau, wohin dein Geld fließt.",
"features_returns_body": "Eigenkapital-IRR und MOIC unter verschiedenen Exit-Szenarien berechnen. Cap-Rate-Exits mit konfigurierbaren Haltedauern modellieren. Die Eigenkapitalentwicklung vom Ersteinsatz bis zum Exit-Erlös nachvollziehen.", "features_returns_body": "Eigenkapital-IRR und MOIC unter verschiedenen Exit-Szenarien berechnen. Cap-Rate-Exits mit konfigurierbaren Haltedauern modellieren. Die Eigenkapitalentwicklung vom Ersteinsatz bis zum Exit-Erlös nachvollziehen.",
"landing_page_title": "Padelnomics - Padel-Kostenrechner & Finanzplaner", "landing_page_title": "Padelnomics Padel-Businessplan & Renditerechner | Kostenlos",
"landing_meta_desc": "Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.", "landing_meta_desc": "Plane Deine Padel-Investition mit echten Marktdaten. Bankfertiges Finanzmodell mit IRR, DSCR, Sensitivitätsanalyse. Kostenlos — ohne Registrierung.",
"landing_og_desc": "Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.", "landing_og_desc": "Plane Deine Padel-Investition mit Sicherheit. Bankfertiges Finanzmodell, echte Marktdaten und verifizierte Anbieter-Vermittlung. Kostenlos — ohne Registrierung.",
"landing_hero_desc": "Modelliere Deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Danach wirst Du mit verifizierten Anbietern zusammengebracht.", "landing_hero_desc": "Du stehst vor einer Investition von über 200.000 €. Padelnomics gibt Dir das Finanzmodell, die Marktdaten und die Anbieter-Kontakte, um diese Entscheidung mit offenen Augen zu treffen.",
"landing_journey_01_desc": "Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.", "landing_journey_01_desc": "Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.",
"landing_journey_02_desc": "Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.", "landing_journey_02_desc": "Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.",
"landing_journey_03_desc": "Kontakte zu Banken und Investoren herstellen. Dein Finanzplan wird zum Businesscase.", "landing_journey_03_desc": "Kontakte zu Banken und Investoren herstellen. Dein Finanzplan wird zum Businesscase.",
"landing_journey_04_desc": "{total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu Deinen Anforderungen vermittelt.", "landing_journey_04_desc": "{total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu Deinen Anforderungen vermittelt.",
"landing_journey_05_desc": "Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für deinen Betrieb.", "landing_journey_05_desc": "Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für deinen Betrieb.",
"landing_feature_1_body": "Jede Annahme ist anpassbar: Platzbaukosten, Miete, Preisgestaltung, Auslastung, Finanzierungskonditionen, Exit-Szenarien. Nichts ist fest vorgegeben.", "landing_feature_1_body": "Jede Kosten-, Erlös- und Finanzierungsannahme ist anpassbar. Nichts ist versteckt, nichts ist fest vorgegeben.",
"landing_feature_2_body": "Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen — jeder Tab mit interaktiven Diagrammen.", "landing_feature_2_body": "IRR, MOIC, DSCR, Cash-on-Cash-Rendite, Break-even-Analyse — genau die Kennzahlen, die Banken und Investoren verlangen.",
"landing_feature_3_body": "Indoorhallenmodelle (Miete oder Neubau) und Außenanlagen mit Saisonalität. Szenarien direkt nebeneinander vergleichen.", "landing_feature_3_body": "Indoorhallen, Außenplätze, Miet- oder Eigentumsmodell — mit Saisonalität und regionalen Kostenanpassungen.",
"landing_feature_4_body": "Sieh dir an, wie sich deine Renditen bei unterschiedlichen Auslastungsraten und Preisen verändern. Break-even-Punkt sofort ermitteln.", "landing_feature_4_body": "Sieh, wie sich Deine Rendite verändert, wenn die Auslastung um 10 % sinkt oder die Zinsen steigen. Break-even-Punkt sofort ermitteln.",
"landing_feature_5_body": "IRR, MOIC, DSCR, Cash-on-Cash-Rendite, Break-even-Auslastung, RevPAH, Schuldenrendite — die Kennzahlen, die Banken und Investoren sehen möchten.", "landing_feature_5_body": "Erhalte dasselbe Finanzmodell, das ein Berater für 5.00010.000 € berechnen würde. Jederzeit selbst aktualisierbar.",
"landing_feature_6_body": "Unbegrenzte Szenarien speichern. Verschiedene Standorte, Platzzahlen und Finanzierungsstrukturen testen. Den optimalen Plan finden.", "landing_feature_6_body": "Verschiedene Standorte, Platzzahlen und Finanzierungsstrukturen testen. Den Plan finden, der funktioniert.",
"landing_supplier_sub": "{total_suppliers}+ verifizierte Anbieter aus {total_countries} Ländern. Hersteller, Baufirmen, Belaghersteller, Beleuchtung und mehr.", "landing_supplier_sub": "Jede Angebotsanfrage enthält Dein vollständiges Finanzmodell — Budget, Platzzahl, Zeitplan und Finanzierungsstatus. {total_suppliers}+ Anbieter aus {total_countries} Ländern.",
"landing_supplier_step_1_body": "Nutze den Finanzplaner, um deine Platzzahl, dein Budget und deinen Zeitplan zu modellieren.", "landing_supplier_step_1_body": "Fülle in 2 Minuten einen Projektbrief aus. Deine Planer-Daten werden automatisch übernommen.",
"landing_supplier_step_2_body": "Angebote anfordern — wir vermitteln dich anhand deiner Projektspezifikationen an passende Anbieter.", "landing_supplier_step_2_body": "Wir benachrichtigen Anbieter, die zu Deinen Anforderungen, Deinem Standort und Budget passen. Keine Kaltakquise nötig.",
"landing_supplier_step_3_body": "Angebote von vermittelten Anbietern erhalten. Keine Kaltakquise erforderlich.", "landing_supplier_step_3_body": "Erhalte Angebote von passenden Anbietern. Jedes Angebot basiert auf Deinen tatsächlichen Projektdaten — keine Standardkalkulationen.",
"landing_faq_a1": "Der Planer erstellt ein vollständiges Finanzmodell: CAPEX-Aufschlüsselung, monatliche Betriebskosten, Cashflow-Projektionen, Schuldendienst, IRR, MOIC, DSCR, Amortisationszeit, Break-even-Auslastung und Sensitivitätsanalyse. Es werden Indoor-/Outdoor-Anlagen, Miet- und Eigentumsmodelle sowie alle wesentlichen Kosten- und Erlösvariablen abgedeckt.", "landing_faq_a1": "Das hängt vom Format ab. Eine typische Indoorhalle mit 68 Plätzen in einem Mietobjekt kostet 250.000500.000 €. Ein Neubau liegt bei 13 Mio. €. Outdoor-Plätze starten bei rund 150.000 € für 4 Courts. Mit Padelnomics modellierst Du Dein genaues Szenario — jede Variable ist anpassbar, und Du siehst das vollständige Finanzbild in Minuten.",
"landing_faq_a2": "Nein. Der Planer funktioniert sofort ohne Registrierung. Erstelle ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren.", "landing_faq_a2": "Ja. Der Planer erstellt IRR, MOIC, DSCR, Break-even-Analyse und 10-Jahres-Cashflow-Projektionen — genau die Kennzahlen, die Banken und Investoren erwarten. Exportiere als professionelles PDF für Deinen Kreditantrag oder Dein Investoren-Pitch.",
"landing_faq_a3": "Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten.", "landing_faq_a3": "Das Modell verwendet reale Standardwerte auf Basis europäischer und internationaler Marktdaten. Jede Annahme ist anpassbar, damit Du Deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern — nicht nur im Best Case.",
"landing_faq_a4": "Das Durchsuchen des Verzeichnisses ist für alle kostenlos. Anbieter erhalten standardmäßig einen Basiseintrag. Kostenpflichtige Pläne (Basic ab 39 €/Monat, Growth ab 199 €/Monat, Pro ab 499 €/Monat) schalten Anfrageformulare, vollständige Beschreibungen, Logos, verifizierte Badges und Prioritätsplatzierung frei.", "landing_faq_a4": "Die Standardwerte basieren auf echten Platzbaukosten, Mietpreisen und Betriebsbenchmarks aus öffentlichen Quellen und Branchendaten. Du kannst jede Annahme mit Deinen eigenen Zahlen überschreiben.",
"landing_faq_a5": "Das Modell verwendet reale Standardwerte auf Basis globaler Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.", "landing_faq_a5": "Der Planer ist 100 % kostenlos — ohne Registrierung, ohne Kreditkarte, ohne Testphase. Erstelle ein kostenloses Konto, um Szenarien zu speichern und Konfigurationen zu vergleichen. Der PDF-Export ist als Zusatzleistung verfügbar (99 € einmalig).",
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot in Märkten von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Padel-Anlage zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.", "landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot in Märkten von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Padel-Anlage zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.",
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Du als Unternehmer Deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Padel-Anlage bewertest — Padelnomics gibt Dir die finanzielle Klarheit für fundierte Entscheidungen.", "landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Du als Unternehmer Deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Padel-Anlage bewertest — Padelnomics gibt Dir die finanzielle Klarheit für fundierte Entscheidungen.",
"landing_final_cta_sub": "Modelliere Deine Investition und lass Dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.", "landing_final_cta_sub": "Schließ Dich 1.000+ Padel-Unternehmern an, die aufgehört haben zu raten — und angefangen haben, mit echten Daten zu planen.",
"landing_jsonld_org_desc": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer.", "landing_jsonld_org_desc": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer.",
"landing_proof_plans": "{count}+ Businesspläne erstellt",
"landing_proof_suppliers": "{count}+ Anbieter in {countries} Ländern",
"landing_proof_projects": "{amount} Mio. €+ an geplanten Projekten",
"landing_familiar_title": "Kommt Dir das bekannt vor?",
"landing_familiar_1_quote": "Ich denke seit Monaten darüber nach — ich muss einfach mal die Zahlen durchrechnen",
"landing_familiar_1_desc": "Der Planer macht aus Deinen Annahmen ein bankfertiges Finanzmodell — in Minuten statt Wochen.",
"landing_familiar_2_quote": "Die Bank will einen Businessplan und ich starre auf eine leere Tabelle",
"landing_familiar_2_desc": "IRR, DSCR, MOIC, Cashflow-Projektionen — alles wird automatisch aus Deinen Eingaben generiert.",
"landing_familiar_3_quote": "Ich finde widersprüchliche Kostendaten und weiß nicht, was ich glauben soll",
"landing_familiar_3_desc": "Die Standardwerte basieren auf echten Marktdaten. Passe jede Annahme an Deinen lokalen Markt an.",
"landing_familiar_4_quote": "Mein Partner ist skeptisch — ich brauche einen Beweis, dass das nicht verrückt ist",
"landing_familiar_4_desc": "Stresstest per Sensitivitätsanalyse. Zeig genau, wo der Plan bricht — und wo nicht.",
"landing_familiar_cta": "Du bist nicht allein. 1.000+ Padel-Unternehmer haben hier angefangen.",
"landing_vs_title": "Warum Padelnomics?",
"landing_vs_sub": "Du hast Alternativen. Hier der ehrliche Vergleich.",
"landing_vs_col_diy": "Eigene Tabelle",
"landing_vs_col_consultant": "Externer Berater",
"landing_vs_col_us": "Padelnomics",
"landing_vs_row1_label": "Kosten",
"landing_vs_row1_diy": "Kostenlos, dauert aber Wochen",
"landing_vs_row1_consultant": "5.00010.000 €",
"landing_vs_row1_us": "Kostenlos, sofort",
"landing_vs_row2_label": "Qualität",
"landing_vs_row2_diy": "Wirkt unprofessionell bei Banken",
"landing_vs_row2_consultant": "Professionell, aber statisch",
"landing_vs_row2_us": "Professionell, anpassbar",
"landing_vs_row3_label": "Daten",
"landing_vs_row3_diy": "Keine Markt-Benchmarks",
"landing_vs_row3_consultant": "Generisch, nicht padelspezifisch",
"landing_vs_row3_us": "Echte Padel-Marktdaten",
"landing_vs_diy_cta": "Du verdienst Besseres als Raten",
"landing_vs_consultant_cta": "Du verdienst Besseres als 5.000 € zu zahlen",
"landing_vs_us_cta": "Kostenlos starten →",
"plan_basic_f1": "Verifiziert-Badge", "plan_basic_f1": "Verifiziert-Badge",
"plan_basic_f2": "Firmenlogo", "plan_basic_f2": "Firmenlogo",
"plan_basic_f3": "Vollständige Beschreibung & Slogan", "plan_basic_f3": "Vollständige Beschreibung & Slogan",
@@ -1736,7 +1770,14 @@
"sup_guarantee_badge": "Garantie ohne Risiko", "sup_guarantee_badge": "Garantie ohne Risiko",
"sup_leads_section_h2": "So sehen deine Interessenten aus", "sup_leads_section_h2": "So sehen deine Interessenten aus",
"sup_leads_section_sub": "Jeder Lead hat unseren Finanzplaner genutzt. Kontaktdaten werden nach dem Freischalten sichtbar.", "sup_leads_section_sub": "Jeder Lead hat unseren Finanzplaner genutzt. Kontaktdaten werden nach dem Freischalten sichtbar.",
"sup_roi_line": "Ein einziges 4-Court-Projekt = <strong>€30.000+ Gewinn</strong>. Growth-Plan: €2.388/Jahr. Die Rechnung ist einfach.", "sup_roi_line": "Dein durchschnittliches Projekt ist <strong>€50.000+</strong> wert. Wenn wir dir 5 qualifizierte Leads/Monat schicken und du 1 abschließt, sind das €50.000 Umsatz für €199/Monat. Die Rechnung ist einfach.",
"sup_familiar_title": "Kommt dir das bekannt vor?",
"sup_familiar_1_quote": "20 Angebote letztes Quartal. 3 Abschlüsse.",
"sup_familiar_1_pivot": "Schluss mit Angeboten an Interessenten, die nie ernst gemeint haben.",
"sup_familiar_2_quote": "Pipeline für Q3 sieht dünn aus — dabei haben wir Kapazität.",
"sup_familiar_2_pivot": "Ein planbarer Lead-Strom, auf den du dich verlassen kannst.",
"sup_familiar_3_quote": "Schon wieder hat ein Wettbewerber uns beim Preis unterboten.",
"sup_familiar_3_pivot": "Gewinn über Angebotsqualität, nicht über den Preis.",
"sup_credits_only_pre": "Noch nicht bereit für ein Abo? Kaufe ein Credit-Paket und schalte Leads einzeln frei. Keine Bindung, keine Monatsgebühr.", "sup_credits_only_pre": "Noch nicht bereit für ein Abo? Kaufe ein Credit-Paket und schalte Leads einzeln frei. Keine Bindung, keine Monatsgebühr.",
"sup_credits_only_cta": "Credits kaufen →", "sup_credits_only_cta": "Credits kaufen →",
"sup_step1_free_forever": "Dauerhaft kostenlos", "sup_step1_free_forever": "Dauerhaft kostenlos",
@@ -1784,5 +1825,16 @@
"affiliate_pros_label": "Vorteile", "affiliate_pros_label": "Vorteile",
"affiliate_cons_label": "Nachteile", "affiliate_cons_label": "Nachteile",
"affiliate_at_retailer": "bei {retailer}", "affiliate_at_retailer": "bei {retailer}",
"affiliate_our_picks": "Unsere Empfehlungen" "affiliate_our_picks": "Unsere Empfehlungen",
"error_404_title": "Seite nicht gefunden",
"error_404_heading": "Diese Seite gibt es nicht",
"error_404_message": "Die gesuchte Seite wurde verschoben oder existiert noch nicht.",
"error_404_city_message": "Die Marktanalyse für diese Stadt ist noch nicht verfügbar.",
"error_404_back_home": "Zur Startseite",
"error_404_back_country": "Zurück zur {country}-Übersicht",
"error_500_title": "Etwas ist schiefgelaufen",
"error_500_heading": "Etwas ist schiefgelaufen",
"error_500_message": "Wir arbeiten an einer Lösung. Bitte versuche es gleich noch einmal.",
"error_500_back_home": "Zur Startseite"
} }

View File

@@ -89,17 +89,17 @@
"flash_verify_invalid": "Invalid verification link.", "flash_verify_invalid": "Invalid verification link.",
"flash_verify_expired": "This link has expired or already been used. Please submit a new quote request.", "flash_verify_expired": "This link has expired or already been used. Please submit a new quote request.",
"flash_verify_invalid_lead": "This quote has already been verified or does not exist.", "flash_verify_invalid_lead": "This quote has already been verified or does not exist.",
"landing_hero_badge": "Padel court financial planner", "landing_hero_badge": "The padel startup toolkit — free",
"landing_hero_h1_1": "Plan Your Padel", "landing_hero_h1_1": "Invest in Padel",
"landing_hero_h1_2": "Business in Minutes,", "landing_hero_h1_2": "with Confidence,",
"landing_hero_h1_3": "Not Months", "landing_hero_h1_3": "Not Guesswork",
"landing_hero_btn_primary": "Plan Your Padel Business →", "landing_hero_btn_primary": "Start Your Free Business Plan →",
"landing_hero_btn_secondary": "Browse Suppliers", "landing_hero_btn_secondary": "Get Supplier Quotes",
"landing_hero_bullet_1": "No signup required", "landing_hero_bullet_1": "Free — no signup, no credit card",
"landing_hero_bullet_2": "60+ variables", "landing_hero_bullet_2": "Bank-ready metrics (IRR, DSCR, MOIC)",
"landing_hero_bullet_3": "Unlimited scenarios", "landing_hero_bullet_3": "Based on real market data",
"landing_roi_title": "Quick ROI Estimate", "landing_roi_title": "Is your padel idea profitable?",
"landing_roi_subtitle": "Drag the sliders to see your projection", "landing_roi_subtitle": "Find out in 30 seconds",
"landing_roi_courts": "Courts", "landing_roi_courts": "Courts",
"landing_roi_rate": "Avg. Hourly Rate", "landing_roi_rate": "Avg. Hourly Rate",
"landing_roi_util": "Target Utilization", "landing_roi_util": "Target Utilization",
@@ -108,7 +108,7 @@
"landing_roi_payback": "Payback Period", "landing_roi_payback": "Payback Period",
"landing_roi_annual_roi": "Annual ROI", "landing_roi_annual_roi": "Annual ROI",
"landing_roi_note": "Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.", "landing_roi_note": "Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.",
"landing_roi_cta": "Plan Your Padel Business →", "landing_roi_cta": "Build Your Full Business Plan — Free →",
"landing_journey_title": "Your Journey", "landing_journey_title": "Your Journey",
"landing_journey_01": "Explore", "landing_journey_01": "Explore",
"landing_journey_01_badge": "Soon", "landing_journey_01_badge": "Soon",
@@ -118,27 +118,27 @@
"landing_journey_04": "Build", "landing_journey_04": "Build",
"landing_journey_05": "Grow", "landing_journey_05": "Grow",
"landing_journey_05_badge": "Soon", "landing_journey_05_badge": "Soon",
"landing_features_title": "Built for Serious Padel Entrepreneurs", "landing_features_title": "Everything You Need to Make a Confident Decision",
"landing_feature_1_h3": "60+ Variables", "landing_feature_1_h3": "Know Your Numbers Inside Out",
"landing_feature_2_h3": "6 Analysis Tabs", "landing_feature_2_h3": "Bank-Ready from Day One",
"landing_feature_3_h3": "Indoor & Outdoor", "landing_feature_3_h3": "Any Venue Type, Any Market",
"landing_feature_4_h3": "Sensitivity Analysis", "landing_feature_4_h3": "Stress-Test Before You Commit",
"landing_feature_5_h3": "Professional Metrics", "landing_feature_5_h3": "Replace the €5K Consultant",
"landing_feature_6_h3": "Save & Compare", "landing_feature_6_h3": "Compare Scenarios Side by Side",
"landing_supplier_title": "Find the Right Suppliers for Your Project", "landing_supplier_title": "Ready to Build? Get Matched with Verified Suppliers",
"landing_supplier_step_1_title": "Plan Your Venue", "landing_supplier_step_1_title": "Share Your Project",
"landing_supplier_step_2_title": "Get Quotes", "landing_supplier_step_2_title": "Get Matched",
"landing_supplier_step_3_title": "Compare & Build", "landing_supplier_step_3_title": "Compare Proposals",
"landing_supplier_browse_btn": "Browse Supplier Directory", "landing_supplier_browse_btn": "Get Quotes — Free & No Obligation",
"landing_faq_title": "Frequently Asked Questions", "landing_faq_title": "Frequently Asked Questions",
"landing_faq_q1": "What does the planner calculate?", "landing_faq_q1": "How much does it cost to open a padel facility?",
"landing_faq_q2": "Do I need to sign up?", "landing_faq_q2": "Will a bank accept a Padelnomics business plan?",
"landing_faq_q3": "How does supplier matching work?", "landing_faq_q3": "How accurate are the financial projections?",
"landing_faq_q4": "Is the supplier directory free?", "landing_faq_q4": "What data are the market benchmarks based on?",
"landing_faq_q5": "How accurate are the financial projections?", "landing_faq_q5": "Do I need to pay anything?",
"landing_seo_title": "Padel Court Investment Planning", "landing_seo_title": "Padel Court Investment Planning",
"landing_final_cta_h2": "Start Planning Today", "landing_final_cta_h2": "Your Bank Meeting Is Coming. Be Ready.",
"landing_final_cta_btn": "Plan Your Padel Business →", "landing_final_cta_btn": "Start Your Free Business Plan →",
"features_h1": "Everything You Need to Plan Your Padel Business", "features_h1": "Everything You Need to Plan Your Padel Business",
"features_subtitle": "Professional-grade financial modeling, completely free.", "features_subtitle": "Professional-grade financial modeling, completely free.",
"features_card_1_h2": "60+ Variables", "features_card_1_h2": "60+ Variables",
@@ -428,6 +428,7 @@
"q4_phase_permit_not_filed": "Permit not yet filed", "q4_phase_permit_not_filed": "Permit not yet filed",
"q4_phase_permit_pending": "Permit in progress", "q4_phase_permit_pending": "Permit in progress",
"q4_phase_permit_granted": "Permit approved", "q4_phase_permit_granted": "Permit approved",
"q4_error_phase": "Please select your project phase.",
"q5_heading": "Timeline", "q5_heading": "Timeline",
"q5_subheading": "When do you want to get started?", "q5_subheading": "When do you want to get started?",
"q5_timeline_label": "Timeline", "q5_timeline_label": "Timeline",
@@ -891,7 +892,7 @@
"sup_meta_desc": "Free directory listing on Padelnomics. Qualified leads from buyers with business plans. Growth and Pro plans from €199/mo.", "sup_meta_desc": "Free directory listing on Padelnomics. Qualified leads from buyers with business plans. Growth and Pro plans from €199/mo.",
"sup_hero_h1a": "Stop Chasing Cold Leads.", "sup_hero_h1a": "Stop Chasing Cold Leads.",
"sup_hero_h1b": "Meet Buyers Who Already Have a Business Plan.", "sup_hero_h1b": "Meet Buyers Who Already Have a Business Plan.",
"sup_hero_sub": "Every lead on Padelnomics has modeled their CAPEX, projected revenue, and calculated ROI — before they contact you. No tire-kickers. No “just browsing.”", "sup_hero_sub": "Every lead has already built a financial model for their project. You get the budget, timeline, and specs — before you make first contact.",
"sup_hero_cta": "Get Started Free", "sup_hero_cta": "Get Started Free",
"sup_hero_trust_pre": "Trusted by suppliers in", "sup_hero_trust_pre": "Trusted by suppliers in",
"sup_hero_trust_post": "countries", "sup_hero_trust_post": "countries",
@@ -955,7 +956,7 @@
"sup_basic_f4": "Website & contact details", "sup_basic_f4": "Website & contact details",
"sup_basic_f5": "Services offered checklist", "sup_basic_f5": "Services offered checklist",
"sup_basic_f6": "Enquiry form on listing page", "sup_basic_f6": "Enquiry form on listing page",
"sup_basic_cta": "List Your Company Free", "sup_basic_cta": "Get Listed Free",
"sup_growth_name": "Growth", "sup_growth_name": "Growth",
"sup_growth_popular": "Most Popular", "sup_growth_popular": "Most Popular",
"sup_growth_credits": "30 credits/mo included", "sup_growth_credits": "30 credits/mo included",
@@ -965,7 +966,7 @@
"sup_growth_f4": "Priority over free listings", "sup_growth_f4": "Priority over free listings",
"sup_growth_f5": "30 lead credits per month", "sup_growth_f5": "30 lead credits per month",
"sup_growth_f6": "Buy additional credit packs", "sup_growth_f6": "Buy additional credit packs",
"sup_growth_cta": "Get Started", "sup_growth_cta": "Start Getting Leads",
"sup_pro_name": "Pro", "sup_pro_name": "Pro",
"sup_pro_credits": "100 credits/mo included", "sup_pro_credits": "100 credits/mo included",
"sup_pro_f1": "Everything in Growth", "sup_pro_f1": "Everything in Growth",
@@ -974,7 +975,7 @@
"sup_pro_f4": "Featured card border & glow", "sup_pro_f4": "Featured card border & glow",
"sup_pro_f5": "Priority placement in directory", "sup_pro_f5": "Priority placement in directory",
"sup_pro_f6": "100 lead credits per month", "sup_pro_f6": "100 lead credits per month",
"sup_pro_cta": "Get Started", "sup_pro_cta": "Maximize Your Pipeline",
"sup_yearly_note_basic": "Free forever", "sup_yearly_note_basic": "Free forever",
"sup_yearly_note_growth": "€1,799 billed yearly", "sup_yearly_note_growth": "€1,799 billed yearly",
"sup_yearly_note_pro": "€4,499 billed yearly", "sup_yearly_note_pro": "€4,499 billed yearly",
@@ -1012,14 +1013,14 @@
"sup_cmp_t4": "Never", "sup_cmp_t4": "Never",
"sup_cmp_m1": "Filtered by category", "sup_cmp_m1": "Filtered by category",
"sup_cmp_footnote": "*Google Ads estimate based on €2080 CPC for padel construction keywords at 510 clicks/day.", "sup_cmp_footnote": "*Google Ads estimate based on €2080 CPC for padel construction keywords at 510 clicks/day.",
"sup_proof_h2": "Trusted by Padel Industry Leaders", "sup_proof_h2": "What You Get with Every Lead",
"sup_proof_stat1": "business plans created", "sup_proof_stat1": "business plans created",
"sup_proof_stat2": "suppliers", "sup_proof_stat2": "suppliers",
"sup_proof_stat3": "countries", "sup_proof_stat3": "countries",
"sup_proof_q1": "Padelnomics sends us leads that are already serious about building. The project briefs are more detailed than what we get from trade shows.", "sup_proof_point1_h3": "Complete Project Brief",
"sup_proof_cite1": "— European padel court manufacturer", "sup_proof_point1_p": "Venue type, court count, glass/lighting specs, budget, timeline, financing status, and full contact details — before you make first contact.",
"sup_proof_q2": "Finally a platform that understands the padel construction market. We know the budget, the timeline, and the venue type before we even make first contact.", "sup_proof_point2_h3": "Financial Model Included",
"sup_proof_cite2": "— Padel court installation company, Scandinavia", "sup_proof_point2_p": "Every lead has already modeled CAPEX, revenue projections, and ROI. You're talking to someone who knows their numbers.",
"sup_faq_h2": "Supplier FAQ", "sup_faq_h2": "Supplier FAQ",
"sup_faq_q1": "How do I get listed?", "sup_faq_q1": "How do I get listed?",
"sup_faq_a1_pre": "Find your company in our", "sup_faq_a1_pre": "Find your company in our",
@@ -1055,7 +1056,14 @@
"sup_guarantee_badge": "No-risk guarantee", "sup_guarantee_badge": "No-risk guarantee",
"sup_leads_section_h2": "See What Your Prospects Look Like", "sup_leads_section_h2": "See What Your Prospects Look Like",
"sup_leads_section_sub": "Every lead has used our financial planner. Contact details are blurred until you unlock.", "sup_leads_section_sub": "Every lead has used our financial planner. Contact details are blurred until you unlock.",
"sup_roi_line": "A single 4-court project = <strong>€30,000+ in profit</strong>. Growth plan costs €2,388/year. The math is simple.", "sup_roi_line": "Your average project is worth <strong>€50K+</strong>. If we send you 5 qualified leads/month and you close 1, that's €50K in revenue for €199/mo. The math is simple.",
"sup_familiar_title": "Is This Your Sales Team Right Now?",
"sup_familiar_1_quote": "We quoted 20 projects last quarter. Closed 3.",
"sup_familiar_1_pivot": "Stop wasting proposals on tire-kickers.",
"sup_familiar_2_quote": "Pipeline looks thin for Q3 — but we have capacity.",
"sup_familiar_2_pivot": "A predictable lead flow you can plan around.",
"sup_familiar_3_quote": "Another competitor just undercut us on price.",
"sup_familiar_3_pivot": "Win on proposal quality, not price.",
"sup_credits_only_pre": "Not ready for a subscription? Buy a credit pack and unlock leads one at a time. No commitment, no monthly fee.", "sup_credits_only_pre": "Not ready for a subscription? Buy a credit pack and unlock leads one at a time. No commitment, no monthly fee.",
"sup_credits_only_cta": "Buy Credits →", "sup_credits_only_cta": "Buy Credits →",
"sup_step1_free_forever": "Free forever", "sup_step1_free_forever": "Free forever",
@@ -1193,34 +1201,67 @@
"features_opex_body": "Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities — all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&B.", "features_opex_body": "Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities — all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&B.",
"features_cf_body": "10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.", "features_cf_body": "10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.",
"features_returns_body": "Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.", "features_returns_body": "Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.",
"landing_page_title": "Padelnomics - Padel Court Business Plan & ROI Calculator", "landing_page_title": "Padelnomics Padel Business Plan & ROI Calculator | Free",
"landing_meta_desc": "Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.", "landing_meta_desc": "Plan your padel facility investment with real market data. Bank-ready financial model with IRR, DSCR, sensitivity analysis. Free — no signup required.",
"landing_og_desc": "The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.", "landing_og_desc": "Plan your padel facility investment with confidence. Bank-ready financial model, real market data, and verified supplier connections. Free — no signup required.",
"landing_hero_desc": "Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers.", "landing_hero_desc": "You're about to commit €200K+. Padelnomics gives you the financial model, market data, and supplier connections to make that decision with your eyes wide open.",
"landing_journey_01_desc": "Market demand analysis, whitespace mapping, location scoring.", "landing_journey_01_desc": "Market demand analysis, whitespace mapping, location scoring.",
"landing_journey_02_desc": "Model your investment with 60+ variables, charts, and sensitivity analysis.", "landing_journey_02_desc": "Model your investment with 60+ variables, charts, and sensitivity analysis.",
"landing_journey_03_desc": "Connect with banks and investors. Your planner becomes your business case.", "landing_journey_03_desc": "Connect with banks and investors. Your planner becomes your business case.",
"landing_journey_04_desc": "Browse {total_suppliers}+ court suppliers across {total_countries} countries. Get matched to your specs.", "landing_journey_04_desc": "Browse {total_suppliers}+ court suppliers across {total_countries} countries. Get matched to your specs.",
"landing_journey_05_desc": "Launch playbook, performance benchmarks, and expansion analytics.", "landing_journey_05_desc": "Launch playbook, performance benchmarks, and expansion analytics.",
"landing_feature_1_body": "Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.", "landing_feature_1_body": "Every cost, revenue, and financing assumption is adjustable. Nothing is hidden, nothing is hard-coded.",
"landing_feature_2_body": "Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each with interactive charts.", "landing_feature_2_body": "IRR, MOIC, DSCR, cash-on-cash yield, break-even analysis — the exact metrics banks and investors ask for.",
"landing_feature_3_body": "Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.", "landing_feature_3_body": "Indoor halls, outdoor courts, rent or build — with seasonality and regional cost adjustments built in.",
"landing_feature_4_body": "See how your returns change with different utilization rates and pricing. Find your break-even point instantly.", "landing_feature_4_body": "See how your returns change when utilization drops 10% or interest rates rise. Find your break-even point instantly.",
"landing_feature_5_body": "IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.", "landing_feature_5_body": "Get the same financial model a consulting firm would charge €5,00010,000 for. Update it yourself, anytime.",
"landing_feature_6_body": "Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.", "landing_feature_6_body": "Test different locations, court counts, and financing structures. Find the plan that works.",
"landing_supplier_sub": "{total_suppliers}+ verified suppliers across {total_countries} countries. Manufacturers, builders, turf, lighting, and more.", "landing_supplier_sub": "Every quote request includes your full financial model — budget, court count, timeline, and financing status. {total_suppliers}+ suppliers across {total_countries} countries.",
"landing_supplier_step_1_body": "Use the financial planner to model your courts, budget, and timeline.", "landing_supplier_step_1_body": "Complete a 2-minute project brief. Your planner scenario data is included automatically.",
"landing_supplier_step_2_body": "Request quotes and we match you with suppliers based on your project specs.", "landing_supplier_step_2_body": "We notify suppliers who match your specs, location, and budget. No cold outreach needed.",
"landing_supplier_step_3_body": "Receive proposals from matched suppliers. No cold outreach needed.", "landing_supplier_step_3_body": "Receive quotes from matched suppliers. Every proposal is based on your actual project data — no generic estimates.",
"landing_faq_a1": "The planner produces a complete financial model: CAPEX breakdown, monthly operating costs, cash flow projections, debt service, IRR, MOIC, DSCR, payback period, break-even utilization, and sensitivity analysis. It covers indoor/outdoor, rent/buy, and all major cost and revenue variables.", "landing_faq_a1": "It depends on the format. A typical indoor padel venue with 68 courts in a rented building costs €250K500K. Building your own hall pushes that to €13M. Outdoor courts start around €150K for 4 courts. Padelnomics lets you model your exact scenario — adjust every variable and see the full financial picture in minutes.",
"landing_faq_a2": "No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.", "landing_faq_a2": "Yes. The planner produces IRR, MOIC, DSCR, break-even analysis, and 10-year cash flow projections — the exact metrics banks and investors expect. Export as a professional PDF to include in your loan application or investor pitch.",
"landing_faq_a3": "When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.", "landing_faq_a3": "The model uses real-world defaults based on market data across Europe and beyond. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, so you understand the full range of outcomes — not just the best case.",
"landing_faq_a4": "Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans (Basic at €39/mo, Growth at €199/mo, Pro at €499/mo) unlock enquiry forms, full descriptions, logos, verified badges, and priority placement.", "landing_faq_a4": "Default values are derived from real court construction costs, rental rates, and operating benchmarks gathered from public sources and industry data. You can override any assumption with your own numbers.",
"landing_faq_a5": "The model uses real-world defaults based on global market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes.", "landing_faq_a5": "The planner is 100% free — no signup, no credit card, no trial period. Create a free account to save scenarios and compare configurations. PDF export is available as a paid add-on (€99 one-time).",
"landing_seo_p1": "Padel is one of the fastest-growing racket sports globally, with demand for courts outstripping supply across markets from Germany, Spain, and Sweden to the US and Middle East. Opening a padel hall can be a lucrative investment, but the numbers need to work. A typical indoor padel venue with 6-8 courts requires between €300K (renting an existing building) and €2-3M (building new), with payback periods of 3-5 years for well-located venues.", "landing_seo_p1": "Padel is one of the fastest-growing racket sports globally, with demand for courts outstripping supply across markets from Germany, Spain, and Sweden to the US and Middle East. Opening a padel hall can be a lucrative investment, but the numbers need to work. A typical indoor padel venue with 6-8 courts requires between €300K (renting an existing building) and €2-3M (building new), with payback periods of 3-5 years for well-located venues.",
"landing_seo_p2": "The key variables that determine success are location (driving utilization), construction costs (CAPEX), rent or land costs, and pricing strategy. Our financial planner lets you model all of these variables interactively, seeing the impact on your IRR, MOIC, cash flow, and debt service coverage ratio in real time. Whether you're an entrepreneur exploring your first venue, a real estate developer adding padel to a mixed-use project, or an investor evaluating a padel hall acquisition, Padelnomics gives you the financial clarity to make informed decisions.", "landing_seo_p2": "The key variables that determine success are location (driving utilization), construction costs (CAPEX), rent or land costs, and pricing strategy. Our financial planner lets you model all of these variables interactively, seeing the impact on your IRR, MOIC, cash flow, and debt service coverage ratio in real time. Whether you're an entrepreneur exploring your first venue, a real estate developer adding padel to a mixed-use project, or an investor evaluating a padel hall acquisition, Padelnomics gives you the financial clarity to make informed decisions.",
"landing_final_cta_sub": "Model your investment, then get matched with verified court suppliers across {total_countries} countries.", "landing_final_cta_sub": "Join 1,000+ padel entrepreneurs who stopped guessing and started planning with real data.",
"landing_jsonld_org_desc": "Professional padel court investment planning platform. Financial planner, supplier directory, and market intelligence for padel entrepreneurs.", "landing_jsonld_org_desc": "Professional padel court investment planning platform. Financial planner, supplier directory, and market intelligence for padel entrepreneurs.",
"landing_proof_plans": "{count}+ business plans created",
"landing_proof_suppliers": "{count}+ suppliers in {countries} countries",
"landing_proof_projects": "€{amount}M+ in projects planned",
"landing_familiar_title": "Sound Familiar?",
"landing_familiar_1_quote": "I've been thinking about this for months — I just need to run the numbers",
"landing_familiar_1_desc": "The planner turns your assumptions into a bank-ready financial model in minutes, not weeks.",
"landing_familiar_2_quote": "The bank asked for a business plan and I'm staring at a blank spreadsheet",
"landing_familiar_2_desc": "IRR, DSCR, MOIC, cash flow projections — all generated automatically from your inputs.",
"landing_familiar_3_quote": "I found conflicting cost data and I don't know what to trust",
"landing_familiar_3_desc": "Default values are based on real market data. Adjust any assumption to match your local market.",
"landing_familiar_4_quote": "My partner is skeptical — I need proof this isn't crazy",
"landing_familiar_4_desc": "Stress-test your plan with sensitivity analysis. Show exactly where it breaks — and where it doesn't.",
"landing_familiar_cta": "You're not alone. 1,000+ padel entrepreneurs started here.",
"landing_vs_title": "Why Padelnomics?",
"landing_vs_sub": "You have options. Here's the honest comparison.",
"landing_vs_col_diy": "DIY Spreadsheet",
"landing_vs_col_consultant": "Hired Consultant",
"landing_vs_col_us": "Padelnomics",
"landing_vs_row1_label": "Cost",
"landing_vs_row1_diy": "Free but takes weeks",
"landing_vs_row1_consultant": "€5,00010,000",
"landing_vs_row1_us": "Free, instant",
"landing_vs_row2_label": "Quality",
"landing_vs_row2_diy": "Looks amateur to banks",
"landing_vs_row2_consultant": "Professional but static",
"landing_vs_row2_us": "Professional, adjustable",
"landing_vs_row3_label": "Data",
"landing_vs_row3_diy": "No market benchmarks",
"landing_vs_row3_consultant": "Generic, not padel-specific",
"landing_vs_row3_us": "Real padel market data",
"landing_vs_diy_cta": "You deserve better than guessing",
"landing_vs_consultant_cta": "You deserve better than paying €5K",
"landing_vs_us_cta": "Start free →",
"plan_basic_f1": "Verified badge", "plan_basic_f1": "Verified badge",
"plan_basic_f2": "Company logo", "plan_basic_f2": "Company logo",
"plan_basic_f3": "Full description & tagline", "plan_basic_f3": "Full description & tagline",
@@ -1787,5 +1828,16 @@
"affiliate_pros_label": "Pros", "affiliate_pros_label": "Pros",
"affiliate_cons_label": "Cons", "affiliate_cons_label": "Cons",
"affiliate_at_retailer": "at {retailer}", "affiliate_at_retailer": "at {retailer}",
"affiliate_our_picks": "Our picks" "affiliate_our_picks": "Our picks",
"error_404_title": "Page Not Found",
"error_404_heading": "This page doesn't exist",
"error_404_message": "The page you're looking for may have been moved or doesn't exist yet.",
"error_404_city_message": "The market analysis for this city isn't available yet.",
"error_404_back_home": "Back to Home",
"error_404_back_country": "Back to {country} overview",
"error_500_title": "Something Went Wrong",
"error_500_heading": "Something went wrong",
"error_500_message": "We're working on fixing this. Please try again in a moment.",
"error_500_back_home": "Back to Home"
} }

View File

@@ -0,0 +1,33 @@
"""Migration 0028: Generalize paddle_products → payment_products.
New table supports multiple payment providers (paddle, stripe).
Existing paddle_products rows are copied with provider='paddle'.
The old paddle_products table is kept (no drop) for backwards compatibility.
"""
def up(conn) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS payment_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
key TEXT NOT NULL,
provider_product_id TEXT NOT NULL,
provider_price_id TEXT NOT NULL,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
billing_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(provider, key)
)
""")
# Copy existing paddle_products rows
conn.execute("""
INSERT OR IGNORE INTO payment_products
(provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type, created_at)
SELECT
'paddle', key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type, created_at
FROM paddle_products
""")

View File

@@ -18,7 +18,7 @@ from ..core import (
feature_gate, feature_gate,
fetch_all, fetch_all,
fetch_one, fetch_one,
get_paddle_price, get_price_id,
utcnow_iso, utcnow_iso,
) )
from ..i18n import get_translations from ..i18n import get_translations
@@ -687,7 +687,9 @@ async def export_details():
@login_required @login_required
@csrf_protect @csrf_protect
async def export_checkout(): async def export_checkout():
"""Return JSON for Paddle.js overlay checkout for business plan PDF.""" """Return checkout JSON for business plan PDF (works with Paddle overlay or Stripe redirect)."""
from ..billing.routes import _provider
form = await request.form form = await request.form
scenario_id = form.get("scenario_id") scenario_id = form.get("scenario_id")
language = form.get("language", "en") language = form.get("language", "en")
@@ -703,23 +705,20 @@ async def export_checkout():
if not scenario: if not scenario:
return jsonify({"error": "Scenario not found."}), 404 return jsonify({"error": "Scenario not found."}), 404
price_id = await get_paddle_price("business_plan") price_id = await get_price_id("business_plan")
if not price_id: if not price_id:
return jsonify({"error": "Product not configured. Contact support."}), 500 return jsonify({"error": "Product not configured. Contact support."}), 500
return jsonify( payload = _provider().build_checkout_payload(
{ price_id=price_id,
"items": [{"priceId": price_id, "quantity": 1}], custom_data={
"customData": {
"user_id": str(g.user["id"]), "user_id": str(g.user["id"]),
"scenario_id": str(scenario_id), "scenario_id": str(scenario_id),
"language": language, "language": language,
}, },
"settings": { success_url=f"{config.BASE_URL}/planner/export/success",
"successUrl": f"{config.BASE_URL}/planner/export/success",
},
}
) )
return jsonify(payload)
@bp.route("/export/success") @bp.route("/export/success")

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %} {% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %} {% block paddle %}{% include "_payment_js.html" %}{% endblock %}
{% block head %} {% block head %}
<style> <style>
@@ -133,11 +133,7 @@ document.getElementById('export-form').addEventListener('submit', async function
btn.textContent = '{{ t.export_btn }}'; btn.textContent = '{{ t.export_btn }}';
return; return;
} }
Paddle.Checkout.open({ startCheckout(data);
items: data.items,
customData: data.customData,
settings: data.settings,
});
btn.disabled = false; btn.disabled = false;
btn.textContent = '{{ t.export_btn }}'; btn.textContent = '{{ t.export_btn }}';
} catch (err) { } catch (err) {

View File

@@ -3,8 +3,9 @@ Public domain: landing page, marketing pages, legal pages, feedback.
""" """
from pathlib import Path from pathlib import Path
from quart import Blueprint, g, render_template, request, session from quart import Blueprint, abort, g, render_template, request, session
from ..analytics import fetch_analytics
from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one
from ..i18n import get_translations from ..i18n import get_translations
@@ -26,10 +27,21 @@ async def _supplier_counts():
@bp.route("/") @bp.route("/")
async def landing(): async def landing():
total_suppliers, total_countries = await _supplier_counts() total_suppliers, total_countries = await _supplier_counts()
calc_requests = await count_where("scenarios WHERE deleted_at IS NULL")
budget_row = await fetch_one(
"SELECT COALESCE(SUM(budget_estimate), 0) AS total"
" FROM lead_requests WHERE budget_estimate > 0 AND lead_type = 'quote'"
)
total_budget_millions = round((budget_row["total"] if budget_row else 0) / 1_000_000, 1)
# Floor to nearest whole number if > 1, show one decimal if < 1
if total_budget_millions >= 1:
total_budget_millions = int(total_budget_millions)
return await render_template( return await render_template(
"landing.html", "landing.html",
total_suppliers=total_suppliers, total_suppliers=total_suppliers,
total_countries=total_countries, total_countries=total_countries,
calc_requests=calc_requests,
total_budget_millions=total_budget_millions,
) )
@@ -60,6 +72,21 @@ async def market_score():
return await render_template("market_score.html") return await render_template("market_score.html")
@bp.route("/opportunity-map")
async def opportunity_map():
"""Interactive padel opportunity map — country selector + location dots."""
from ..core import is_flag_enabled
if not await is_flag_enabled("maps", default=True):
abort(404)
countries = await fetch_analytics("""
SELECT DISTINCT country_slug, country_name_en
FROM serving.location_profiles
WHERE city_slug IS NOT NULL
ORDER BY country_name_en
""")
return await render_template("opportunity_map.html", countries=countries)
@bp.route("/imprint") @bp.route("/imprint")
async def imprint(): async def imprint():
lang = g.get("lang", "en") lang = g.get("lang", "en")

View File

@@ -118,48 +118,64 @@
} }
.roi-calc__cta:hover { background: #1E40AF; color: #fff; } .roi-calc__cta:hover { background: #1E40AF; color: #fff; }
/* Journey timeline */ /* Proof strip */
.journey-section { padding: 5rem 0 4rem; } .proof-strip {
.journey-section h2 { text-align: center; font-size: 1.75rem; margin-bottom: 3.5rem; } display: flex; align-items: center; justify-content: center; gap: 8px;
.journey-track { padding: 1rem 1.5rem; font-size: 0.8125rem; color: #64748B;
display: grid; grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid #E2E8F0; flex-wrap: wrap;
position: relative; padding: 0 1rem;
} }
.journey-track::after { .proof-strip__dot { color: #CBD5E1; }
content: ''; position: absolute; top: 23px; left: 12%; right: 12%;
height: 2px; background: #E2E8F0; z-index: 0; /* "Sound familiar?" cards */
.familiar-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem;
max-width: 800px; margin: 0 auto;
} }
.journey-step { .familiar-card {
display: flex; flex-direction: column; align-items: center; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 14px;
text-align: center; position: relative; z-index: 1; padding: 1.5rem; position: relative;
} }
.journey-step__num { .familiar-card blockquote {
width: 48px; height: 48px; border-radius: 50%; font-size: 0.9375rem; color: #334155; font-style: italic;
display: flex; align-items: center; justify-content: center; line-height: 1.6; margin: 0 0 0.75rem; padding: 0;
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-weight: 800; font-size: 0.875rem;
background: #fff; border: 2px solid #E2E8F0; color: #CBD5E1;
margin-bottom: 1rem; transition: all 0.2s;
} }
.journey-step--active .journey-step__num { .familiar-card p {
background: #1D4ED8; border-color: #1D4ED8; color: #fff; font-size: 0.8125rem; color: #64748B; margin: 0; line-height: 1.5;
box-shadow: 0 4px 16px rgba(29,78,216,0.3);
} }
.journey-step__title {
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif); /* "Why Padelnomics" comparison */
font-size: 0.9375rem; font-weight: 700; color: #0F172A; margin-bottom: 0.375rem; .vs-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem;
max-width: 900px; margin: 0 auto;
} }
.journey-step__desc { .vs-card {
font-size: 0.8125rem; color: #64748B; max-width: 170px; line-height: 1.5; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem;
display: flex; flex-direction: column;
} }
.journey-step--upcoming { opacity: 0.45; } .vs-card h3 {
.journey-step--upcoming .journey-step__title { color: #64748B; } font-size: 1rem; margin-bottom: 1rem; text-align: center;
.badge-soon {
display: inline-block; background: rgba(29,78,216,0.08); color: #1D4ED8;
font-size: 0.625rem; font-weight: 700; padding: 2px 8px; border-radius: 999px;
margin-left: 4px; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle;
} }
.vs-card dl { flex-grow: 1; }
.vs-card dt {
font-size: 0.6875rem; color: #94A3B8; text-transform: uppercase;
letter-spacing: 0.04em; margin-top: 0.75rem;
}
.vs-card dt:first-of-type { margin-top: 0; }
.vs-card dd { font-size: 0.875rem; color: #475569; margin: 0.25rem 0 0; }
.vs-card--muted { background: #F8FAFC; }
.vs-card--highlight {
border-color: #1D4ED8; border-width: 2px;
box-shadow: 0 4px 16px rgba(29,78,216,0.08);
}
.vs-card__bottom {
font-size: 0.8125rem; color: #94A3B8; font-style: italic;
text-align: center; margin-top: auto; padding-top: 1rem;
}
.vs-card__cta {
display: block; text-align: center; margin-top: auto; padding-top: 1rem;
color: #1D4ED8; font-weight: 600; font-size: 0.875rem; text-decoration: none;
}
.vs-card__cta:hover { text-decoration: underline; }
/* Supplier matching */ /* Supplier matching */
.match-grid { .match-grid {
@@ -225,14 +241,8 @@
.hero-title { font-size: clamp(32px, 8vw, 44px); } .hero-title { font-size: clamp(32px, 8vw, 44px); }
.hero-bullets { flex-wrap: wrap; gap: 12px; } .hero-bullets { flex-wrap: wrap; gap: 12px; }
.roi-metrics { grid-template-columns: 1fr 1fr; } .roi-metrics { grid-template-columns: 1fr 1fr; }
.journey-track { grid-template-columns: 1fr; gap: 2rem; padding: 0; } .familiar-grid { grid-template-columns: 1fr; }
.journey-track::after { display: none; } .vs-grid { grid-template-columns: 1fr; }
.journey-step {
display: grid; grid-template-columns: 48px 1fr;
column-gap: 1rem; text-align: left; align-items: start;
}
.journey-step__num { grid-row: 1 / 3; margin-bottom: 0; }
.journey-step__desc { max-width: none; }
.match-grid { grid-template-columns: 1fr; } .match-grid { grid-template-columns: 1fr; }
} }
</style> </style>
@@ -253,7 +263,7 @@
<p class="hero-desc">{{ t.landing_hero_desc }}</p> <p class="hero-desc">{{ t.landing_hero_desc }}</p>
<div class="hero-actions"> <div class="hero-actions">
<a href="{{ url_for('planner.index') }}" class="btn-hero">{{ t.landing_hero_btn_primary }}</a> <a href="{{ url_for('planner.index') }}" class="btn-hero">{{ t.landing_hero_btn_primary }}</a>
<a href="{{ url_for('directory.index') }}" class="btn-hero-outline">{{ t.landing_hero_btn_secondary }}</a> <a href="{{ url_for('leads.quote_request') }}" class="btn-hero-outline">{{ t.landing_hero_btn_secondary }}</a>
</div> </div>
<div class="hero-bullets"> <div class="hero-bullets">
<span><span class="hero-check">&#x2713;</span> {{ t.landing_hero_bullet_1 }}</span> <span><span class="hero-check">&#x2713;</span> {{ t.landing_hero_bullet_1 }}</span>
@@ -305,36 +315,37 @@
</section> </section>
<main class="container-page"> <main class="container-page">
<!-- Journey Timeline --> <!-- Social proof strip -->
<section class="journey-section"> <div class="proof-strip">
<h2>{{ t.landing_journey_title }}</h2> <span>{{ t.landing_proof_plans | tformat(count=calc_requests) }}</span>
<div class="journey-track"> <span class="proof-strip__dot">&middot;</span>
<div class="journey-step journey-step--upcoming"> <span>{{ t.landing_proof_suppliers | tformat(count=total_suppliers, countries=total_countries) }}</span>
<div class="journey-step__num">01</div> <span class="proof-strip__dot">&middot;</span>
<h3 class="journey-step__title">{{ t.landing_journey_01 }} <span class="badge-soon">{{ t.landing_journey_01_badge }}</span></h3> <span>{{ t.landing_proof_projects | tformat(amount=total_budget_millions) }}</span>
<p class="journey-step__desc">{{ t.landing_journey_01_desc }}</p>
</div> </div>
<div class="journey-step journey-step--active">
<div class="journey-step__num">02</div> <!-- "Sound familiar?" — struggling moments -->
<h3 class="journey-step__title">{{ t.landing_journey_02 }}</h3> <section class="py-12">
<p class="journey-step__desc">{{ t.landing_journey_02_desc }}</p> <h2 class="text-2xl text-center mb-8">{{ t.landing_familiar_title }}</h2>
<div class="familiar-grid">
<div class="familiar-card">
<blockquote>&ldquo;{{ t.landing_familiar_1_quote }}&rdquo;</blockquote>
<p>{{ t.landing_familiar_1_desc }}</p>
</div> </div>
<div class="journey-step journey-step--upcoming"> <div class="familiar-card">
<div class="journey-step__num">03</div> <blockquote>&ldquo;{{ t.landing_familiar_2_quote }}&rdquo;</blockquote>
<h3 class="journey-step__title">{{ t.landing_journey_03 }} <span class="badge-soon">{{ t.landing_journey_03_badge }}</span></h3> <p>{{ t.landing_familiar_2_desc }}</p>
<p class="journey-step__desc">{{ t.landing_journey_03_desc }}</p>
</div> </div>
<div class="journey-step journey-step--active"> <div class="familiar-card">
<div class="journey-step__num">04</div> <blockquote>&ldquo;{{ t.landing_familiar_3_quote }}&rdquo;</blockquote>
<h3 class="journey-step__title">{{ t.landing_journey_04 }}</h3> <p>{{ t.landing_familiar_3_desc }}</p>
<p class="journey-step__desc">{{ t.landing_journey_04_desc | tformat(total_suppliers=total_suppliers, total_countries=total_countries) }}</p>
</div> </div>
<div class="journey-step journey-step--upcoming"> <div class="familiar-card">
<div class="journey-step__num">05</div> <blockquote>&ldquo;{{ t.landing_familiar_4_quote }}&rdquo;</blockquote>
<h3 class="journey-step__title">{{ t.landing_journey_05 }} <span class="badge-soon">{{ t.landing_journey_05_badge }}</span></h3> <p>{{ t.landing_familiar_4_desc }}</p>
<p class="journey-step__desc">{{ t.landing_journey_05_desc }}</p>
</div> </div>
</div> </div>
<p class="text-center text-slate mt-6" style="font-weight:500">{{ t.landing_familiar_cta }}</p>
</section> </section>
<!-- Feature Highlights --> <!-- Feature Highlights -->
@@ -370,6 +381,41 @@
</div> </div>
</section> </section>
<!-- "Why Padelnomics" comparison -->
<section class="py-12">
<h2 class="text-2xl text-center mb-2">{{ t.landing_vs_title }}</h2>
<p class="text-center text-slate mb-8">{{ t.landing_vs_sub }}</p>
<div class="vs-grid">
<div class="vs-card vs-card--muted">
<h3>{{ t.landing_vs_col_diy }}</h3>
<dl>
<dt>{{ t.landing_vs_row1_label }}</dt><dd>{{ t.landing_vs_row1_diy }}</dd>
<dt>{{ t.landing_vs_row2_label }}</dt><dd>{{ t.landing_vs_row2_diy }}</dd>
<dt>{{ t.landing_vs_row3_label }}</dt><dd>{{ t.landing_vs_row3_diy }}</dd>
</dl>
<p class="vs-card__bottom">{{ t.landing_vs_diy_cta }}</p>
</div>
<div class="vs-card vs-card--muted">
<h3>{{ t.landing_vs_col_consultant }}</h3>
<dl>
<dt>{{ t.landing_vs_row1_label }}</dt><dd>{{ t.landing_vs_row1_consultant }}</dd>
<dt>{{ t.landing_vs_row2_label }}</dt><dd>{{ t.landing_vs_row2_consultant }}</dd>
<dt>{{ t.landing_vs_row3_label }}</dt><dd>{{ t.landing_vs_row3_consultant }}</dd>
</dl>
<p class="vs-card__bottom">{{ t.landing_vs_consultant_cta }}</p>
</div>
<div class="vs-card vs-card--highlight">
<h3>{{ t.landing_vs_col_us }}</h3>
<dl>
<dt>{{ t.landing_vs_row1_label }}</dt><dd>{{ t.landing_vs_row1_us }}</dd>
<dt>{{ t.landing_vs_row2_label }}</dt><dd>{{ t.landing_vs_row2_us }}</dd>
<dt>{{ t.landing_vs_row3_label }}</dt><dd>{{ t.landing_vs_row3_us }}</dd>
</dl>
<a href="{{ url_for('planner.index') }}" class="vs-card__cta">{{ t.landing_vs_us_cta }}</a>
</div>
</div>
</section>
<!-- Supplier Matching --> <!-- Supplier Matching -->
<section class="py-12"> <section class="py-12">
<h2 class="text-2xl text-center mb-2">{{ t.landing_supplier_title }}</h2> <h2 class="text-2xl text-center mb-2">{{ t.landing_supplier_title }}</h2>
@@ -392,7 +438,7 @@
</div> </div>
</div> </div>
<div class="text-center mt-8"> <div class="text-center mt-8">
<a href="{{ url_for('directory.index') }}" class="btn-outline">{{ t.landing_supplier_browse_btn }}</a> <a href="{{ url_for('leads.quote_request') }}" class="btn-outline">{{ t.landing_supplier_browse_btn }}</a>
</div> </div>
</section> </section>
@@ -436,7 +482,7 @@
<section style="padding: 2rem 0 4rem"> <section style="padding: 2rem 0 4rem">
<div class="cta-card"> <div class="cta-card">
<h2>{{ t.landing_final_cta_h2 }}</h2> <h2>{{ t.landing_final_cta_h2 }}</h2>
<p>{{ t.landing_final_cta_sub | tformat(total_countries=total_countries) }}</p> <p>{{ t.landing_final_cta_sub }}</p>
<a href="{{ url_for('planner.index') }}" class="cta-card__btn">{{ t.landing_final_cta_btn }}</a> <a href="{{ url_for('planner.index') }}" class="cta-card__btn">{{ t.landing_final_cta_btn }}</a>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Padel Opportunity Map — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Explore padel investment opportunities by location. Find underserved markets with high population, strong sports culture, and no existing padel courts.">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
<style>
#opportunity-map { width: 100%; height: 600px; border-radius: 12px; }
.opp-legend {
background: white; padding: 10px 14px; border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.15); font-size: 0.8125rem; line-height: 1.8;
}
.opp-legend span { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
</style>
{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="mb-6">
<h1 class="text-3xl mb-2">Padel Opportunity Map</h1>
<p class="text-slate">Locations ranked by investment potential — population, supply gap, sports culture, and catchment reach.</p>
</header>
<div class="card mb-4" style="padding: 1rem 1.25rem;">
<label class="form-label" for="opp-country-select" style="margin-bottom: 0.5rem; display:block;">Select a country</label>
<select id="opp-country-select" class="form-input" style="max-width: 280px;">
<option value="">— choose country —</option>
{% for c in countries %}
<option value="{{ c.country_slug }}">{{ c.country_name_en }}</option>
{% endfor %}
</select>
</div>
<div id="opportunity-map"></div>
<div class="mt-4 text-sm text-slate">
<strong>Circle size:</strong> population &nbsp;|&nbsp;
<strong>Color:</strong>
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥70) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (4070) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;vertical-align:middle;margin:0 4px"></span>Low (&lt;40)
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script>
(function() {
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
var map = L.map('opportunity-map', {scrollWheelZoom: false}).setView([48.5, 10], 4);
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
var oppLayer = L.layerGroup().addTo(map);
var refLayer = L.layerGroup().addTo(map);
function oppColor(score) {
if (score >= 70) return '#16A34A';
if (score >= 40) return '#D97706';
return '#3B82F6';
}
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.8;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
var REF_ICON = L.divIcon({
className: '',
html: '<div class="pn-venue" style="background:#94A3B8;border-color:white;opacity:0.7;"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
function fmtPop(p) {
return p >= 1000000 ? (p / 1000000).toFixed(1) + 'M'
: p >= 1000 ? Math.round(p / 1000) + 'K'
: (p || '');
}
function loadCountry(slug) {
oppLayer.clearLayers();
refLayer.clearLayers();
if (!slug) return;
fetch('/api/opportunity/' + slug + '.json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) return;
var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; }));
var bounds = [];
data.forEach(function(loc) {
if (!loc.lat || !loc.lon) return;
var size = 8 + 40 * Math.sqrt((loc.population || 1) / maxPop);
var color = oppColor(loc.opportunity_score);
var dist = loc.nearest_padel_court_km != null
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
: 'No nearby courts';
var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626');
var tip = '<strong>' + loc.location_name + '</strong><br>'
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Opportunity Score: ' + loc.opportunity_score + '/100</span><br>'
+ '<span style="color:' + mktColor + ';font-weight:600;">Padelnomics Market Score: ' + (loc.market_score || 0) + '/100</span><br>'
+ dist + ' · Pop. ' + fmtPop(loc.population);
L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.addTo(oppLayer);
bounds.push([loc.lat, loc.lon]);
});
if (bounds.length) map.fitBounds(bounds, { padding: [30, 30] });
});
// Existing venues as small gray reference dots (drawn first = behind opp dots)
fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); })
.then(function(data) {
data.forEach(function(c) {
if (!c.lat || !c.lon || !c.padel_venue_count) return;
L.marker([c.lat, c.lon], { icon: REF_ICON })
.bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' existing venues',
{ className: 'map-tooltip', direction: 'top', offset: [0, -7] })
.addTo(refLayer);
});
});
}
document.getElementById('opp-country-select').addEventListener('change', function() {
loadCountry(this.value);
});
})();
</script>
{% endblock %}

View File

@@ -256,9 +256,26 @@
.sup-cta h2 { font-size: 1.75rem; margin-bottom: 0.75rem; color: white; } .sup-cta h2 { font-size: 1.75rem; margin-bottom: 0.75rem; color: white; }
.sup-cta p { color: #94A3B8; margin-bottom: 1.5rem; font-size: 1rem; } .sup-cta p { color: #94A3B8; margin-bottom: 1.5rem; font-size: 1rem; }
/* Supplier struggling moments */
.sup-familiar-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem;
max-width: 900px; margin: 0 auto;
}
.sup-familiar-card {
background: #FEF2F2; border: 1px solid #FECACA; border-radius: 14px;
padding: 1.5rem; text-align: center;
}
.sup-familiar-card blockquote {
font-size: 0.9375rem; color: #991B1B; font-style: italic;
line-height: 1.5; margin: 0 0 0.75rem; padding: 0;
}
.sup-familiar-card p {
font-size: 0.8125rem; color: #1D4ED8; font-weight: 600; margin: 0;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.sup-stats { grid-template-columns: repeat(2, 1fr); } .sup-stats { grid-template-columns: repeat(2, 1fr); }
.sup-steps, .sup-why, .sup-problem-grid, .sup-proof-grid { grid-template-columns: 1fr; } .sup-steps, .sup-why, .sup-problem-grid, .sup-proof-grid, .sup-familiar-grid { grid-template-columns: 1fr; }
.pricing-grid { grid-template-columns: 1fr; } .pricing-grid { grid-template-columns: 1fr; }
.lead-preview-grid { grid-template-columns: 1fr; } .lead-preview-grid { grid-template-columns: 1fr; }
.sup-hero h1 { font-size: 1.75rem; } .sup-hero h1 { font-size: 1.75rem; }
@@ -277,24 +294,30 @@
<p class="sup-hero__proof">{{ t.sup_hero_trust_pre }} {{ total_countries }} {{ t.sup_hero_trust_post }}</p> <p class="sup-hero__proof">{{ t.sup_hero_trust_pre }} {{ total_countries }} {{ t.sup_hero_trust_post }}</p>
</div> </div>
<!-- Live Stats --> <!-- Live Stats (conditional — hide if numbers aren't meaningful) -->
<div class="sup-stats"> <div class="sup-stats">
{% if calc_requests >= 10 %}
<div class="sup-stat-card"> <div class="sup-stat-card">
<strong>{{ calc_requests }}+</strong> <strong>{{ calc_requests }}+</strong>
<span>{{ t.sup_stat_plans }}</span> <span>{{ t.sup_stat_plans }}</span>
</div> </div>
{% endif %}
{% if avg_budget and avg_budget > 50000 %}
<div class="sup-stat-card"> <div class="sup-stat-card">
<strong>{% if avg_budget %}&euro;{{ "{:,.0f}".format(avg_budget / 1000) }}K{% else %}&mdash;{% endif %}</strong> <strong>&euro;{{ "{:,.0f}".format(avg_budget / 1000) }}K</strong>
<span>{{ t.sup_stat_avg }}</span> <span>{{ t.sup_stat_avg }}</span>
</div> </div>
{% endif %}
<div class="sup-stat-card"> <div class="sup-stat-card">
<strong>{{ total_suppliers }}+</strong> <strong>{{ total_suppliers }}+</strong>
<span>{{ t.sup_stat_suppliers_pre }} {{ total_countries }} {{ t.sup_stat_suppliers_post }}</span> <span>{{ t.sup_stat_suppliers_pre }} {{ total_countries }} {{ t.sup_stat_suppliers_post }}</span>
</div> </div>
{% if monthly_leads >= 3 %}
<div class="sup-stat-card"> <div class="sup-stat-card">
<strong>{{ monthly_leads }}</strong> <strong>{{ monthly_leads }}</strong>
<span>{{ t.sup_stat_leads }}</span> <span>{{ t.sup_stat_leads }}</span>
</div> </div>
{% endif %}
</div> </div>
<!-- Problem section --> <!-- Problem section -->
@@ -323,6 +346,25 @@
</div> </div>
</section> </section>
<!-- "Is this your sales team?" — struggling moments -->
<section class="sup-section">
<h2 style="text-align:center;margin-bottom:1.5rem">{{ t.sup_familiar_title }}</h2>
<div class="sup-familiar-grid">
<div class="sup-familiar-card">
<blockquote>&ldquo;{{ t.sup_familiar_1_quote }}&rdquo;</blockquote>
<p>{{ t.sup_familiar_1_pivot }}</p>
</div>
<div class="sup-familiar-card">
<blockquote>&ldquo;{{ t.sup_familiar_2_quote }}&rdquo;</blockquote>
<p>{{ t.sup_familiar_2_pivot }}</p>
</div>
<div class="sup-familiar-card">
<blockquote>&ldquo;{{ t.sup_familiar_3_quote }}&rdquo;</blockquote>
<p>{{ t.sup_familiar_3_pivot }}</p>
</div>
</div>
</section>
<!-- Why Padelnomics — moved above lead preview --> <!-- Why Padelnomics — moved above lead preview -->
<section class="sup-section"> <section class="sup-section">
<h2>{{ t.sup_why_h2 }}</h2> <h2>{{ t.sup_why_h2 }}</h2>
@@ -446,22 +488,27 @@
</div> </div>
</section> </section>
<!-- Social proof — moved before pricing --> <!-- What you get with every lead -->
<section class="sup-section"> <section class="sup-section">
<h2>{{ t.sup_proof_h2 }}</h2> <h2>{{ t.sup_proof_h2 }}</h2>
<p class="sub">{{ calc_requests }}+ {{ t.sup_proof_stat1 }} &middot; {{ total_suppliers }}+ {{ t.sup_proof_stat2 }} &middot; {{ total_countries }} {{ t.sup_proof_stat3 }}</p> <p class="sub">{{ calc_requests }}+ {{ t.sup_proof_stat1 }} &middot; {{ total_suppliers }}+ {{ t.sup_proof_stat2 }} &middot; {{ total_countries }} {{ t.sup_proof_stat3 }}</p>
<div class="sup-proof-grid"> <div class="sup-proof-grid">
<div class="sup-proof-card"> <div class="sup-proof-card">
<blockquote>&ldquo;{{ t.sup_proof_q1 }}&rdquo;</blockquote> <h3 style="font-size:1rem;margin-bottom:0.5rem">{{ t.sup_proof_point1_h3 }}</h3>
<cite>{{ t.sup_proof_cite1 }}</cite> <p style="font-size:0.875rem;color:#475569;line-height:1.6;margin:0">{{ t.sup_proof_point1_p }}</p>
</div> </div>
<div class="sup-proof-card"> <div class="sup-proof-card">
<blockquote>&ldquo;{{ t.sup_proof_q2 }}&rdquo;</blockquote> <h3 style="font-size:1rem;margin-bottom:0.5rem">{{ t.sup_proof_point2_h3 }}</h3>
<cite>{{ t.sup_proof_cite2 }}</cite> <p style="font-size:0.875rem;color:#475569;line-height:1.6;margin:0">{{ t.sup_proof_point2_p }}</p>
</div> </div>
</div> </div>
</section> </section>
<!-- ROI callout — bridge to pricing -->
<div class="sup-roi" style="margin-bottom:0">
<p>{{ t.sup_roi_line }}</p>
</div>
<!-- Pricing --> <!-- Pricing -->
<section id="pricing" class="sup-section"> <section id="pricing" class="sup-section">
<!-- Hidden radio inputs MUST come before the elements they control (CSS sibling selector) --> <!-- Hidden radio inputs MUST come before the elements they control (CSS sibling selector) -->
@@ -547,11 +594,6 @@
</div> </div>
</div> </div>
<!-- Static ROI line -->
<div class="sup-roi">
<p>{{ t.sup_roi_line }}</p>
</div>
<!-- Credits-only callout --> <!-- Credits-only callout -->
<div class="sup-credits-only"> <div class="sup-credits-only">
<p>{{ t.sup_credits_only_pre }} <a href="{{ url_for('suppliers.signup') }}?plan=supplier_basic#credits">{{ t.sup_credits_only_cta }}</a></p> <p>{{ t.sup_credits_only_pre }} <a href="{{ url_for('suppliers.signup') }}?plan=supplier_basic#credits">{{ t.sup_credits_only_cta }}</a></p>

View File

@@ -0,0 +1,254 @@
"""
Create or sync Stripe products, prices, and webhook endpoint.
Prerequisites:
- Enable Stripe Tax in your Stripe Dashboard (Settings → Tax)
- Set STRIPE_SECRET_KEY in .env
Commands:
uv run python -m padelnomics.scripts.setup_stripe # create products + webhook
uv run python -m padelnomics.scripts.setup_stripe --sync # re-populate DB from existing Stripe products
"""
import logging
import os
import re
import sqlite3
import sys
from pathlib import Path
import stripe
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
if not STRIPE_SECRET_KEY:
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
logger.error("Set STRIPE_SECRET_KEY in .env first")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
# Product definitions — same keys as setup_paddle.py.
# Prices in EUR cents, matching Paddle exactly.
PRODUCTS = [
# Supplier Growth
{
"key": "supplier_growth",
"name": "Supplier Growth",
"price": 19900,
"currency": "eur",
"interval": "month",
"billing_type": "subscription",
},
{
"key": "supplier_growth_yearly",
"name": "Supplier Growth (Yearly)",
"price": 179900,
"currency": "eur",
"interval": "year",
"billing_type": "subscription",
},
# Supplier Pro
{
"key": "supplier_pro",
"name": "Supplier Pro",
"price": 49900,
"currency": "eur",
"interval": "month",
"billing_type": "subscription",
},
{
"key": "supplier_pro_yearly",
"name": "Supplier Pro (Yearly)",
"price": 449900,
"currency": "eur",
"interval": "year",
"billing_type": "subscription",
},
# Boost add-ons (subscriptions)
{"key": "boost_logo", "name": "Boost: Logo", "price": 2900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_highlight", "name": "Boost: Highlight", "price": 3900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_verified", "name": "Boost: Verified Badge", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_card_color", "name": "Boost: Custom Card Color", "price": 5900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
# One-time boosts
{"key": "boost_sticky_week", "name": "Boost: Sticky Top 1 Week", "price": 7900, "currency": "eur", "billing_type": "one_time"},
{"key": "boost_sticky_month", "name": "Boost: Sticky Top 1 Month", "price": 19900, "currency": "eur", "billing_type": "one_time"},
# Credit packs
{"key": "credits_25", "name": "Credit Pack 25", "price": 9900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_50", "name": "Credit Pack 50", "price": 17900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_100", "name": "Credit Pack 100", "price": 32900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_250", "name": "Credit Pack 250", "price": 74900, "currency": "eur", "billing_type": "one_time"},
# PDF product
{"key": "business_plan", "name": "Padel Business Plan (PDF)", "price": 14900, "currency": "eur", "billing_type": "one_time"},
# Planner subscriptions
{"key": "starter", "name": "Planner Starter", "price": 1900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "pro", "name": "Planner Pro", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
]
_PRODUCT_BY_NAME = {p["name"]: p for p in PRODUCTS}
def _open_db():
db_path = DATABASE_PATH
if not Path(db_path).exists():
logger.error("Database not found at %s. Run migrations first.", db_path)
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _write_product(conn, key, product_id, price_id, name, price_cents, billing_type):
conn.execute(
"""INSERT OR REPLACE INTO payment_products
(provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
("stripe", key, product_id, price_id, name, price_cents, "EUR", billing_type),
)
def sync(conn):
"""Fetch existing Stripe products and re-populate payment_products table."""
logger.info("Syncing products from Stripe...")
# Fetch all products (auto-paginated, max 100 per page)
products = stripe.Product.list(limit=100, active=True)
matched = 0
for product in products.auto_paging_iter():
spec = _PRODUCT_BY_NAME.get(product.name)
if not spec:
continue
# Get the first active price for this product
prices = stripe.Price.list(product=product.id, active=True, limit=1)
if not prices.data:
logger.warning(" SKIP %s: no active prices on %s", spec["key"], product.id)
continue
price = prices.data[0]
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
matched += 1
logger.info(" %s: %s / %s", spec["key"], product.id, price.id)
conn.commit()
if matched == 0:
logger.warning("No matching products found in Stripe. Run without --sync first.")
else:
logger.info("%s/%s products synced to DB", matched, len(PRODUCTS))
def create(conn):
"""Create new products and prices in Stripe, write to DB, set up webhook."""
logger.info("Creating products in Stripe...")
for spec in PRODUCTS:
product = stripe.Product.create(
name=spec["name"],
tax_code="txcd_10000000", # General — Tangible Goods (Stripe default)
)
logger.info(" Product: %s -> %s", spec["name"], product.id)
price_params = {
"product": product.id,
"unit_amount": spec["price"],
"currency": spec["currency"],
"tax_behavior": "exclusive", # Price + tax on top (EU standard)
}
if spec["billing_type"] == "subscription":
interval = spec.get("interval", "month")
price_params["recurring"] = {"interval": interval}
price = stripe.Price.create(**price_params)
logger.info(" Price: %s = %s", spec["key"], price.id)
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
conn.commit()
logger.info("All products written to DB")
# -- Webhook endpoint -------------------------------------------------------
webhook_url = f"{BASE_URL}/billing/webhook/stripe"
enabled_events = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.payment_failed",
]
logger.info("Creating webhook endpoint...")
logger.info(" URL: %s", webhook_url)
try:
endpoint = stripe.WebhookEndpoint.create(
url=webhook_url,
enabled_events=enabled_events,
)
except stripe.InvalidRequestError as exc:
logger.warning(" Webhook endpoint creation failed: %s", exc.user_message)
logger.info(" For local dev, use Stripe CLI: stripe listen --forward-to %s", webhook_url)
logger.info("Done (products created, webhook skipped).")
return
webhook_secret = endpoint.secret
logger.info(" ID: %s", endpoint.id)
logger.info(" Secret: %s", webhook_secret)
env_path = Path(".env")
env_vars = {
"STRIPE_WEBHOOK_SECRET": webhook_secret,
"STRIPE_WEBHOOK_ENDPOINT_ID": endpoint.id,
}
if env_path.exists():
env_text = env_path.read_text()
for key, value in env_vars.items():
pattern = rf"^{key}=.*$"
replacement = f"{key}={value}"
if re.search(pattern, env_text, flags=re.MULTILINE):
env_text = re.sub(pattern, replacement, env_text, flags=re.MULTILINE)
else:
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
env_path.write_text(env_text)
logger.info("STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID written to .env")
else:
logger.info("Add to .env:")
for key, value in env_vars.items():
logger.info(" %s=%s", key, value)
logger.info("Done. Remember to enable Stripe Tax in your Dashboard (Settings > Tax).")
def main():
conn = _open_db()
try:
if "--sync" in sys.argv:
sync(conn)
else:
create(conn)
finally:
conn.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

View File

@@ -860,3 +860,57 @@
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Leaflet map overrides (beanflows-style) ── */
/* Dark tooltip — no arrow */
.leaflet-tooltip.map-tooltip {
background: #1E293B;
color: rgba(255,255,255,0.9);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
font-size: 0.8125rem;
line-height: 1.6;
white-space: nowrap;
pointer-events: none;
}
.leaflet-tooltip.map-tooltip::before { display: none; }
.leaflet-tooltip.map-tooltip strong { color: white; }
/* Polished variable-size circle — white border + drop shadow */
.pn-marker {
border-radius: 50%;
border: 2.5px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
cursor: pointer;
transition: box-shadow 0.15s, transform 0.1s;
}
.pn-marker:hover {
box-shadow: 0 3px 12px rgba(0,0,0,0.38);
transform: scale(1.1);
}
/* Non-article city markers: faded + dashed border, no click affordance */
.pn-marker--muted {
opacity: 0.45;
border: 2px dashed rgba(255,255,255,0.6);
cursor: default;
filter: saturate(0.7);
}
.pn-marker--muted:hover {
transform: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
}
/* Small fixed venue dot */
.pn-venue {
width: 10px;
height: 10px;
border-radius: 50%;
background: #1D4ED8;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
cursor: pointer;
}

View File

@@ -455,6 +455,8 @@
border-radius: 18px; border-radius: 18px;
padding: 1rem; padding: 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.04); box-shadow: 0 1px 4px rgba(0,0,0,0.04);
min-width: 0;
overflow: hidden;
} }
.chart-container__label { .chart-container__label {
font-size: 11px; font-size: 11px;

View File

@@ -0,0 +1,122 @@
/**
* Leaflet map initialisation for article pages (country + city maps).
*
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
* variable pointing to the Leaflet JS bundle.
*/
(function() {
var countryMapEl = document.getElementById('country-map');
var cityMapEl = document.getElementById('city-map');
if (!countryMapEl && !cityMapEl) return;
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
function scoreColor(score) {
if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706';
return '#DC2626';
}
function makeIcon(size, color, muted) {
var s = Math.round(size);
var cls = 'pn-marker' + (muted ? ' pn-marker--muted' : '');
return L.divIcon({
className: '',
html: '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
function initCountryMap(el) {
var slug = el.dataset.countrySlug;
var map = L.map(el, {scrollWheelZoom: false});
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
var lang = document.documentElement.lang || 'en';
fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) return;
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
var bounds = [];
data.forEach(function(c) {
if (!c.lat || !c.lon) return;
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
var hasArticle = c.has_article !== false;
var color = scoreColor(c.market_score);
var pop = c.population >= 1000000
? (c.population / 1000000).toFixed(1) + 'M'
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#3B82F6');
var tip = '<strong>' + c.city_name + '</strong><br>'
+ (c.padel_venue_count || 0) + ' venues'
+ (pop ? ' · ' + pop : '')
+ '<br><span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + Math.round(c.market_score) + '/100</span>'
+ '<br><span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span>';
if (hasArticle) {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Click to explore →</span>';
} else {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Coming soon</span>';
}
var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color, !hasArticle) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.addTo(map);
if (hasArticle) {
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
}
bounds.push([c.lat, c.lon]);
});
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
})
.catch(function(err) { console.error('Country map fetch failed:', err); });
}
function initCityMap(el, venueIcon) {
var countrySlug = el.dataset.countrySlug;
var citySlug = el.dataset.citySlug;
var lat = parseFloat(el.dataset.lat);
var lon = parseFloat(el.dataset.lon);
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
.then(function(r) { return r.json(); })
.then(function(data) {
data.forEach(function(v) {
if (!v.lat || !v.lon) return;
var indoor = v.indoor_court_count || 0;
var outdoor = v.outdoor_court_count || 0;
var total = v.court_count || (indoor + outdoor);
var courtLine = total
? total + ' court' + (total > 1 ? 's' : '')
+ (indoor || outdoor
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
: '')
: '';
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
L.marker([v.lat, v.lon], { icon: venueIcon })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
.addTo(map);
});
})
.catch(function(err) { console.error('City map fetch failed:', err); });
}
/* Dynamically load Leaflet JS then init maps */
var script = document.createElement('script');
script.src = window.LEAFLET_JS_URL || '/static/vendor/leaflet/leaflet.min.js';
script.onload = function() {
if (countryMapEl) initCountryMap(countryMapEl);
if (cityMapEl) {
var venueIcon = L.divIcon({
className: '',
html: '<div class="pn-venue"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
initCityMap(cityMapEl, venueIcon);
}
};
document.head.appendChild(script);
})();

Some files were not shown because too many files have changed in this diff Show More