Compare commits
30 Commits
v202603092
...
v202603111
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
988daec452 | ||
|
|
e5f9dbacad | ||
|
|
f7d10f39cb | ||
|
|
b8cd58bf8a | ||
|
|
fb03602653 | ||
|
|
61b072bf7c | ||
|
|
64b82dbd4a | ||
|
|
2d3a53a736 | ||
|
|
bed07974cb | ||
|
|
207fa18fda | ||
|
|
4e81987741 | ||
|
|
8cc1cef780 | ||
|
|
236f0d1061 | ||
|
|
44617ea783 | ||
|
|
301f3b76c3 | ||
|
|
018eacb0f3 | ||
|
|
abacaac3f5 | ||
|
|
241e0de78e | ||
|
|
fc21c25c82 | ||
|
|
bd7fa1ae9a | ||
|
|
511a0ebac7 | ||
|
|
97ba13c42a | ||
|
|
1bd5bae90d | ||
|
|
608f16f578 | ||
|
|
927f77ae5e | ||
|
|
adf6f0c1ef | ||
|
|
9dc705970e | ||
|
|
9c5bed01f5 | ||
|
|
3ce97cd41b | ||
|
|
ff6401254a |
@@ -74,6 +74,30 @@ DUCKDB_PATH=local.duckdb SERVING_DUCKDB_PATH=analytics.duckdb \
|
||||
|
||||
```
|
||||
|
||||
## Production operations
|
||||
|
||||
Use `scripts/prod.py` for all prod server operations over SSH. **Always prefer this over raw SSH commands** — it handles escaping, timeouts, and streaming.
|
||||
|
||||
```bash
|
||||
# Query analytics.duckdb (serving tables — default)
|
||||
uv run python scripts/prod.py query "SELECT COUNT(*) FROM serving.location_profiles"
|
||||
|
||||
# Query lakehouse.duckdb (foundation/staging tables)
|
||||
uv run python scripts/prod.py query --db lakehouse "SELECT * FROM foundation.dim_countries LIMIT 5"
|
||||
|
||||
# JSON output
|
||||
uv run python scripts/prod.py query --json "SELECT COUNT(*) FROM serving.location_profiles"
|
||||
|
||||
# Pipeline operations
|
||||
uv run python scripts/prod.py sqlmesh-plan --dry-run # preview only
|
||||
uv run python scripts/prod.py sqlmesh-plan # plan + auto-apply
|
||||
uv run python scripts/prod.py export # export serving tables
|
||||
uv run python scripts/prod.py status # supervisor status
|
||||
uv run python scripts/prod.py logs # last 100 log lines
|
||||
uv run python scripts/prod.py logs -f # follow logs
|
||||
uv run python scripts/prod.py deploy # blue/green deploy
|
||||
```
|
||||
|
||||
## Architecture documentation
|
||||
|
||||
| Topic | File |
|
||||
|
||||
@@ -74,12 +74,13 @@ GSC_SERVICE_ACCOUNT_PATH=
|
||||
GSC_SITE_URL=
|
||||
BING_WEBMASTER_API_KEY=
|
||||
BING_SITE_URL=
|
||||
CLARITY_PROJECT_ID=ENC[AES256_GCM,data:PQ==,iv:GqQLR3UERBEGtqpZXAkZ8ETyVdj7+pk4YwuBPVxcjyE=,tag:1uuH2Gw3zE78Pugy6i6eDg==,type:str]
|
||||
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
|
||||
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,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_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||
sops_lastmodified=2026-03-03T15:16:35Z
|
||||
sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
|
||||
sops_lastmodified=2026-03-10T15:07:09Z
|
||||
sops_mac=ENC[AES256_GCM,data:mYPhIGSZIN+nqFEQE5VmLGaoTOvxFQ7fXvOHWcYtjr+AL/Zmnt81bo8Icgja5IMQPplSWoBo4J/7N08kSHATuBDuvCxNrsJaqTzCriTwfXq0WFa5yvoce/Sd29JEDAN505L+mR1PovhfIPndTR/E1bLvcyTz2NuAq5VGSg6KcUU=,iv:6mrSOWqIOItVt7Dp6jNecvzLjaTw/qQMr5b28I/bZWU=,tag:bT6Zi0Mb5Ci0CZBqr9iB3g==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
@@ -50,6 +50,8 @@ GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ
|
||||
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
|
||||
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHTwBr/bNnCeqsTrUqAAYM5Vs=,tag:4jBxqgpyomzMLwiC9XpfVQ==,type:str]
|
||||
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
|
||||
INDEXNOW_KEY=ENC[AES256_GCM,data:3AJnmPOQJoKw525QR7jx6QBzV9kznUsWqHRmQjv1cU8=,iv:4XRmcPKrFE8S3GzsfNbxUdaUNaKc6z9T+ihUUwjZ8Y0=,tag:wERrhY9whJ9yTEgt8ewaMQ==,type:str]
|
||||
CLARITY_PROJECT_ID=ENC[AES256_GCM,data:mLQ4vvtDFpZOpCg=,iv:S58K5Qf32EFlAuh8xkjo603wVpCOhNodZLJ4ZyaGF6c=,tag:LUQor6rIRc7unYCyytSgSg==,type:str]
|
||||
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
|
||||
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
|
||||
CENSUS_API_KEY=ENC[AES256_GCM,data:9RbKlxSD17LqIuuNXaOKSgZ8LnFh9Wbze3XHgpctfV/1TqBMZTIedQ==,iv:WwsmR3HLUEcgUpLliGRaUPhGM9vFNPMGXSAQQ6+9UVc=,tag:R4EMNy5MxxvK0UTaCL0umA==,type:str]
|
||||
@@ -64,7 +66,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
||||
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_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||
sops_lastmodified=2026-03-05T15:55:19Z
|
||||
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_lastmodified=2026-03-10T15:05:54Z
|
||||
sops_mac=ENC[AES256_GCM,data:85sRBn6/gjXZFgyZlFk2RyMQGYK/e6rVC879F7/APj0xeguY5q4ui4OaE7OpO+joRMoLbE+rCWjYEyTeToTTdCNJ30yLiwlTrKR+tnmegJ/8wUAdyJtI8KO6XxKZpAesbiKl+o4F38iBZMhuZ6iybQx1RGF8SzQRu+E3fUEIiKk=,iv:/uW53lMRNNk/a/bzPvWqwDzP0un5/1muBDvDLSRet58=,tag:56LC972g6HSipvwxfSepJg==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,7 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **pSEO error details collapse** — clicking "Error" on a job row expanded the details, but they collapsed after ~2s because HTMX polling replaced the `<tr>`. Jobs with errors now stop polling, keeping the `<details>` element stable.
|
||||
- **UNIQUE constraint on article slug** — `ON CONFLICT(url_path, language)` upsert failed because a separate single-column `UNIQUE` on `slug` fired first. Migration 0030 drops the redundant `UNIQUE` from `slug` (keeps the index for lookups and the composite `UNIQUE(url_path, language)`).
|
||||
- **Map country names** — 22 countries (PL, RO, CO, HU, ZA, KE, BR, CZ, QA, NZ, HR, LV, MT, CR, CY, PA, SV, DO, PE, VE, EE, ID) that appeared as bare ISO codes on the markets map and dropdown now show proper English/German names. Added country names to `dim_countries.sql`, `COUNTRY_LABELS` (i18n.py), and both locale files. Map tooltips and dropdown are now fully localised via `get_country_name()`.
|
||||
- **Map score tooltip clarity** — tooltip now shows both "Avg. Score" (country average) and "Top City" (highest location score) with separate color dots, making clear the map bubble color represents the country average — not a cap.
|
||||
|
||||
### Added
|
||||
- **Microsoft Clarity integration** — consent-gated heatmaps and session recordings (project ID via `CLARITY_PROJECT_ID` env var). Script only loads when the user has accepted functional cookies; bootstraps immediately on consent without requiring a reload. Privacy policy (EN + DE) updated with Clarity disclosure: data collection, sub-processor, cookies (`_clck`, `_clsk`), and international transfers.
|
||||
- **IndexNow integration** — push-notify Bing, Yandex, Seznam, and Naver when articles are published/unpublished/edited or suppliers are created. Bulk operations batch all URLs into a single request. Skips silently in dev (no key configured). Serves key verification file at `/{key}.txt`.
|
||||
|
||||
### Fixed
|
||||
- **SEO audit fixes** — sitemap: replaced `/market-score` with `/padelnomics-score`, added `/opportunity-map`, removed `/billing/pricing` (blocked by robots.txt), deduplicated articles query (was producing 4 entries per article instead of 2). Fixed `/market-score` redirect chain (1 hop instead of 2). Moved default OG tags inside `{% block head %}` so child templates replace rather than duplicate them. Added JSON-LD WebPage + BreadcrumbList schema to features, planner, and directory pages. Added meta descriptions to export pages.
|
||||
|
||||
### Changed
|
||||
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
|
||||
- **Opportunity Score v6 → v7 (calibration fix)** — two fixes for inflated scores in saturated markets. (1) `dim_locations` now sources venue coordinates from `dim_venues` (deduplicated OSM + Playtomic) instead of `stg_padel_courts` (OSM only), making Playtomic-only venues visible to spatial lookups. (2) Country-level supply saturation dampener on the 40-pt supply deficit component: saturated countries (Spain ~4.5/100k) get dampened supply deficit (×0.55 → 22 pts max), emerging markets (Germany ~0.7/100k) are nearly unaffected (×0.93 → ~37 pts).
|
||||
- **Single-score simplification** — consolidated two public-facing scores (Market Score + Opportunity Score) into one **Padelnomics Score** (internally: `opportunity_score`). All maps, tooltips, article templates, and the methodology page now show a single score. Dual-ring markers reverted to single-color markers. `/market-score` route renamed to `/padelnomics-score` (old URL 301-redirects). All `mscore_*` i18n keys replaced with `pnscore_*`. Business plan queries `opportunity_score` from `location_profiles` (replaces legacy `city_market_overview` view). Map tooltip strings now i18n'd via `window.__MAP_T` (12 keys, EN + DE).
|
||||
|
||||
|
||||
266
scripts/prod.py
Normal file
266
scripts/prod.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Unified prod server tool — query, pipeline ops, deploy, logs.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/prod.py query "SELECT COUNT(*) FROM serving.location_profiles"
|
||||
uv run python scripts/prod.py query --db lakehouse "SELECT 1"
|
||||
uv run python scripts/prod.py query --json "SELECT * FROM serving.pseo_country_overview LIMIT 3"
|
||||
uv run python scripts/prod.py sqlmesh-plan
|
||||
uv run python scripts/prod.py sqlmesh-plan --dry-run
|
||||
uv run python scripts/prod.py export
|
||||
uv run python scripts/prod.py deploy
|
||||
uv run python scripts/prod.py status
|
||||
uv run python scripts/prod.py logs
|
||||
uv run python scripts/prod.py logs -f
|
||||
uv run python scripts/prod.py logs -n 50
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
SSH_HOST = "hetzner_root"
|
||||
SSH_USER = "padelnomics_service"
|
||||
REPO_DIR = "/opt/padelnomics"
|
||||
DATA_DIR = "/data/padelnomics"
|
||||
DB_PATHS = {
|
||||
"analytics": f"{DATA_DIR}/analytics.duckdb",
|
||||
"lakehouse": f"{DATA_DIR}/lakehouse.duckdb",
|
||||
}
|
||||
LANDING_DIR = f"{DATA_DIR}/landing"
|
||||
|
||||
MAX_ROWS = 500
|
||||
QUERY_TIMEOUT_SECONDS = 40
|
||||
SQLMESH_TIMEOUT_SECONDS = 14400 # 4h — matches supervisor SUBPROCESS_TIMEOUT_SECONDS
|
||||
EXPORT_TIMEOUT_SECONDS = 300
|
||||
DEPLOY_TIMEOUT_SECONDS = 600
|
||||
STATUS_TIMEOUT_SECONDS = 30
|
||||
|
||||
# Mutation keywords blocked (defense in depth — DB is read_only anyway)
|
||||
BLOCKED_KEYWORDS = {
|
||||
"CREATE", "DROP", "ALTER", "INSERT", "UPDATE", "DELETE",
|
||||
"ATTACH", "COPY", "EXPORT", "INSTALL", "LOAD",
|
||||
}
|
||||
|
||||
# Remote Python script template for query subcommand.
|
||||
# Receives SQL as base64 via {b64_sql}.
|
||||
# Uses ATTACH + USE to alias the lakehouse catalog as "local" for SQLMesh view compat.
|
||||
REMOTE_QUERY_SCRIPT = """\
|
||||
import duckdb, json, sys, base64
|
||||
db_path = "{db_path}"
|
||||
sql = base64.b64decode("{b64_sql}").decode()
|
||||
max_rows = {max_rows}
|
||||
output_json = {output_json}
|
||||
try:
|
||||
if "lakehouse" in db_path:
|
||||
con = duckdb.connect(":memory:")
|
||||
con.execute(f"ATTACH '{{db_path}}' AS local (READ_ONLY)")
|
||||
con.execute("USE local")
|
||||
else:
|
||||
con = duckdb.connect(db_path, read_only=True)
|
||||
result = con.execute(sql)
|
||||
cols = [d[0] for d in result.description]
|
||||
rows = result.fetchmany(max_rows)
|
||||
if output_json:
|
||||
print(json.dumps({{"columns": cols, "rows": [list(r) for r in rows], "count": len(rows)}}, default=str))
|
||||
else:
|
||||
print("\\t".join(cols))
|
||||
for row in rows:
|
||||
print("\\t".join(str(v) if v is not None else "NULL" for v in row))
|
||||
if len(rows) == max_rows:
|
||||
print(f"... truncated at {{max_rows}} rows", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {{e}}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
|
||||
# --- SSH execution ---
|
||||
|
||||
|
||||
def run_ssh_shell(shell_cmd, *, timeout_seconds=None, capture=False):
|
||||
"""Run a shell command on the prod server via SSH, streaming or capturing output.
|
||||
|
||||
Commands run as SSH_USER via sudo. Returns the remote exit code.
|
||||
"""
|
||||
ssh_cmd = [
|
||||
"ssh", SSH_HOST,
|
||||
f"sudo -u {SSH_USER} bash -lc {_shell_quote(f'cd {REPO_DIR} && {shell_cmd}')}",
|
||||
]
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
ssh_cmd, capture_output=True, text=True, timeout=timeout_seconds,
|
||||
)
|
||||
if result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if result.stderr:
|
||||
print(result.stderr, end="", file=sys.stderr)
|
||||
return result.returncode
|
||||
else:
|
||||
result = subprocess.run(ssh_cmd, timeout=timeout_seconds)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_ssh_shell_as_root(shell_cmd, *, timeout_seconds=None):
|
||||
"""Run a shell command as root (not the service user). For journalctl etc."""
|
||||
ssh_cmd = ["ssh", SSH_HOST, shell_cmd]
|
||||
result = subprocess.run(ssh_cmd, timeout=timeout_seconds)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_ssh_python(script, *, timeout_seconds):
|
||||
"""Send a Python script to the prod server via SSH stdin and capture output."""
|
||||
ssh_cmd = [
|
||||
"ssh", SSH_HOST,
|
||||
f"sudo -u {SSH_USER} bash -lc 'cd {REPO_DIR} && uv run python3 -'",
|
||||
]
|
||||
return subprocess.run(
|
||||
ssh_cmd, input=script, capture_output=True, text=True,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _shell_quote(s):
|
||||
"""Single-quote a string for shell, escaping embedded single quotes."""
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
# --- Subcommands ---
|
||||
|
||||
|
||||
def cmd_query(args):
|
||||
sql = args.sql
|
||||
if args.stdin or sql is None:
|
||||
sql = sys.stdin.read().strip()
|
||||
if not sql:
|
||||
print("ERROR: No SQL provided", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
sql_upper = sql.upper()
|
||||
for kw in BLOCKED_KEYWORDS:
|
||||
if kw in sql_upper:
|
||||
print(f"ERROR: Blocked keyword '{kw}' in query", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
b64_sql = base64.b64encode(sql.encode()).decode()
|
||||
remote_script = REMOTE_QUERY_SCRIPT.format(
|
||||
db_path=DB_PATHS[args.db],
|
||||
b64_sql=b64_sql,
|
||||
max_rows=args.max_rows,
|
||||
output_json=args.json,
|
||||
)
|
||||
|
||||
result = run_ssh_python(remote_script, timeout_seconds=QUERY_TIMEOUT_SECONDS)
|
||||
if result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if result.stderr:
|
||||
print(result.stderr, end="", file=sys.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def cmd_sqlmesh_plan(args):
|
||||
auto_apply = "" if args.dry_run else " --auto-apply"
|
||||
shell_cmd = (
|
||||
f"LANDING_DIR={LANDING_DIR} "
|
||||
f"DUCKDB_PATH={DB_PATHS['lakehouse']} "
|
||||
f"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod{auto_apply}"
|
||||
)
|
||||
return run_ssh_shell(shell_cmd, timeout_seconds=SQLMESH_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
shell_cmd = (
|
||||
f"DUCKDB_PATH={DB_PATHS['lakehouse']} "
|
||||
f"SERVING_DUCKDB_PATH={DB_PATHS['analytics']} "
|
||||
"uv run python src/padelnomics/export_serving.py"
|
||||
)
|
||||
return run_ssh_shell(shell_cmd, timeout_seconds=EXPORT_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def cmd_deploy(args):
|
||||
return run_ssh_shell("bash deploy.sh", timeout_seconds=DEPLOY_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
shell_cmd = "uv run python src/padelnomics/supervisor.py status"
|
||||
return run_ssh_shell(shell_cmd, timeout_seconds=STATUS_TIMEOUT_SECONDS, capture=True)
|
||||
|
||||
|
||||
def cmd_logs(args):
|
||||
follow = " -f" if args.follow else ""
|
||||
shell_cmd = f"journalctl -u padelnomics-supervisor --no-pager -n {args.lines}{follow}"
|
||||
return run_ssh_shell_as_root(shell_cmd, timeout_seconds=None if args.follow else STATUS_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
# --- CLI ---
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="prod",
|
||||
description="Unified prod server tool — query, pipeline ops, deploy, logs",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# query
|
||||
p_query = subparsers.add_parser("query", help="Run a read-only DuckDB query")
|
||||
p_query.add_argument("sql", nargs="?", help="SQL query to run")
|
||||
p_query.add_argument("--stdin", action="store_true", help="Read SQL from stdin")
|
||||
p_query.add_argument(
|
||||
"--db", choices=list(DB_PATHS.keys()), default="analytics",
|
||||
help="Which database (default: analytics)",
|
||||
)
|
||||
p_query.add_argument(
|
||||
"--max-rows", type=int, default=MAX_ROWS,
|
||||
help=f"Max rows to return (default: {MAX_ROWS})",
|
||||
)
|
||||
p_query.add_argument("--json", action="store_true", help="Output JSON instead of TSV")
|
||||
p_query.set_defaults(func=cmd_query)
|
||||
|
||||
# sqlmesh-plan
|
||||
p_sqlmesh = subparsers.add_parser("sqlmesh-plan", help="Run SQLMesh plan prod")
|
||||
p_sqlmesh.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Show plan without applying (omits --auto-apply)",
|
||||
)
|
||||
p_sqlmesh.set_defaults(func=cmd_sqlmesh_plan)
|
||||
|
||||
# export
|
||||
p_export = subparsers.add_parser("export", help="Export serving tables to analytics.duckdb")
|
||||
p_export.set_defaults(func=cmd_export)
|
||||
|
||||
# deploy
|
||||
p_deploy = subparsers.add_parser("deploy", help="Run deploy.sh (blue/green swap)")
|
||||
p_deploy.set_defaults(func=cmd_deploy)
|
||||
|
||||
# status
|
||||
p_status = subparsers.add_parser("status", help="Show supervisor status")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
# logs
|
||||
p_logs = subparsers.add_parser("logs", help="Show supervisor journal logs")
|
||||
p_logs.add_argument(
|
||||
"-f", "--follow", action="store_true",
|
||||
help="Follow log output (Ctrl-C to stop)",
|
||||
)
|
||||
p_logs.add_argument(
|
||||
"-n", "--lines", type=int, default=100,
|
||||
help="Number of log lines to show (default: 100)",
|
||||
)
|
||||
p_logs.set_defaults(func=cmd_logs)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
print(f"\nERROR: Timed out after {exc.timeout}s: {' '.join(str(a) for a in exc.cmd)}", file=sys.stderr)
|
||||
sys.exit(124)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
@@ -267,48 +267,6 @@ def run_export() -> None:
|
||||
send_alert(f"[export] {err}")
|
||||
|
||||
|
||||
_last_seen_head: str | None = None
|
||||
|
||||
|
||||
def web_code_changed() -> bool:
|
||||
"""True on the first tick after a commit that changed web app code or secrets.
|
||||
|
||||
Compares the current HEAD to the HEAD from the previous tick. On first call
|
||||
after process start (e.g. after os.execv reloads new code), falls back to
|
||||
HEAD~1 so the just-deployed commit is evaluated exactly once.
|
||||
|
||||
Records HEAD before returning so the same commit never triggers twice.
|
||||
"""
|
||||
global _last_seen_head
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
current_head = result.stdout.strip()
|
||||
|
||||
if _last_seen_head is None:
|
||||
# Fresh process — use HEAD~1 as base (evaluates the newly deployed tag).
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD~1"], capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
base = base_result.stdout.strip() if base_result.returncode == 0 else current_head
|
||||
else:
|
||||
base = _last_seen_head
|
||||
|
||||
_last_seen_head = current_head # advance now — won't fire again for this HEAD
|
||||
|
||||
if base == current_head:
|
||||
return False
|
||||
|
||||
diff = subprocess.run(
|
||||
["git", "diff", "--name-only", base, current_head, "--",
|
||||
"web/", "Dockerfile", ".env.prod.sops"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return bool(diff.stdout.strip())
|
||||
|
||||
|
||||
def current_deployed_tag() -> str | None:
|
||||
"""Return the highest-version tag pointing at HEAD, or None.
|
||||
|
||||
@@ -360,6 +318,15 @@ def git_pull_and_sync() -> None:
|
||||
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")
|
||||
# Always redeploy the web app on new tag — blue/green swap is zero-downtime
|
||||
# and Docker layer caching makes no-op builds fast. Previous approach of
|
||||
# diffing HEAD~1 missed changes inside merge commits.
|
||||
logger.info("Deploying web app (blue/green swap)")
|
||||
ok, err = run_shell("./deploy.sh")
|
||||
if ok:
|
||||
send_alert(f"[deploy] {latest} ok")
|
||||
else:
|
||||
send_alert(f"[deploy] {latest} failed: {err}")
|
||||
# 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.
|
||||
logger.info("Deploy complete — re-execing to load new code")
|
||||
@@ -408,14 +375,6 @@ def tick() -> None:
|
||||
# Export serving tables
|
||||
run_export()
|
||||
|
||||
# Deploy web app if code changed
|
||||
if os.getenv("SUPERVISOR_GIT_PULL") and web_code_changed():
|
||||
logger.info("Web code changed — deploying")
|
||||
ok, err = run_shell("./deploy.sh")
|
||||
if ok:
|
||||
send_alert("[deploy] ok")
|
||||
else:
|
||||
send_alert(f"[deploy] failed: {err}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -148,6 +148,28 @@ SELECT
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
WHEN 'PL' THEN 'Poland'
|
||||
WHEN 'RO' THEN 'Romania'
|
||||
WHEN 'CO' THEN 'Colombia'
|
||||
WHEN 'HU' THEN 'Hungary'
|
||||
WHEN 'ZA' THEN 'South Africa'
|
||||
WHEN 'KE' THEN 'Kenya'
|
||||
WHEN 'BR' THEN 'Brazil'
|
||||
WHEN 'CZ' THEN 'Czech Republic'
|
||||
WHEN 'QA' THEN 'Qatar'
|
||||
WHEN 'NZ' THEN 'New Zealand'
|
||||
WHEN 'HR' THEN 'Croatia'
|
||||
WHEN 'LV' THEN 'Latvia'
|
||||
WHEN 'MT' THEN 'Malta'
|
||||
WHEN 'CR' THEN 'Costa Rica'
|
||||
WHEN 'CY' THEN 'Cyprus'
|
||||
WHEN 'PA' THEN 'Panama'
|
||||
WHEN 'SV' THEN 'El Salvador'
|
||||
WHEN 'DO' THEN 'Dominican Republic'
|
||||
WHEN 'PE' THEN 'Peru'
|
||||
WHEN 'VE' THEN 'Venezuela'
|
||||
WHEN 'EE' THEN 'Estonia'
|
||||
WHEN 'ID' THEN 'Indonesia'
|
||||
ELSE ac.country_code
|
||||
END AS country_name_en,
|
||||
LOWER(REGEXP_REPLACE(
|
||||
@@ -172,6 +194,28 @@ SELECT
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
WHEN 'PL' THEN 'Poland'
|
||||
WHEN 'RO' THEN 'Romania'
|
||||
WHEN 'CO' THEN 'Colombia'
|
||||
WHEN 'HU' THEN 'Hungary'
|
||||
WHEN 'ZA' THEN 'South Africa'
|
||||
WHEN 'KE' THEN 'Kenya'
|
||||
WHEN 'BR' THEN 'Brazil'
|
||||
WHEN 'CZ' THEN 'Czech Republic'
|
||||
WHEN 'QA' THEN 'Qatar'
|
||||
WHEN 'NZ' THEN 'New Zealand'
|
||||
WHEN 'HR' THEN 'Croatia'
|
||||
WHEN 'LV' THEN 'Latvia'
|
||||
WHEN 'MT' THEN 'Malta'
|
||||
WHEN 'CR' THEN 'Costa Rica'
|
||||
WHEN 'CY' THEN 'Cyprus'
|
||||
WHEN 'PA' THEN 'Panama'
|
||||
WHEN 'SV' THEN 'El Salvador'
|
||||
WHEN 'DO' THEN 'Dominican Republic'
|
||||
WHEN 'PE' THEN 'Peru'
|
||||
WHEN 'VE' THEN 'Venezuela'
|
||||
WHEN 'EE' THEN 'Estonia'
|
||||
WHEN 'ID' THEN 'Indonesia'
|
||||
ELSE ac.country_code
|
||||
END, '[^a-zA-Z0-9]+', '-'
|
||||
)) AS country_slug,
|
||||
|
||||
@@ -19,20 +19,23 @@
|
||||
-- 10 pts economic context — income PPS normalised to 25,000 ceiling
|
||||
-- 10 pts data quality — completeness discount
|
||||
--
|
||||
-- Padelnomics Opportunity Score (Marktpotenzial-Score v7, 0–100):
|
||||
-- Padelnomics Opportunity Score (Marktpotenzial-Score v8, 0–100):
|
||||
-- "Where should I build a padel court?"
|
||||
-- Computed for ALL locations — zero-court locations score highest on supply deficit.
|
||||
-- H3 catchment methodology: addressable market and supply deficit use a regional
|
||||
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
|
||||
--
|
||||
-- v7 changes: country-level supply saturation dampener on supply deficit.
|
||||
-- Saturated countries (Spain 7.4/100k) get dampened supply deficit (×0.30 → 12 pts max).
|
||||
-- Emerging markets (Germany 0.24/100k) are nearly unaffected (×0.98 → ~39 pts).
|
||||
-- Floor at 0.3 so supply deficit never fully vanishes.
|
||||
-- v8 changes: better spread/discrimination.
|
||||
-- - Reweight: addressable market 20→15, economic power 15→10, supply deficit 40→50.
|
||||
-- - Supply deficit existence dampener: country_venues/50 factor (0.1–1.0).
|
||||
-- Zero-venue countries get max 5 pts supply deficit (was 50).
|
||||
-- - Steeper addressable market curve: LN/500K → SQRT/1M.
|
||||
-- - NULL distance gap → 0.0 (was 0.5). Unknown = assume nearby.
|
||||
-- - Added country_percentile output column (PERCENT_RANK within country).
|
||||
--
|
||||
-- 20 pts addressable market — log-scaled catchment population, ceiling 500K
|
||||
-- 15 pts economic power — income PPS, normalised to 35,000
|
||||
-- 40 pts supply deficit — max(density gap, distance gap) × country dampener
|
||||
-- 15 pts addressable market — sqrt-scaled catchment population, ceiling 1M
|
||||
-- 10 pts economic power — income PPS, normalised to 35,000
|
||||
-- 50 pts supply deficit — max(density gap, distance gap) × existence dampener
|
||||
-- 10 pts sports culture — tennis court density as racquet-sport adoption proxy
|
||||
-- 5 pts construction affordability — income relative to construction costs (PLI)
|
||||
-- 10 pts market headroom — inverse country-level avg market maturity
|
||||
@@ -215,10 +218,10 @@ country_market AS (
|
||||
country_supply AS (
|
||||
SELECT
|
||||
country_code,
|
||||
SUM(city_padel_venue_count) AS country_venues,
|
||||
SUM(padel_venue_count) AS country_venues,
|
||||
SUM(population) AS country_pop,
|
||||
CASE WHEN SUM(population) > 0
|
||||
THEN SUM(city_padel_venue_count) * 100000.0 / SUM(population)
|
||||
THEN SUM(padel_venue_count) * 100000.0 / SUM(population)
|
||||
ELSE 0
|
||||
END AS venues_per_100k
|
||||
FROM foundation.dim_cities
|
||||
@@ -228,28 +231,29 @@ country_supply AS (
|
||||
-- Step 4: add opportunity_score using country market validation + supply saturation.
|
||||
scored AS (
|
||||
SELECT ms.*,
|
||||
-- ── Opportunity Score (Marktpotenzial-Score v7, H3 catchment) ──────────
|
||||
-- ── Opportunity Score (Marktpotenzial-Score v8, H3 catchment) ──────────
|
||||
ROUND(
|
||||
-- Addressable market (20 pts): log-scaled catchment population, ceiling 500K
|
||||
20.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
|
||||
-- Economic power (15 pts): income PPS normalised to 35,000
|
||||
+ 15.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
||||
-- Supply deficit (40 pts): max of density gap and distance gap.
|
||||
-- Dampened by country-level supply saturation:
|
||||
-- Spain (7.4/100k) → dampener 0.30 → 12 pts max
|
||||
-- Germany (0.24/100k) → dampener 0.98 → ~39 pts max
|
||||
+ 40.0 * GREATEST(
|
||||
-- Addressable market (15 pts): sqrt-scaled catchment population, ceiling 1M
|
||||
15.0 * LEAST(1.0, SQRT(GREATEST(catchment_population, 1) / 1000000.0))
|
||||
-- Economic power (10 pts): income PPS normalised to 35,000
|
||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
||||
-- Supply deficit (50 pts): max of density gap and distance gap.
|
||||
-- Dampened by market existence: country_venues/50 (0.1–1.0).
|
||||
-- 0 venues in country → factor 0.1 → max 5 pts supply deficit
|
||||
-- 10 venues → 0.2 → max 10 pts
|
||||
-- 50+ venues → 1.0 → full credit
|
||||
+ 50.0 * GREATEST(
|
||||
-- density-based gap (H3 catchment): 0 courts = 1.0, 5/100k = 0.0
|
||||
GREATEST(0.0, 1.0 - COALESCE(
|
||||
CASE WHEN catchment_population > 0
|
||||
THEN GREATEST(catchment_padel_courts, COALESCE(city_padel_venue_count, 0))::DOUBLE / catchment_population * 100000
|
||||
ELSE 0.0
|
||||
END, 0.0) / 5.0),
|
||||
-- distance-based gap: 30km+ = 1.0, 0km = 0.0; NULL = 0.5
|
||||
COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
|
||||
-- distance-based gap: 30km+ = 1.0, 0km = 0.0; NULL = 0.0 (assume nearby)
|
||||
COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.0)
|
||||
)
|
||||
-- Country supply dampener: floor 0.3 so deficit never fully vanishes
|
||||
* GREATEST(0.3, 1.0 - COALESCE(cs.venues_per_100k, 0.0) / 10.0)
|
||||
-- Market existence dampener: zero-venue countries get 0.1, 50+ venues = 1.0
|
||||
* GREATEST(0.1, LEAST(1.0, COALESCE(cs.country_venues, 0) / 50.0))
|
||||
-- Sports culture (10 pts): tennis density as racquet-sport adoption proxy.
|
||||
-- Ceiling 50 courts within 25km. Harmless when tennis data is zero (contributes 0).
|
||||
+ 10.0 * LEAST(1.0, COALESCE(tennis_courts_within_25km, 0) / 50.0)
|
||||
@@ -301,6 +305,9 @@ SELECT
|
||||
END AS catchment_venues_per_100k,
|
||||
LEAST(GREATEST(s.market_score, 0), 100) AS market_score,
|
||||
LEAST(GREATEST(s.opportunity_score, 0), 100) AS opportunity_score,
|
||||
ROUND(PERCENT_RANK() OVER (
|
||||
PARTITION BY s.country_code ORDER BY s.opportunity_score
|
||||
) * 100, 0) AS country_percentile,
|
||||
s.median_hourly_rate,
|
||||
s.median_peak_rate,
|
||||
s.median_offpeak_rate,
|
||||
|
||||
@@ -34,7 +34,7 @@ all_jsonl AS (
|
||||
tenant_id,
|
||||
slots AS slots_json
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
@@ -46,6 +46,7 @@ all_jsonl AS (
|
||||
filename = true
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
AND CAST(date AS DATE) BETWEEN @start_ds AND @end_ds
|
||||
),
|
||||
raw_resources AS (
|
||||
SELECT
|
||||
|
||||
@@ -1105,6 +1105,10 @@ async def supplier_new():
|
||||
category, tier, contact_name, contact_email, contact_role,
|
||||
services_offered, linkedin_url, instagram_url, youtube_url, now),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
await notify_indexnow([f"/directory/{slug}"])
|
||||
|
||||
await flash(f"Supplier '{name}' created.", "success")
|
||||
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||
|
||||
@@ -2264,10 +2268,9 @@ async def _sync_static_articles() -> None:
|
||||
(slug, title, url_path, language, meta_description,
|
||||
status, template_slug, group_key, article_type, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
ON CONFLICT(url_path, language) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
url_path = excluded.url_path,
|
||||
language = excluded.language,
|
||||
slug = excluded.slug,
|
||||
meta_description = excluded.meta_description,
|
||||
template_slug = excluded.template_slug,
|
||||
group_key = excluded.group_key,
|
||||
@@ -2625,20 +2628,28 @@ async def articles_bulk():
|
||||
)
|
||||
|
||||
if action == "publish":
|
||||
affected = await fetch_all(
|
||||
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
|
||||
)
|
||||
await execute(
|
||||
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
|
||||
(now, *where_params),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
await notify_indexnow([r["url_path"] for r in affected])
|
||||
|
||||
elif action == "unpublish":
|
||||
affected = await fetch_all(
|
||||
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
|
||||
)
|
||||
await execute(
|
||||
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
|
||||
(now, *where_params),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
await notify_indexnow([r["url_path"] for r in affected])
|
||||
|
||||
elif action == "toggle_noindex":
|
||||
await execute(
|
||||
@@ -2685,16 +2696,26 @@ async def articles_bulk():
|
||||
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
|
||||
(now, *article_ids),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
affected = await fetch_all(
|
||||
f"SELECT DISTINCT url_path FROM articles WHERE id IN ({placeholders})",
|
||||
tuple(article_ids),
|
||||
)
|
||||
await notify_indexnow([r["url_path"] for r in affected])
|
||||
|
||||
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
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
affected = await fetch_all(
|
||||
f"SELECT DISTINCT url_path FROM articles WHERE id IN ({placeholders})",
|
||||
tuple(article_ids),
|
||||
)
|
||||
await notify_indexnow([r["url_path"] for r in affected])
|
||||
|
||||
elif action == "toggle_noindex":
|
||||
await execute(
|
||||
@@ -2808,8 +2829,10 @@ async def article_new():
|
||||
(url_path, article_slug, title, meta_description, og_image_url,
|
||||
country, region, language, status, pub_dt, seo_head, article_type),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
if status == "published":
|
||||
await notify_indexnow([url_path])
|
||||
|
||||
await flash(f"Article '{title}' created.", "success")
|
||||
return redirect(url_for("admin.articles"))
|
||||
@@ -2881,6 +2904,9 @@ async def article_edit(article_id: int):
|
||||
(title, url_path, meta_description, og_image_url,
|
||||
country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
|
||||
)
|
||||
if status == "published":
|
||||
from ..sitemap import notify_indexnow
|
||||
await notify_indexnow([url_path])
|
||||
await flash("Article updated.", "success")
|
||||
return redirect(url_for("admin.articles"))
|
||||
|
||||
@@ -2976,8 +3002,11 @@ async def article_publish(article_id: int):
|
||||
(new_status, now, article_id),
|
||||
)
|
||||
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||
invalidate_sitemap_cache()
|
||||
toggled = await fetch_one("SELECT url_path FROM articles WHERE id = ?", (article_id,))
|
||||
if toggled:
|
||||
await notify_indexnow([toggled["url_path"]])
|
||||
|
||||
if request.headers.get("HX-Request"):
|
||||
updated = await fetch_one(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% set pct = [((job.progress_current / job.progress_total) * 100) | int, 100] | min if job.progress_total else 0 %}
|
||||
|
||||
<tr id="job-{{ job.id }}"
|
||||
{% if job.status == 'pending' %}
|
||||
{% if job.status == 'pending' and not job.error %}
|
||||
hx-get="{{ url_for('pseo.pseo_job_status', job_id=job.id) }}"
|
||||
hx-trigger="every 2s"
|
||||
hx-target="this"
|
||||
|
||||
@@ -284,6 +284,12 @@ def create_app() -> Quart:
|
||||
from .sitemap import sitemap_response
|
||||
return await sitemap_response(config.BASE_URL)
|
||||
|
||||
# IndexNow key verification — only register if key is configured
|
||||
if config.INDEXNOW_KEY:
|
||||
@app.route(f"/{config.INDEXNOW_KEY}.txt")
|
||||
async def indexnow_key():
|
||||
return Response(config.INDEXNOW_KEY, content_type="text/plain")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Error pages
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -401,7 +407,7 @@ def create_app() -> Quart:
|
||||
|
||||
@app.route("/market-score")
|
||||
async def legacy_market_score():
|
||||
return redirect("/en/market-score", 301)
|
||||
return redirect("/en/padelnomics-score", 301)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Blueprint registration
|
||||
|
||||
@@ -18,6 +18,7 @@ from jinja2 import ChainableUndefined, Environment
|
||||
|
||||
from ..analytics import fetch_analytics
|
||||
from ..core import REPO_ROOT, slugify, transaction, utcnow_iso
|
||||
from ..i18n import get_country_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -364,6 +365,10 @@ async def generate_articles(
|
||||
# Jinja filters (round, int) don't crash.
|
||||
safe_ctx = {k: (v if v is not None else 0) for k, v in row.items()}
|
||||
safe_ctx["language"] = lang
|
||||
if "country_name_en" in safe_ctx:
|
||||
safe_ctx["country_name"] = get_country_name(
|
||||
safe_ctx["country_name_en"], lang
|
||||
)
|
||||
|
||||
# Render URL pattern (no lang prefix — blueprint provides /<lang>)
|
||||
url_path = url_tmpl.render(**safe_ctx)
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..core import (
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
from ..i18n import get_country_name, get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"content",
|
||||
@@ -208,10 +208,14 @@ async def markets():
|
||||
SELECT country_code, country_name_en, country_slug,
|
||||
city_count, total_venues,
|
||||
avg_market_score, avg_opportunity_score,
|
||||
top_opportunity_score,
|
||||
lat, lon
|
||||
FROM serving.pseo_country_overview
|
||||
ORDER BY total_venues DESC
|
||||
""")
|
||||
lang = g.get("lang", "en")
|
||||
for c in map_countries:
|
||||
c["country_name"] = get_country_name(c["country_code"], lang)
|
||||
# Sort so user's country renders last (on top in Leaflet z-order)
|
||||
user_country = g.get("user_country", "")
|
||||
if user_country and map_countries:
|
||||
|
||||
@@ -35,7 +35,7 @@ priority_column: population
|
||||
|
||||
<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 }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> von {{ opportunity_score | round(1) }}/100** — der Score bewertet Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität der Region. {% if opportunity_score >= 65 %}Damit zählt {{ city_name }} zu den vielversprechendsten Standorten in {{ country_name_en }}{% elif opportunity_score >= 40 %}Solides Potenzial — der Markt bietet noch Raum für neue Anlagen{% else %}Der Standort ist vergleichsweise gut versorgt; Differenzierung wird zum Schlüssel{% 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.
|
||||
{{ city_name }} erreicht einen **<a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> von {{ opportunity_score | round(1) }}/100** — der Score bewertet Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität der Region. {% if opportunity_score >= 65 %}Damit zählt {{ city_name }} zu den vielversprechendsten Standorten in {{ country_name }}{% elif opportunity_score >= 40 %}Solides Potenzial — der Markt bietet noch Raum für neue Anlagen{% else %}Der Standort ist vergleichsweise gut versorgt; Differenzierung wird zum Schlüssel{% 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.
|
||||
|
||||
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 }}.
|
||||
|
||||
@@ -45,13 +45,13 @@ Auf Basis aktueller Marktdaten für {{ city_name }} sieht die Investitionsrechnu
|
||||
|
||||
[scenario:{{ scenario_slug }}:capex]
|
||||
|
||||
Die Baukosten hängen stark davon ab, ob Du eine Indoor- oder Outdoor-Anlage planst. Indoor-Hallen in {{ country_name_en }} liegen typischerweise deutlich höher — Hallenbau, Belüftung und Beleuchtung treiben die Kosten. Outdoor-Anlagen sind günstiger im Bau, schränken aber die Saison und damit die Einnahmen ein. Die Courtanzahl bestimmt maßgeblich die Gesamtinvestition: Jeder zusätzliche Court senkt die Kosten pro Court, erhöht aber das Gesamtrisiko.
|
||||
Die Baukosten hängen stark davon ab, ob Du eine Indoor- oder Outdoor-Anlage planst. Indoor-Hallen in {{ country_name }} liegen typischerweise deutlich höher — Hallenbau, Belüftung und Beleuchtung treiben die Kosten. Outdoor-Anlagen sind günstiger im Bau, schränken aber die Saison und damit die Einnahmen ein. Die Courtanzahl bestimmt maßgeblich die Gesamtinvestition: Jeder zusätzliche Court senkt die Kosten pro Court, erhöht aber das Gesamtrisiko.
|
||||
|
||||
## Umsatzpotenzial in {{ city_name }}
|
||||
|
||||
[scenario:{{ scenario_slug }}:operating]
|
||||
|
||||
Der Umsatz steht und fällt mit drei Faktoren: Courtpreise, Auslastung und Nebeneinnahmen. {% if median_peak_rate %}In {{ city_name }} liegen die Spitzenpreise bei {{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std — {% if median_peak_rate >= 40 %}ein starkes Preisniveau, das auf hohe Nachfrage schließen lässt{% elif median_peak_rate >= 25 %}ein marktübliches Niveau für {{ country_name_en }}{% else %}ein vergleichsweise niedriges Preisniveau, das sich mit wachsender Nachfrage entwickeln dürfte{% endif %}. {% endif %}Die Auslastung variiert stark nach Tageszeit und Saison — in den Abendstunden und am Wochenende sind die Courts oft voll, während vormittags und nachmittags Kapazitäten frei bleiben.
|
||||
Der Umsatz steht und fällt mit drei Faktoren: Courtpreise, Auslastung und Nebeneinnahmen. {% if median_peak_rate %}In {{ city_name }} liegen die Spitzenpreise bei {{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std — {% if median_peak_rate >= 40 %}ein starkes Preisniveau, das auf hohe Nachfrage schließen lässt{% elif median_peak_rate >= 25 %}ein marktübliches Niveau für {{ country_name }}{% else %}ein vergleichsweise niedriges Preisniveau, das sich mit wachsender Nachfrage entwickeln dürfte{% endif %}. {% endif %}Die Auslastung variiert stark nach Tageszeit und Saison — in den Abendstunden und am Wochenende sind die Courts oft voll, während vormittags und nachmittags Kapazitäten frei bleiben.
|
||||
|
||||
## Rendite & Finanzierung
|
||||
|
||||
@@ -66,7 +66,7 @@ Die Renditekennzahlen zeigen, wie schnell sich Dein Investment amortisiert und w
|
||||
|
||||
## Marktumfeld in {{ city_name }}
|
||||
|
||||
{% if venues_per_100k >= 3.0 %}{{ city_name }} hat mit {{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner eine vergleichsweise hohe Padel-Dichte. Das spricht für einen etablierten Markt mit bewiesener Nachfrage — bedeutet aber auch mehr Wettbewerb für neue Anlagen. Differenzierung über Lage, Qualität oder Preisgestaltung wird hier zum Erfolgsfaktor.{% elif venues_per_100k >= 1.0 %}Mit {{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner hat {{ city_name }} eine moderate Padel-Abdeckung. Es gibt nachgewiesene Nachfrage, aber noch Platz für neue Anlagen — besonders in unterversorgten Stadtteilen oder mit einem differenzierten Angebot.{% else %}{{ city_name }} hat mit {{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner eine niedrige Padel-Dichte. Das bedeutet weniger Wettbewerb und First-Mover-Vorteile — aber auch weniger validierte Nachfragedaten. Die wachsende Popularität von Padel in {{ country_name_en }} spricht für ein hohes Entwicklungspotenzial.{% endif %}
|
||||
{% if venues_per_100k >= 3.0 %}{{ city_name }} hat mit {{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner eine vergleichsweise hohe Padel-Dichte. Das spricht für einen etablierten Markt mit bewiesener Nachfrage — bedeutet aber auch mehr Wettbewerb für neue Anlagen. Differenzierung über Lage, Qualität oder Preisgestaltung wird hier zum Erfolgsfaktor.{% elif venues_per_100k >= 1.0 %}Mit {{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner hat {{ city_name }} eine moderate Padel-Abdeckung. Es gibt nachgewiesene Nachfrage, aber noch Platz für neue Anlagen — besonders in unterversorgten Stadtteilen oder mit einem differenzierten Angebot.{% else %}{{ city_name }} hat mit {{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner eine niedrige Padel-Dichte. Das bedeutet weniger Wettbewerb und First-Mover-Vorteile — aber auch weniger validierte Nachfragedaten. Die wachsende Popularität von Padel in {{ country_name }} spricht für ein hohes Entwicklungspotenzial.{% endif %}
|
||||
|
||||
Padel wächst europaweit rasant — in vielen Märkten verdoppelt sich die Anlagenzahl innerhalb weniger Jahre. Die vergleichsweise niedrigen Einstiegshürden (weniger Fläche als Tennis, kürzere Lernkurve für Spieler) treiben das Wachstum.
|
||||
|
||||
@@ -109,7 +109,7 @@ Die Rendite hängt von Deinen Baukosten, der Courtanzahl, Preisgestaltung und Au
|
||||
<details>
|
||||
<summary>Was kostet es, eine Padelhalle in {{ city_name }} zu bauen?</summary>
|
||||
|
||||
Das Gesamtinvestment hängt vom Hallentyp (Indoor vs. Outdoor), Grundstückskosten und lokalen Baustandards in {{ country_name_en }} ab. Das CAPEX-Modell oben schlüsselt die wichtigsten Kostentreiber für einen typischen Bau in {{ city_name }} auf.
|
||||
Das Gesamtinvestment hängt vom Hallentyp (Indoor vs. Outdoor), Grundstückskosten und lokalen Baustandards in {{ country_name }} ab. Das CAPEX-Modell oben schlüsselt die wichtigsten Kostentreiber für einen typischen Bau in {{ city_name }} auf.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -121,13 +121,13 @@ Das Gesamtinvestment hängt vom Hallentyp (Indoor vs. Outdoor), Grundstückskost
|
||||
<details>
|
||||
<summary>Was kosten Padel-Courts in {{ city_name }}?</summary>
|
||||
|
||||
{% if median_peak_rate %}Zu Hauptzeiten liegen die Preise bei durchschnittlich **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std**, in Nebenzeiten bei ca. **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**. Die Daten stammen aus Live-Buchungsdaten von Playtomic.{% else %}Für {{ city_name }} liegen noch keine Playtomic-Preisdaten vor. Das Finanzmodell nutzt Benchmarks aus {{ country_name_en }} als Näherung.{% endif %}
|
||||
{% if median_peak_rate %}Zu Hauptzeiten liegen die Preise bei durchschnittlich **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std**, in Nebenzeiten bei ca. **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**. Die Daten stammen aus Live-Buchungsdaten von Playtomic.{% else %}Für {{ city_name }} liegen noch keine Playtomic-Preisdaten vor. Das Finanzmodell nutzt Benchmarks aus {{ country_name }} als Näherung.{% endif %}
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
|
||||
<summary>Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name }} ab?</summary>
|
||||
|
||||
Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 zeigt {{ city_name }}s Investitionspotenzial im Vergleich zu anderen Städten in {{ country_name_en }}. In der [Marktübersicht für {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
|
||||
Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 zeigt {{ city_name }}s Investitionspotenzial im Vergleich zu anderen Städten in {{ country_name }}. In der [Marktübersicht für {{ country_name }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
|
||||
</details>
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||
@@ -137,7 +137,7 @@ Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;co
|
||||
|
||||
---
|
||||
|
||||
*Weitere Padel-Märkte in {{ country_name_en }}: [{{ country_name_en }} Übersicht](/{{ language }}/markets/{{ country_slug }})*
|
||||
*Weitere Padel-Märkte in {{ country_name }}: [{{ country_name }} Übersicht](/{{ language }}/markets/{{ country_slug }})*
|
||||
{% else %}
|
||||
# Is {{ city_name }} Worth Building a Padel Center In?
|
||||
|
||||
@@ -162,7 +162,7 @@ Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;co
|
||||
|
||||
<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 }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> of {{ opportunity_score | round(1) }}/100** — the score evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture. {% if opportunity_score >= 65 %}This places {{ city_name }} among the most promising locations in {{ country_name_en }}{% elif opportunity_score >= 40 %}Solid potential — the market still has room for new facilities{% else %}The area is comparatively well-served; differentiation becomes the key lever{% 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.
|
||||
{{ city_name }} has a **<a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> of {{ opportunity_score | round(1) }}/100** — the score evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture. {% if opportunity_score >= 65 %}This places {{ city_name }} among the most promising locations in {{ country_name }}{% elif opportunity_score >= 40 %}Solid potential — the market still has room for new facilities{% else %}The area is comparatively well-served; differentiation becomes the key lever{% 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -172,13 +172,13 @@ Based on current market data for {{ city_name }}, here is what a padel center in
|
||||
|
||||
[scenario:{{ scenario_slug }}:capex]
|
||||
|
||||
Construction costs vary significantly depending on whether you build an indoor or outdoor facility. Indoor halls in {{ country_name_en }} run considerably higher — structural build, ventilation, and lighting drive the cost. Outdoor courts are cheaper to construct but limit your operating season and revenue potential. The number of courts is the single biggest lever on total investment: each additional court lowers the per-court cost, but increases your total capital at risk.
|
||||
Construction costs vary significantly depending on whether you build an indoor or outdoor facility. Indoor halls in {{ country_name }} run considerably higher — structural build, ventilation, and lighting drive the cost. Outdoor courts are cheaper to construct but limit your operating season and revenue potential. The number of courts is the single biggest lever on total investment: each additional court lowers the per-court cost, but increases your total capital at risk.
|
||||
|
||||
## Revenue Potential in {{ city_name }}
|
||||
|
||||
[scenario:{{ scenario_slug }}:operating]
|
||||
|
||||
Revenue depends on three factors: court rental pricing, occupancy rates, and ancillary income (coaching, retail, food & beverage). {% if median_peak_rate %}In {{ city_name }}, peak rates sit at {{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr — {% if median_peak_rate >= 40 %}a strong price point that signals high local demand{% elif median_peak_rate >= 25 %}a standard rate for the {{ country_name_en }} market{% else %}a relatively low price point that may rise as local demand grows{% endif %}. {% endif %}Utilisation swings sharply by time of day and season — evenings and weekends tend to run at or near capacity, while weekday mornings and afternoons have idle courts.
|
||||
Revenue depends on three factors: court rental pricing, occupancy rates, and ancillary income (coaching, retail, food & beverage). {% if median_peak_rate %}In {{ city_name }}, peak rates sit at {{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr — {% if median_peak_rate >= 40 %}a strong price point that signals high local demand{% elif median_peak_rate >= 25 %}a standard rate for the {{ country_name }} market{% else %}a relatively low price point that may rise as local demand grows{% endif %}. {% endif %}Utilisation swings sharply by time of day and season — evenings and weekends tend to run at or near capacity, while weekday mornings and afternoons have idle courts.
|
||||
|
||||
## Financial Returns
|
||||
|
||||
@@ -193,7 +193,7 @@ The return metrics above show how quickly your investment pays back and what lon
|
||||
|
||||
## Market Context
|
||||
|
||||
{% if venues_per_100k >= 3.0 %}With {{ venues_per_100k | round(1) }} venues per 100K residents, {{ city_name }} has a relatively high padel density. This indicates a proven market with demonstrated demand — but also more competition for new entrants. Differentiation through location, facility quality, or pricing strategy becomes the key success factor.{% elif venues_per_100k >= 1.0 %}At {{ venues_per_100k | round(1) }} venues per 100K residents, {{ city_name }} has moderate padel coverage. There is proven demand, but room for new facilities — particularly in underserved areas of the city or with a differentiated offering.{% else %}{{ city_name }} has a low padel density of {{ venues_per_100k | round(1) }} venues per 100K residents. This means less competition and potential first-mover advantage — but also less validated demand data. The rapid growth of padel across {{ country_name_en }} suggests significant development potential.{% endif %}
|
||||
{% if venues_per_100k >= 3.0 %}With {{ venues_per_100k | round(1) }} venues per 100K residents, {{ city_name }} has a relatively high padel density. This indicates a proven market with demonstrated demand — but also more competition for new entrants. Differentiation through location, facility quality, or pricing strategy becomes the key success factor.{% elif venues_per_100k >= 1.0 %}At {{ venues_per_100k | round(1) }} venues per 100K residents, {{ city_name }} has moderate padel coverage. There is proven demand, but room for new facilities — particularly in underserved areas of the city or with a differentiated offering.{% else %}{{ city_name }} has a low padel density of {{ venues_per_100k | round(1) }} venues per 100K residents. This means less competition and potential first-mover advantage — but also less validated demand data. The rapid growth of padel across {{ country_name }} suggests significant development potential.{% endif %}
|
||||
|
||||
Padel is growing rapidly across Europe — many markets are seeing venue counts double within a few years. The sport's relatively low barriers to entry (less space than tennis, faster learning curve for players) continue to drive expansion.
|
||||
|
||||
@@ -236,7 +236,7 @@ ROI depends on your build cost, court count, pricing, and occupancy assumptions.
|
||||
<details>
|
||||
<summary>How much does it cost to build a padel center in {{ city_name }}?</summary>
|
||||
|
||||
Total investment depends on venue type (indoor vs outdoor), land costs, and local construction standards in {{ country_name_en }}. The capex model above breaks down the key cost drivers for a typical {{ city_name }} build based on current market assumptions.
|
||||
Total investment depends on venue type (indoor vs outdoor), land costs, and local construction standards in {{ country_name }}. The capex model above breaks down the key cost drivers for a typical {{ city_name }} build based on current market assumptions.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -248,13 +248,13 @@ Total investment depends on venue type (indoor vs outdoor), land costs, and loca
|
||||
<details>
|
||||
<summary>What are typical padel court rental prices in {{ city_name }}?</summary>
|
||||
|
||||
{% if median_peak_rate %}Peak hour rates average around **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr**, while off-peak rates are approximately **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr**. These figures come from live Playtomic booking data.{% else %}Pricing data from Playtomic is not yet available for {{ city_name }}. The financial model uses {{ country_name_en }}-wide benchmarks as a proxy.{% endif %}
|
||||
{% if median_peak_rate %}Peak hour rates average around **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr**, while off-peak rates are approximately **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr**. These figures come from live Playtomic booking data.{% else %}Pricing data from Playtomic is not yet available for {{ city_name }}. The financial model uses {{ country_name }}-wide benchmarks as a proxy.{% endif %}
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How does {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
|
||||
<summary>How does {{ city_name }} compare to other {{ country_name }} cities?</summary>
|
||||
|
||||
{{ city_name }}'s <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 reflects its investment potential among tracked {{ country_name_en }} cities. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full comparison across cities.
|
||||
{{ city_name }}'s <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 reflects its investment potential among tracked {{ country_name }} cities. See the [{{ country_name }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full comparison across cities.
|
||||
</details>
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||
@@ -264,5 +264,5 @@ Total investment depends on venue type (indoor vs outdoor), land costs, and loca
|
||||
|
||||
---
|
||||
|
||||
*More {{ country_name_en }} padel markets: [{{ country_name_en }} overview](/{{ language }}/markets/{{ country_slug }})*
|
||||
*More {{ country_name }} padel markets: [{{ country_name }} overview](/{{ language }}/markets/{{ country_slug }})*
|
||||
{% endif %}
|
||||
|
||||
@@ -55,11 +55,11 @@ Die Preisspanne von {{ hourly_rate_p25 | round(0) | int }} bis {{ hourly_rate_p7
|
||||
|
||||
## Wie steht {{ city_name }} im Vergleich da?
|
||||
|
||||
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if opportunity_score >= 65 %}Mit einem <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> von {{ opportunity_score | round(1) }}/100 zählt {{ city_name }} zu den vielversprechendsten Standorten in {{ country_name_en }}. {% elif opportunity_score >= 40 %}Ein <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 steht für solides Investitionspotenzial: genug Markt für faire Preise, aber Raum für neue Anlagen. {% else %}Ein <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 spricht für einen bereits gut versorgten Markt — Differenzierung über Qualität und Lage wird entscheidend. {% endif %}
|
||||
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if opportunity_score >= 65 %}Mit einem <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> von {{ opportunity_score | round(1) }}/100 zählt {{ city_name }} zu den vielversprechendsten Standorten in {{ country_name }}. {% elif opportunity_score >= 40 %}Ein <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 steht für solides Investitionspotenzial: genug Markt für faire Preise, aber Raum für neue Anlagen. {% else %}Ein <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 spricht für einen bereits gut versorgten Markt — Differenzierung über Qualität und Lage wird entscheidend. {% endif %}
|
||||
|
||||
Die Anlagendichte von {{ venues_per_100k | round(1) }} pro 100K Einwohner beeinflusst die Preisgestaltung direkt: {% if venues_per_100k >= 3.0 %}Höhere Dichte bedeutet mehr Wettbewerb, was die Preise eher stabilisiert oder senkt.{% elif venues_per_100k >= 1.0 %}Moderate Dichte ermöglicht marktgerechte Preise bei gleichzeitigem Wachstumsspielraum.{% else %}Niedrige Dichte gibt Betreibern mehr Preissetzungsmacht — vorausgesetzt, die Nachfrage ist da.{% endif %}
|
||||
|
||||
In der [Marktübersicht {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) kannst Du {{ city_name }} mit anderen Städten vergleichen. Du planst ein Investment? Die vollständige [Investitionsanalyse für {{ city_name }}](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) enthält ein detailliertes Finanzmodell.
|
||||
In der [Marktübersicht {{ country_name }}](/{{ language }}/markets/{{ country_slug }}) kannst Du {{ city_name }} mit anderen Städten vergleichen. Du planst ein Investment? Die vollständige [Investitionsanalyse für {{ city_name }}](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) enthält ein detailliertes Finanzmodell.
|
||||
|
||||
## Was bestimmt die Padel-Preise?
|
||||
|
||||
@@ -105,9 +105,9 @@ In Nebenzeiten — typischerweise vormittags und am frühen Nachmittag unter der
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie schneidet {{ city_name }} preislich im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
|
||||
<summary>Wie schneidet {{ city_name }} preislich im Vergleich zu anderen Städten in {{ country_name }} ab?</summary>
|
||||
|
||||
Die Preise in {{ city_name }} liegen {% if median_peak_rate >= 40 %}im oberen Bereich{% elif median_peak_rate >= 25 %}im Mittelfeld{% else %}unter dem Durchschnitt{% endif %} für {{ country_name_en }}. In der [Marktübersicht {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
|
||||
Die Preise in {{ city_name }} liegen {% if median_peak_rate >= 40 %}im oberen Bereich{% elif median_peak_rate >= 25 %}im Mittelfeld{% else %}unter dem Durchschnitt{% endif %} für {{ country_name }}. In der [Marktübersicht {{ country_name }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -123,7 +123,7 @@ Die aktuellen Daten sind eine Momentaufnahme auf Basis von Playtomic-Livedaten.
|
||||
|
||||
---
|
||||
|
||||
*Siehe auch: [{{ city_name }} Investitionsanalyse](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) · [Marktübersicht {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }})*
|
||||
*Siehe auch: [{{ city_name }} Investitionsanalyse](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) · [Marktübersicht {{ country_name }}](/{{ language }}/markets/{{ country_slug }})*
|
||||
{% else %}
|
||||
# Padel Court Prices in {{ city_name }}
|
||||
|
||||
@@ -168,11 +168,11 @@ The P25–P75 price range of {{ hourly_rate_p25 | round(0) | int }} to {{ hourly
|
||||
|
||||
## How Does {{ city_name }} Compare?
|
||||
|
||||
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if opportunity_score >= 65 %}With a <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> of {{ opportunity_score | round(1) }}/100, {{ city_name }} is among the most promising investment locations in {{ country_name_en }}. {% elif opportunity_score >= 40 %}A <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 reflects solid investment potential: enough market for competitive pricing, but room for new venues. {% else %}A <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 indicates a well-served market — differentiation through quality and location becomes critical. {% endif %}
|
||||
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if opportunity_score >= 65 %}With a <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> of {{ opportunity_score | round(1) }}/100, {{ city_name }} is among the most promising investment locations in {{ country_name }}. {% elif opportunity_score >= 40 %}A <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 reflects solid investment potential: enough market for competitive pricing, but room for new venues. {% else %}A <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 indicates a well-served market — differentiation through quality and location becomes critical. {% endif %}
|
||||
|
||||
Venue density of {{ venues_per_100k | round(1) }} per 100K residents directly influences pricing: {% if venues_per_100k >= 3.0 %}higher density means more competition, which tends to stabilize or compress prices.{% elif venues_per_100k >= 1.0 %}moderate density supports market-rate pricing with room for growth.{% else %}low density gives operators more pricing power — provided demand exists.{% endif %}
|
||||
|
||||
See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) to compare {{ city_name }} against other cities. Thinking about investing? The full [{{ city_name }} investment analysis](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) includes a detailed financial model.
|
||||
See the [{{ country_name }} market overview](/{{ language }}/markets/{{ country_slug }}) to compare {{ city_name }} against other cities. Thinking about investing? The full [{{ city_name }} investment analysis](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) includes a detailed financial model.
|
||||
|
||||
## What Drives Padel Pricing?
|
||||
|
||||
@@ -218,9 +218,9 @@ Off-peak slots — typically weekday mornings and early afternoons — are price
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How does padel pricing in {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
|
||||
<summary>How does padel pricing in {{ city_name }} compare to other {{ country_name }} cities?</summary>
|
||||
|
||||
{{ city_name }}'s pricing sits {% if median_peak_rate >= 40 %}at the higher end{% elif median_peak_rate >= 25 %}in the mid-range{% else %}below average{% endif %} for {{ country_name_en }}. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full city-by-city comparison.
|
||||
{{ city_name }}'s pricing sits {% if median_peak_rate >= 40 %}at the higher end{% elif median_peak_rate >= 25 %}in the mid-range{% else %}below average{% endif %} for {{ country_name }}. See the [{{ country_name }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full city-by-city comparison.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -236,5 +236,5 @@ The current data is a snapshot based on live Playtomic booking data. In general,
|
||||
|
||||
---
|
||||
|
||||
*See also: [{{ city_name }} investment analysis](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) · [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }})*
|
||||
*See also: [{{ city_name }} investment analysis](/{{ language }}/markets/{{ country_slug }}/{{ city_slug }}) · [{{ country_name }} market overview](/{{ language }}/markets/{{ country_slug }})*
|
||||
{% endif %}
|
||||
|
||||
@@ -6,14 +6,14 @@ data_table: serving.pseo_country_overview
|
||||
natural_key: country_slug
|
||||
languages: [en, de]
|
||||
url_pattern: "/markets/{{ country_slug }}"
|
||||
title_pattern: "{% if language == 'de' %}Padel in {{ country_name_en }} — Marktüberblick {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ country_name_en }} — Market Overview {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
||||
meta_description_pattern: "{% if language == 'de' %}{{ total_venues }} Padelanlagen in {{ city_count }} Städten in {{ country_name_en }}. Padelnomics Score, Preisdaten und Investmentanalysen für jede Stadt.{% else %}{{ total_venues }} padel venues across {{ city_count }} cities in {{ country_name_en }}. Padelnomics Score, pricing data, and investment analysis for each city.{% endif %}"
|
||||
title_pattern: "{% if language == 'de' %}Padel in {{ country_name }} — Marktüberblick {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ country_name }} — Market Overview {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
||||
meta_description_pattern: "{% if language == 'de' %}{{ total_venues }} Padelanlagen in {{ city_count }} Städten in {{ country_name }}. Padelnomics Score, Preisdaten und Investmentanalysen für jede Stadt.{% else %}{{ total_venues }} padel venues across {{ city_count }} cities in {{ country_name }}. Padelnomics Score, pricing data, and investment analysis for each city.{% endif %}"
|
||||
schema_type: [Article, FAQPage]
|
||||
priority_column: total_venues
|
||||
---
|
||||
|
||||
{% if language == "de" %}
|
||||
# Padel in {{ country_name_en }} — Marktüberblick
|
||||
# Padel in {{ country_name }} — Marktüberblick
|
||||
|
||||
<div class="stats-strip">
|
||||
<div class="stats-strip__item">
|
||||
@@ -36,15 +36,15 @@ priority_column: total_venues
|
||||
|
||||
<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 }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> liegt bei **{{ avg_opportunity_score }}/100** — {% if avg_opportunity_score >= 65 %}hohes Investitionspotenzial mit relevanten Versorgungslücken{% elif avg_opportunity_score >= 40 %}solides Potenzial, der Markt bietet noch Raum für neue Standorte{% else %}ein bereits gut versorgter Markt, der sorgfältige Standortwahl erfordert{% endif %}.
|
||||
In {{ country_name }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> liegt bei **{{ avg_opportunity_score }}/100** — {% if avg_opportunity_score >= 65 %}hohes Investitionspotenzial mit relevanten Versorgungslücken{% elif avg_opportunity_score >= 40 %}solides Potenzial, der Markt bietet noch Raum für neue Standorte{% else %}ein bereits gut versorgter Markt, der sorgfältige Standortwahl erfordert{% endif %}.
|
||||
|
||||
## Marktlandschaft
|
||||
|
||||
Padel wächst in {{ country_name_en }} mit bemerkenswertem Tempo. Unsere Daten zeigen {{ total_venues }} erfasste Anlagen — eine Zahl, die angesichts nicht auf Buchungsplattformen gelisteter Clubs vermutlich noch höher liegt. Der durchschnittliche <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ avg_opportunity_score }}/100 über {{ city_count }} Städte bewertet das Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität.
|
||||
Padel wächst in {{ country_name }} mit bemerkenswertem Tempo. Unsere Daten zeigen {{ total_venues }} erfasste Anlagen — eine Zahl, die angesichts nicht auf Buchungsplattformen gelisteter Clubs vermutlich noch höher liegt. Der durchschnittliche <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ avg_opportunity_score }}/100 über {{ city_count }} Städte bewertet das Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität.
|
||||
|
||||
{% if avg_opportunity_score >= 65 %}Ein Durchschnittsscore über 65 signalisiert relevante Versorgungslücken bei gleichzeitig vorhandener Nachfrage. Selbst in Regionen mit etablierter Padel-Infrastruktur variiert die Anlagendichte pro 100.000 Einwohner erheblich — lokale Analyse lohnt sich.{% elif avg_opportunity_score >= 40 %}Ein Score im mittleren Bereich deutet auf eine Wachstumsphase hin: Die Nachfrage ist nachweisbar, die Anlageninfrastruktur baut sich auf, und gut positionierte Standorte bieten noch Chancen für Neueintritte.{% else %}Viele Standorte in {{ country_name_en }} sind bereits gut versorgt. Neue Projekte brauchen eine sorgfältige Standortanalyse und ein klares Differenzierungsprofil.{% endif %}
|
||||
{% if avg_opportunity_score >= 65 %}Ein Durchschnittsscore über 65 signalisiert relevante Versorgungslücken bei gleichzeitig vorhandener Nachfrage. Selbst in Regionen mit etablierter Padel-Infrastruktur variiert die Anlagendichte pro 100.000 Einwohner erheblich — lokale Analyse lohnt sich.{% elif avg_opportunity_score >= 40 %}Ein Score im mittleren Bereich deutet auf eine Wachstumsphase hin: Die Nachfrage ist nachweisbar, die Anlageninfrastruktur baut sich auf, und gut positionierte Standorte bieten noch Chancen für Neueintritte.{% else %}Viele Standorte in {{ country_name }} sind bereits gut versorgt. Neue Projekte brauchen eine sorgfältige Standortanalyse und ein klares Differenzierungsprofil.{% endif %}
|
||||
|
||||
## Top-Städte in {{ country_name_en }}
|
||||
## Top-Städte in {{ country_name }}
|
||||
|
||||
Die Rangfolge basiert auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score — einer Bewertung des Investitionspotenzials anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität. Städte mit höherem Score bieten in der Regel die besten Standortbedingungen für neue Anlagen.
|
||||
|
||||
@@ -58,7 +58,7 @@ Jede Stadtseite enthält detaillierte Preisdaten, eine Kosten-Nutzen-Analyse und
|
||||
## Preisüberblick
|
||||
|
||||
{% if median_peak_rate %}
|
||||
Die Mietpreise für Padel-Courts in {{ country_name_en }} basieren auf Live-Daten von Playtomic:
|
||||
Die Mietpreise für Padel-Courts in {{ country_name }} basieren auf Live-Daten von Playtomic:
|
||||
|
||||
| Zeitfenster | Median |
|
||||
|------------|--------|
|
||||
@@ -70,57 +70,57 @@ Die Mietpreise für Padel-Courts in {{ country_name_en }} basieren auf Live-Date
|
||||
|
||||
Die Preise variieren erheblich zwischen Städten — Großstädte mit hoher Nachfrage erzielen Premiumpreise. Auf den einzelnen Stadtseiten findest Du stadtspezifische Benchmarks.
|
||||
{% else %}
|
||||
Detaillierte Preisdaten von Playtomic liegen für {{ country_name_en }} noch nicht aggregiert vor. Auf den Stadtseiten findest Du lokale Daten, sofern verfügbar.
|
||||
Detaillierte Preisdaten von Playtomic liegen für {{ country_name }} noch nicht aggregiert vor. Auf den Stadtseiten findest Du lokale Daten, sofern verfügbar.
|
||||
{% endif %}
|
||||
|
||||
## Businessplan für {{ country_name_en }} erstellen
|
||||
## Businessplan für {{ country_name }} erstellen
|
||||
|
||||
Jede Stadt hat andere Kostenstrukturen, Wettbewerbsbedingungen und Zielgruppen. Der Padelnomics-Finanzplaner modelliert eine Padelhalle mit realen lokalen Marktdaten als Ausgangswerte — jede Annahme lässt sich an Dein konkretes Vorhaben anpassen.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||
Businessplan für eine Padelhalle in {{ country_name_en }} erstellen →
|
||||
Businessplan für eine Padelhalle in {{ country_name }} erstellen →
|
||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Zum Finanzplaner</a>
|
||||
</div>
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary>Wie viele Padel-Courts gibt es in {{ country_name_en }}?</summary>
|
||||
<summary>Wie viele Padel-Courts gibt es in {{ country_name }}?</summary>
|
||||
|
||||
Wir erfassen aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten** in {{ country_name_en }}. Unsere Daten stammen von Playtomic und aus Overpass/OpenStreetMap. Die tatsächliche Zahl liegt vermutlich höher, da unabhängige Clubs ohne Buchungsplattform nicht immer erfasst werden.
|
||||
Wir erfassen aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten** in {{ country_name }}. Unsere Daten stammen von Playtomic und aus Overpass/OpenStreetMap. Die tatsächliche Zahl liegt vermutlich höher, da unabhängige Clubs ohne Buchungsplattform nicht immer erfasst werden.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?</summary>
|
||||
<summary>Welche Stadt in {{ country_name }} eignet sich am besten für eine Padelhalle?</summary>
|
||||
|
||||
Unsere Spitzenstadt nach <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score ist **{{ top_city_names[0] }}**. Der Score bewertet Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität. Die beste Stadt für *Dein* Vorhaben hängt aber von Faktoren wie Flächenverfügbarkeit, lokalem Wettbewerb und Deiner Zielgruppe ab. Nutze den <a href="/{{ language }}/planner">Finanzplaner</a>, um verschiedene Standorte durchzurechnen.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Was kostet eine Stunde Padel in {{ country_name_en }}?</summary>
|
||||
<summary>Was kostet eine Stunde Padel in {{ country_name }}?</summary>
|
||||
|
||||
{% if median_peak_rate %}Laut Live-Daten von Playtomic liegt der Median-Hauptzeitpreis in {{ country_name_en }} bei **{{ median_peak_rate | int }} {{ price_currency }}/Std**, Nebenzeit bei **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/Std**. Die Preise variieren stark zwischen Städten — auf den jeweiligen Stadtseiten findest Du lokale Benchmarks.{% else %}Aggregierte Preisdaten sind für {{ country_name_en }} noch nicht verfügbar. Prüfe die einzelnen Stadtseiten für lokale Daten.{% endif %}
|
||||
{% if median_peak_rate %}Laut Live-Daten von Playtomic liegt der Median-Hauptzeitpreis in {{ country_name }} bei **{{ median_peak_rate | int }} {{ price_currency }}/Std**, Nebenzeit bei **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/Std**. Die Preise variieren stark zwischen Städten — auf den jeweiligen Stadtseiten findest Du lokale Benchmarks.{% else %}Aggregierte Preisdaten sind für {{ country_name }} noch nicht verfügbar. Prüfe die einzelnen Stadtseiten für lokale Daten.{% endif %}
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie schnell wächst Padel in {{ country_name_en }}?</summary>
|
||||
<summary>Wie schnell wächst Padel in {{ country_name }}?</summary>
|
||||
|
||||
Padel gehört zu den am schnellsten wachsenden Racketsportarten in Europa. Mit {{ total_venues }} erfassten Anlagen in {{ city_count }} Städten zeigt {{ country_name_en }} {% if avg_opportunity_score >= 65 %}noch erhebliches Wachstumspotenzial — viele Standorte sind unterversorgt{% elif avg_opportunity_score >= 40 %}eine klare Wachstumsdynamik mit steigender Nachfrage und neuen Anlagen{% else %}bereits eine gut ausgebaute Infrastruktur — Wachstum kommt hier vor allem aus steigender Spielfrequenz und Premiumangeboten{% endif %}. Die Sportart profitiert von niedriger Einstiegshürde, hohem Spaßfaktor und starker Mund-zu-Mund-Verbreitung.
|
||||
Padel gehört zu den am schnellsten wachsenden Rückschlagsportarten in Europa. Mit {{ total_venues }} erfassten Anlagen in {{ city_count }} Städten zeigt {{ country_name }} {% if avg_opportunity_score >= 65 %}noch erhebliches Wachstumspotenzial — viele Standorte sind unterversorgt{% elif avg_opportunity_score >= 40 %}eine klare Wachstumsdynamik mit steigender Nachfrage und neuen Anlagen{% else %}bereits eine gut ausgebaute Infrastruktur — Wachstum kommt hier vor allem aus steigender Spielfrequenz und Premiumangeboten{% endif %}. Die Sportart profitiert von niedriger Einstiegshürde, hohem Spaßfaktor und starker Mund-zu-Mund-Verbreitung.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Welche Städte haben die besten Preisdaten?</summary>
|
||||
|
||||
Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score (wie {{ top_city_names[0] }}) haben in der Regel die umfassendsten Preisdaten, weil dort mehr Anlagen auf Playtomic gelistet sind. In unserem <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }}-Marktüberblick</a> findest Du alle Städte nach Score sortiert.
|
||||
Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score (wie {{ top_city_names[0] }}) haben in der Regel die umfassendsten Preisdaten, weil dort mehr Anlagen auf Playtomic gelistet sind. In unserem <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name }}-Marktüberblick</a> findest Du alle Städte nach Score sortiert.
|
||||
</details>
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||
Du überlegst, eine Padelhalle in {{ country_name_en }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
|
||||
Du überlegst, eine Padelhalle in {{ country_name }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
|
||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Zum Finanzplaner</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
# Padel in {{ country_name_en }} — Market Overview
|
||||
# Padel in {{ country_name }} — Market Overview
|
||||
|
||||
<div class="stats-strip">
|
||||
<div class="stats-strip__item">
|
||||
@@ -143,15 +143,15 @@ Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;f
|
||||
|
||||
<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 }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> is **{{ avg_opportunity_score }}/100** — {% if avg_opportunity_score >= 65 %}strong investment potential with meaningful supply gaps{% elif avg_opportunity_score >= 40 %}solid potential with room for new locations{% else %}a well-served market requiring careful site selection{% endif %}.
|
||||
{{ country_name }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> is **{{ avg_opportunity_score }}/100** — {% if avg_opportunity_score >= 65 %}strong investment potential with meaningful supply gaps{% elif avg_opportunity_score >= 40 %}solid potential with room for new locations{% else %}a well-served market requiring careful site selection{% endif %}.
|
||||
|
||||
## Market Landscape
|
||||
|
||||
Padel is growing rapidly across {{ country_name_en }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ avg_opportunity_score }}/100 across {{ city_count }} cities evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture.
|
||||
Padel is growing rapidly across {{ country_name }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ avg_opportunity_score }}/100 across {{ city_count }} cities evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture.
|
||||
|
||||
{% if avg_opportunity_score >= 65 %}A score above 65 signals meaningful supply gaps alongside existing demand. Even in regions with established padel infrastructure, venue density per 100,000 residents varies significantly between cities — local analysis pays off.{% elif avg_opportunity_score >= 40 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and well-positioned locations still offer opportunities for new entrants.{% else %}Many locations in {{ country_name_en }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}
|
||||
{% if avg_opportunity_score >= 65 %}A score above 65 signals meaningful supply gaps alongside existing demand. Even in regions with established padel infrastructure, venue density per 100,000 residents varies significantly between cities — local analysis pays off.{% elif avg_opportunity_score >= 40 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and well-positioned locations still offer opportunities for new entrants.{% else %}Many locations in {{ country_name }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}
|
||||
|
||||
## Top Cities in {{ country_name_en }}
|
||||
## Top Cities in {{ country_name }}
|
||||
|
||||
Cities are ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score — evaluating investment potential based on supply gaps, catchment reach, market maturity, and sports culture. Higher-scoring cities generally offer the strongest conditions for new facilities.
|
||||
|
||||
@@ -165,7 +165,7 @@ Each city page includes detailed pricing data, a cost-benefit analysis, and a pr
|
||||
## Pricing Overview
|
||||
|
||||
{% if median_peak_rate %}
|
||||
Court rental rates across {{ country_name_en }} cities (median from Playtomic live data):
|
||||
Court rental rates across {{ country_name }} cities (median from Playtomic live data):
|
||||
|
||||
| Rate Type | Median |
|
||||
|-----------|--------|
|
||||
@@ -177,52 +177,52 @@ Court rental rates across {{ country_name_en }} cities (median from Playtomic li
|
||||
|
||||
Prices vary significantly by city — larger cities with higher demand command premium rates. See individual city pages for city-specific benchmarks.
|
||||
{% else %}
|
||||
Detailed pricing data from Playtomic is not yet available for {{ country_name_en }} cities in aggregate. Visit individual city pages for the latest data where available.
|
||||
Detailed pricing data from Playtomic is not yet available for {{ country_name }} cities in aggregate. Visit individual city pages for the latest data where available.
|
||||
{% endif %}
|
||||
|
||||
## Build Your Business Plan for {{ country_name_en }}
|
||||
## Build Your Business Plan for {{ country_name }}
|
||||
|
||||
Every city has a different cost structure, competitive landscape, and customer base. The Padelnomics financial planner lets you model a padel center with real local market data as defaults — then adjust every assumption to match your specific site. From construction costs and court count to revenue projections and payback period, get a data-driven view of your investment before committing capital.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||
Build your business plan for a {{ country_name_en }} padel center →
|
||||
Build your business plan for a {{ country_name }} padel center →
|
||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Open the Planner</a>
|
||||
</div>
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary>How many padel courts are there in {{ country_name_en }}?</summary>
|
||||
<summary>How many padel courts are there in {{ country_name }}?</summary>
|
||||
|
||||
We currently track **{{ total_venues }} padel venues** across **{{ city_count }} cities** in {{ country_name_en }}. This covers venues listed on Playtomic and venues identified through our Overpass/OpenStreetMap data source. The actual total may be higher as independent clubs not listed on booking platforms are not always captured.
|
||||
We currently track **{{ total_venues }} padel venues** across **{{ city_count }} cities** in {{ country_name }}. This covers venues listed on Playtomic and venues identified through our Overpass/OpenStreetMap data source. The actual total may be higher as independent clubs not listed on booking platforms are not always captured.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Which city in {{ country_name_en }} is best for a padel center?</summary>
|
||||
<summary>Which city in {{ country_name }} is best for a padel center?</summary>
|
||||
|
||||
Our top-ranked city by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score is **{{ top_city_names[0] }}**. The score evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture. However, the best city for *you* depends on land availability, local competition, and your target customer profile. Use the <a href="/{{ language }}/planner">financial planner</a> to model different locations side by side.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What are typical padel court prices in {{ country_name_en }}?</summary>
|
||||
<summary>What are typical padel court prices in {{ country_name }}?</summary>
|
||||
|
||||
{% if median_peak_rate %}Based on live Playtomic data, median peak rates across {{ country_name_en }} cities are **{{ median_peak_rate | int }} {{ price_currency }}/hr** and off-peak rates are around **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/hr**. Individual cities vary — see each city's page for local benchmarks.{% else %}Pricing data is not yet available in aggregate for {{ country_name_en }}. Check individual city pages where Playtomic data is available.{% endif %}
|
||||
{% if median_peak_rate %}Based on live Playtomic data, median peak rates across {{ country_name }} cities are **{{ median_peak_rate | int }} {{ price_currency }}/hr** and off-peak rates are around **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/hr**. Individual cities vary — see each city's page for local benchmarks.{% else %}Pricing data is not yet available in aggregate for {{ country_name }}. Check individual city pages where Playtomic data is available.{% endif %}
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How fast is padel growing in {{ country_name_en }}?</summary>
|
||||
<summary>How fast is padel growing in {{ country_name }}?</summary>
|
||||
|
||||
Padel is one of the fastest-growing racquet sports in Europe. With {{ total_venues }} venues tracked across {{ city_count }} cities, {{ country_name_en }} shows {% if avg_opportunity_score >= 65 %}significant untapped potential — many locations remain underserved{% elif avg_opportunity_score >= 40 %}clear growth momentum with rising demand and new venues opening{% else %}a well-developed infrastructure — growth comes mainly from increasing play frequency and premium offerings{% endif %}. The sport benefits from a low barrier to entry, high enjoyment factor, and strong word-of-mouth growth among players.
|
||||
Padel is one of the fastest-growing racquet sports in Europe. With {{ total_venues }} venues tracked across {{ city_count }} cities, {{ country_name }} shows {% if avg_opportunity_score >= 65 %}significant untapped potential — many locations remain underserved{% elif avg_opportunity_score >= 40 %}clear growth momentum with rising demand and new venues opening{% else %}a well-developed infrastructure — growth comes mainly from increasing play frequency and premium offerings{% endif %}. The sport benefits from a low barrier to entry, high enjoyment factor, and strong word-of-mouth growth among players.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Which cities have the best pricing data?</summary>
|
||||
|
||||
Cities with higher <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Scores (like {{ top_city_names[0] }}) typically have the most comprehensive pricing data, because more venues are listed on Playtomic. Browse our <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }} market overview</a> to see all cities ranked by score.
|
||||
Cities with higher <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Scores (like {{ top_city_names[0] }}) typically have the most comprehensive pricing data, because more venues are listed on Playtomic. Browse our <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name }} market overview</a> to see all cities ranked by score.
|
||||
</details>
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →
|
||||
Considering a padel center in {{ country_name }}? Model your investment with real market data →
|
||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Open the Planner</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,11 +21,9 @@
|
||||
<!-- Map legend -->
|
||||
<div class="mb-6" style="display:flex; flex-wrap:wrap; gap:1rem 1.5rem; align-items:center; font-size:0.82rem; color:#64748B;">
|
||||
<span style="display:flex; align-items:center; gap:0.3rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#15803D;"></span>≥80
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0D9488;margin-left:4px;"></span>≥60
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;margin-left:4px;"></span>≥40
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EA580C;margin-left:4px;"></span>≥20
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;margin-left:4px;"></span><20
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#15803D;"></span>≥60
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;margin-left:4px;"></span>≥30
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;margin-left:4px;"></span><30
|
||||
</span>
|
||||
<span style="display:flex; align-items:center; gap:0.35rem;">
|
||||
<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:#64748B; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span>
|
||||
@@ -55,7 +53,7 @@
|
||||
hx-include="#market-q, #market-region">
|
||||
<option value="">{{ t.mkt_all_countries }}</option>
|
||||
{% for c in countries %}
|
||||
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c | country_name(lang) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -86,7 +84,7 @@
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
||||
<script>
|
||||
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",cities:"{{ t.map_cities }}"};
|
||||
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",cities:"{{ t.map_cities }}",score_avg:"{{ t.map_score_avg }}",score_top:"{{ t.map_score_top }}"};
|
||||
(function() {
|
||||
var sc = PNMarkers.scoreColor;
|
||||
var T = window.__MAP_T;
|
||||
@@ -105,9 +103,13 @@ window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues
|
||||
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
|
||||
var score = c.avg_opportunity_score || 0;
|
||||
var hex = sc(score);
|
||||
var tip = '<strong>' + c.country_name_en + '</strong><br>'
|
||||
var topScore = c.top_opportunity_score || 0;
|
||||
var topHex = sc(topScore);
|
||||
var tip = '<strong>' + c.country_name + '</strong><br>'
|
||||
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
|
||||
+ '<span style="color:' + hex + ';font-weight:600;">' + T.score_label + ': ' + score + '/100</span><br>'
|
||||
+ '<span style="color:' + hex + ';font-weight:600;">' + T.score_avg + ': ' + score + '/100</span><br>'
|
||||
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + topHex + ';vertical-align:middle;margin-right:4px;"></span>'
|
||||
+ '<span style="color:' + topHex + ';font-weight:600;">' + T.score_top + ': ' + topScore + '/100</span><br>'
|
||||
+ '<span style="color:#94A3B8;font-size:0.75rem;">' + c.total_venues + ' ' + T.venues + ' · ' + c.city_count + ' ' + T.cities + '</span>';
|
||||
L.marker([c.lat, c.lon], { icon: PNMarkers.makeIcon({ size: size, color: hex }) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
|
||||
@@ -72,6 +72,8 @@ class Config:
|
||||
GSC_SITE_URL: str = os.getenv("GSC_SITE_URL", "")
|
||||
BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
|
||||
BING_SITE_URL: str = os.getenv("BING_SITE_URL", "")
|
||||
INDEXNOW_KEY: str = os.getenv("INDEXNOW_KEY", "")
|
||||
CLARITY_PROJECT_ID: str = os.getenv("CLARITY_PROJECT_ID", "")
|
||||
|
||||
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
||||
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
||||
|
||||
@@ -6,6 +6,28 @@
|
||||
<meta name="description" content="{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
||||
<meta property="og:title" content="{{ t.dir_page_title }} - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="{{ t.dir_page_og_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"name": "{{ t.dir_page_title }} - {{ config.APP_NAME }}",
|
||||
"description": "{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}",
|
||||
"url": "{{ config.BASE_URL }}/{{ lang }}/directory/",
|
||||
"inLanguage": "{{ lang }}",
|
||||
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||
{"@type": "ListItem", "position": 2, "name": "{{ t.dir_page_title }}", "item": "{{ config.BASE_URL }}/{{ lang }}/directory/"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--dir-green: #15803D;
|
||||
|
||||
@@ -49,6 +49,25 @@ COUNTRY_LABELS: dict[str, str] = {
|
||||
"AU": "Australia",
|
||||
"ZA": "South Africa",
|
||||
"EG": "Egypt",
|
||||
"PL": "Poland",
|
||||
"RO": "Romania",
|
||||
"CO": "Colombia",
|
||||
"HU": "Hungary",
|
||||
"KE": "Kenya",
|
||||
"CZ": "Czech Republic",
|
||||
"QA": "Qatar",
|
||||
"NZ": "New Zealand",
|
||||
"HR": "Croatia",
|
||||
"LV": "Latvia",
|
||||
"MT": "Malta",
|
||||
"CR": "Costa Rica",
|
||||
"CY": "Cyprus",
|
||||
"PA": "Panama",
|
||||
"SV": "El Salvador",
|
||||
"DO": "Dominican Republic",
|
||||
"PE": "Peru",
|
||||
"VE": "Venezuela",
|
||||
"IE": "Ireland",
|
||||
}
|
||||
|
||||
_LOCALES_DIR = Path(__file__).parent / "locales"
|
||||
|
||||
@@ -345,6 +345,25 @@
|
||||
"dir_country_AU": "Australien",
|
||||
"dir_country_ZA": "Südafrika",
|
||||
"dir_country_EG": "Ägypten",
|
||||
"dir_country_PL": "Polen",
|
||||
"dir_country_RO": "Rumänien",
|
||||
"dir_country_CO": "Kolumbien",
|
||||
"dir_country_HU": "Ungarn",
|
||||
"dir_country_KE": "Kenia",
|
||||
"dir_country_CZ": "Tschechien",
|
||||
"dir_country_QA": "Katar",
|
||||
"dir_country_NZ": "Neuseeland",
|
||||
"dir_country_HR": "Kroatien",
|
||||
"dir_country_LV": "Lettland",
|
||||
"dir_country_MT": "Malta",
|
||||
"dir_country_CR": "Costa Rica",
|
||||
"dir_country_CY": "Zypern",
|
||||
"dir_country_PA": "Panama",
|
||||
"dir_country_SV": "El Salvador",
|
||||
"dir_country_DO": "Dominikanische Republik",
|
||||
"dir_country_PE": "Peru",
|
||||
"dir_country_VE": "Venezuela",
|
||||
"dir_country_IE": "Irland",
|
||||
"sp_back": "Zurück zum Verzeichnis",
|
||||
"sp_verified": "Verifiziert ✓",
|
||||
"sp_request_quote": "Angebot anfragen →",
|
||||
@@ -620,6 +639,8 @@
|
||||
"map_existing_venues": "bestehende Anlagen",
|
||||
"map_km_nearest": "km zur nächsten Anlage",
|
||||
"map_no_nearby": "Keine Anlagen in der Nähe",
|
||||
"map_score_avg": "Ø Score",
|
||||
"map_score_top": "Top-Stadt",
|
||||
"waitlist_markets_title": "Marktdaten — Demnächst verfügbar",
|
||||
"waitlist_markets_sub": "Detaillierte Marktberichte für Padel-Investoren: Baukosten, Umsatz-Benchmarks, Auslastungsdaten und ROI-Analysen nach Stadt und Region.",
|
||||
"waitlist_markets_feature1": "Echte Kostendaten aus laufenden Anlagen in über 30 Ländern",
|
||||
@@ -1704,50 +1725,48 @@
|
||||
"email_footer_copyright": "© {year} {app_name}. Du erhältst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.",
|
||||
"footer_padelnomics_score": "Padelnomics Score",
|
||||
"pnscore_page_title": "Padelnomics Score — So bewerten wir Padel-Investitionsstandorte",
|
||||
"pnscore_meta_desc": "Der Padelnomics Score bewertet das Investitionspotenzial von Padel-Standorten in Europa. Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität in einem Score von 0-100.",
|
||||
"pnscore_og_desc": "Ein Score, der zeigt, wo sich eine Padelhalle lohnt. Methodik, Komponenten und Datenquellen erklärt.",
|
||||
"pnscore_meta_desc": "Der Padelnomics Score bewertet das Investitionspotenzial von Padel-Standorten in Europa. Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität in einem Score von 0 bis 100.",
|
||||
"pnscore_og_desc": "Ein Score, der zeigt, wo sich eine Padelhalle lohnt. Methodik und Bewertungsfaktoren erklärt.",
|
||||
"pnscore_subtitle": "Ein Score für Padel-Investitionspotenzial — Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität auf einer Skala von 0 bis 100.",
|
||||
"pnscore_what_h2": "Was ist der Padelnomics Score?",
|
||||
"pnscore_what_intro": "Der Padelnomics Score ist ein Komposit-Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für eine neue Padelanlage ist. Er kombiniert angebotsseitige Lücken (gibt es genug Courts?) mit nachfrageseitigen Signalen (Bevölkerung, Einkommen, Sportaffinität) und berücksichtigt die Marktreife. Ein hoher Score bedeutet: Es gibt adressierbare Nachfrage, das Gebiet ist unterversorgt und die Rahmenbedingungen begünstigen ein Investment.",
|
||||
"pnscore_what_intro": "Der Padelnomics Score ist ein gewichteter Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für eine neue Padelanlage ist. Er kombiniert angebotsseitige Lücken (gibt es genug Courts?) mit nachfrageseitigen Signalen (Bevölkerung, Einkommen, Sportaffinität) und berücksichtigt die Marktreife. Ein hoher Score bedeutet: Es gibt adressierbare Nachfrage, das Gebiet ist unterversorgt und die Rahmenbedingungen begünstigen ein Investment.",
|
||||
"pnscore_components_h2": "Was der Score misst",
|
||||
"pnscore_components_intro": "Sechs gewichtete Komponenten fließen in den Gesamtscore ein. Jede erfasst einen anderen Aspekt des Investitionspotenzials.",
|
||||
"pnscore_cat_market_h3": "Adressierbarer Markt (20 Pkt)",
|
||||
"pnscore_cat_market_p": "Einzugsgebiet-Bevölkerung im Umkreis von ~24 km (H3 Res-5-Zelle + Nachbarn). Logarithmisch skaliert — eine Stadt mit 500K Einwohnern erreicht das Maximum. Größeres Einzugsgebiet bedeutet mehr potenzielle Spieler.",
|
||||
"pnscore_cat_econ_h3": "Wirtschaftskraft (15 Pkt)",
|
||||
"pnscore_cat_econ_p": "Regionales Einkommen in Kaufkraftstandards (KKS). Höheres verfügbares Einkommen stützt Premium-Preise und häufigeres Spielen. Daten von Eurostat (EU), Census (USA), ONS (UK).",
|
||||
"pnscore_cat_gap_h3": "Versorgungslücke (40 Pkt)",
|
||||
"pnscore_cat_gap_p": "Die gewichtigste Komponente. Misst zwei Signale: Anlagendichte-Lücke (wie weit unter 5 Courts pro 100K?) und Entfernungslücke (wie weit zur nächsten Anlage?). Null Courts = maximale Punktzahl. Bereits gut versorgte Gebiete erhalten kaum Punkte.",
|
||||
"pnscore_cat_sports_h3": "Sportaffinität (10 Pkt)",
|
||||
"pnscore_cat_sports_p": "Tennisplatz-Dichte im Umkreis von 25 km als Proxy für Racketsport-Affinität. Regionen mit starker Tennis-Infrastruktur haben ein bereites Publikum für Padel — einen eng verwandten Sport mit niedrigerer Einstiegshürde.",
|
||||
"pnscore_cat_catchment_h3": "Baukosten-Erschwinglichkeit (5 Pkt)",
|
||||
"pnscore_cat_catchment_p": "Einkommen relativ zu lokalen Baukosten (Eurostat-Preisniveau-Index). Höhere Erschwinglichkeit bedeutet bessere Margen beim Bau — das Umsatzpotenzial wird nicht von den Baukosten aufgefressen.",
|
||||
"pnscore_cat_maturity_h3": "Markt-Spielraum (10 Pkt)",
|
||||
"pnscore_cat_maturity_p": "Invers zur durchschnittlichen Marktreife des Landes. Länder mit bereits gesättigten Märkten (z.B. Spanien) erhalten hier weniger Punkte — der nationale Markt ist wettbewerbsintensiv. Aufstrebende Märkte punkten höher — mehr Raum zum Wachsen.",
|
||||
"pnscore_components_intro": "Mehrere Faktoren fließen in den Gesamtscore ein. Jeder erfasst einen anderen Aspekt des Investitionspotenzials.",
|
||||
"pnscore_cat_market_h3": "Marktgröße",
|
||||
"pnscore_cat_market_p": "Die Bevölkerung im definierten Einzugsgebiet eines Standorts. Ein größeres Einzugsgebiet bedeutet mehr potenzielle Spieler und einen größeren adressierbaren Markt für eine neue Anlage.",
|
||||
"pnscore_cat_econ_h3": "Wirtschaftskraft",
|
||||
"pnscore_cat_econ_p": "Regionales Einkommen, kaufkraftbereinigt. Höheres verfügbares Einkommen stützt Premium-Preise und häufigeres Spielen.",
|
||||
"pnscore_cat_gap_h3": "Versorgungslücke",
|
||||
"pnscore_cat_gap_p": "Der am stärksten gewichtete Faktor. Misst zwei Signale: Wie weit liegt die Court-Dichte unter dem landesweiten Richtwert, und wie weit ist es zum nächsten bestehenden Court? Der Wert wird durch die Marktetablierung gedämpft — eine Versorgungslücke in einem Land ohne nachgewiesene Padel-Nachfrage zählt weniger als in einem etablierten Markt.",
|
||||
"pnscore_cat_sports_h3": "Sportaffinität",
|
||||
"pnscore_cat_sports_p": "Tennisplatz-Dichte in der Umgebung als Indikator für die Affinität zu Rückschlagsportarten. Regionen mit starker Tennis-Infrastruktur haben ein bereites Publikum für Padel — einen eng verwandten Sport mit niedrigerer Einstiegshürde.",
|
||||
"pnscore_cat_catchment_h3": "Baukosten-Erschwinglichkeit",
|
||||
"pnscore_cat_catchment_p": "Einkommen im Verhältnis zu den regionalen Baukosten. Höhere Erschwinglichkeit bedeutet bessere Margen beim Bau — das Umsatzpotenzial wird nicht von den Baukosten aufgefressen.",
|
||||
"pnscore_cat_maturity_h3": "Markt-Spielraum",
|
||||
"pnscore_cat_maturity_p": "Länder mit bereits gesättigten Märkten (z.B. Spanien) erhalten hier weniger Punkte — der nationale Markt ist wettbewerbsintensiv. Je weniger reif der Markt, desto höher der Spielraum für Neueinsteiger.",
|
||||
"pnscore_read_h2": "Wie man den Score liest",
|
||||
"pnscore_band_high_label": "80+ — Ausgezeichnet",
|
||||
"pnscore_band_high_p": "Erstklassiges Investitionspotenzial. Erhebliche Versorgungslücken, starkes Einzugsgebiet und günstige Marktbedingungen. Diese Standorte sind erste Wahl für neue Anlagen.",
|
||||
"pnscore_band_good_label": "60-79 — Gut",
|
||||
"pnscore_band_good_p": "Starkes Potenzial bei etwas mehr Wettbewerb oder kleinerem Einzugsgebiet. Weiterhin attraktiv für gut positionierte Projekte mit klarer Differenzierungsstrategie.",
|
||||
"pnscore_band_mid_label": "40-59 — Moderat",
|
||||
"pnscore_band_high_label": "60+ — Hoch",
|
||||
"pnscore_band_high_p": "Starkes Investitionspotenzial. Erhebliche Versorgungslücken, starkes Einzugsgebiet und günstige Marktbedingungen. Diese Standorte sind erste Wahl für neue Anlagen.",
|
||||
"pnscore_band_mid_label": "30-59 — Moderat",
|
||||
"pnscore_band_mid_p": "Solide Grundlagen, aber der Markt ist teilweise versorgt. Erfolg hängt von präziser Standortwahl, Preisgestaltung und Anlagenqualität ab.",
|
||||
"pnscore_band_low_label": "20-39 — Unterdurchschnittlich",
|
||||
"pnscore_band_low_label": "<30 — Niedrig",
|
||||
"pnscore_band_low_p": "Das Gebiet ist vergleichsweise gut versorgt oder zeigt schwächere Nachfragesignale. Neue Anlagen stehen im stärkeren Wettbewerb und brauchen ein überzeugendes Konzept.",
|
||||
"pnscore_sources_h2": "Datenquellen",
|
||||
"pnscore_sources_p": "Der Score nutzt mehrere offene und proprietäre Datenquellen: GeoNames (globale Städtebevölkerung, 140K+ Standorte), Eurostat (regionales Einkommen, Preisniveaus, NUTS-2-Grenzen), US Census und ONS UK (Bevölkerung/Einkommen außerhalb der EU), OpenStreetMap via Overpass (Padel- und Tennisplatz-Standorte) sowie Playtomic (Anlagenverzeichnisse, Court-Zahlen). Alle Daten werden regelmäßig aktualisiert und durch unsere SQLMesh-Pipeline verarbeitet.",
|
||||
"pnscore_sources_h2": "Unsere Daten",
|
||||
"pnscore_sources_p": "Der Score basiert auf mehreren offenen und proprietären Datenquellen zu Anlagenstandorten, Bevölkerung, Einkommen und Sportinfrastruktur. Alle Daten werden regelmäßig aktualisiert und über unsere Analysepipeline verarbeitet.",
|
||||
"pnscore_limits_h2": "Einschränkungen",
|
||||
"pnscore_limits_p1": "Der Score bewertet Standortpotenzial, nicht Projektdurchführbarkeit. Er kann standortspezifische Faktoren wie Grundstücksverfügbarkeit, Bebauungsvorschriften, Mietkosten oder lokale Wettbewerbsdetails nicht berücksichtigen. Den Score immer mit eigener Vor-Ort-Recherche kombinieren.",
|
||||
"pnscore_limits_p2": "Die Datenabdeckung variiert nach Land. Europäische Märkte haben die stärkste Datenbasis (Eurostat-Einkommen, dichte Anlagenverzeichnisse). Aufstrebende Märkte haben teils weniger granulare Einkommensdaten, was die Komponenten Wirtschaftskraft und Erschwinglichkeit beeinflusst.",
|
||||
"pnscore_limits_p2": "Die Datenabdeckung variiert nach Land. Europäische Märkte haben die stärkste Datenbasis (detaillierte Einkommensstatistiken, dichte Anlagenverzeichnisse). Aufstrebende Märkte haben teils weniger granulare Einkommensdaten, was die Bewertung von Wirtschaftskraft und Erschwinglichkeit beeinflusst.",
|
||||
"pnscore_cta_markets": "Märkte entdecken",
|
||||
"pnscore_cta_planner": "Finanzplaner",
|
||||
"pnscore_faq_h2": "Häufige Fragen",
|
||||
"pnscore_faq_q1": "Was ist der Padelnomics Score?",
|
||||
"pnscore_faq_a1": "Ein Komposit-Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für den Bau einer neuen Padelanlage ist. Er kombiniert Versorgungslücken, Einzugsgebiet, Wirtschaftskraft, Sportaffinität, Baukosten-Erschwinglichkeit und Markt-Spielraum in einer einzigen Kennzahl.",
|
||||
"pnscore_faq_a1": "Ein gewichteter Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für den Bau einer neuen Padelanlage ist. Er fasst Versorgungslücken, Einzugsgebiet, Wirtschaftskraft, Sportaffinität, Baukosten-Erschwinglichkeit und Markt-Spielraum in einer einzigen Kennzahl zusammen.",
|
||||
"pnscore_faq_q2": "Wie oft wird der Score aktualisiert?",
|
||||
"pnscore_faq_a2": "Der Score wird täglich neu berechnet. Anlagenverzeichnisse, Bevölkerungsdaten und Einkommensstatistiken werden in ihren jeweiligen Intervallen aktualisiert (täglich für Anlagen, monatlich für Bevölkerung, jährlich für Einkommen).",
|
||||
"pnscore_faq_a2": "Der Score wird regelmäßig neu berechnet, sobald aktuelle Daten vorliegen. Die verschiedenen Datenquellen werden in unterschiedlichen Intervallen aktualisiert.",
|
||||
"pnscore_faq_q3": "Warum hat eine Stadt mit vielen Padel-Courts einen niedrigen Score?",
|
||||
"pnscore_faq_a3": "Der Score belohnt unterversorgte Gebiete. Eine Stadt mit hoher Court-Dichte relativ zur Bevölkerung hat eine geringe Versorgungslücke — die gewichtigste Komponente (40 von 100 Punkten). Gut versorgte Städte können dennoch moderat punkten, wenn andere Faktoren (Einzugsgebiet, Wirtschaftskraft) stark sind.",
|
||||
"pnscore_faq_a3": "Der Score belohnt unterversorgte Gebiete. Eine Stadt mit hoher Court-Dichte relativ zur Bevölkerung hat eine geringe Versorgungslücke — der am stärksten gewichtete Faktor. Gut versorgte Städte können dennoch moderat punkten, wenn andere Faktoren wie Einzugsgebiet oder Wirtschaftskraft stark sind.",
|
||||
"pnscore_faq_q4": "Wie beeinflusst Marktreife den Score?",
|
||||
"pnscore_faq_a4": "Marktreife fließt auf zwei Wegen ein: Die Versorgungslücke misst direkt die lokale Court-Dichte, und der Markt-Spielraum gewichtet die landesweite Marktreife invers. Länder mit dominantem Padel-Markt (z.B. Spanien) bieten weniger Spielraum als aufstrebende Märkte.",
|
||||
"pnscore_faq_a4": "Marktreife fließt auf zwei Wegen ein: Die Versorgungslücke misst direkt die lokale Court-Dichte, und der Markt-Spielraum berücksichtigt die landesweite Marktreife. Länder mit dominantem Padel-Markt (z.B. Spanien) bieten weniger Spielraum als aufstrebende Märkte.",
|
||||
"pnscore_faq_q5": "Kann ich den Score für meinen Businessplan verwenden?",
|
||||
"pnscore_faq_a5": "Ja — der Score ist als Screening-Tool für die Standortsuche konzipiert. Nutze ihn, um vielversprechende Standorte vorzuselektieren, und vertiefe dann mit dem Finanzplaner die Umsatz-, Kosten- und Renditeanalyse für Dein konkretes Szenario.",
|
||||
"sup_cta_btn": "Kostenlos starten",
|
||||
|
||||
@@ -345,6 +345,25 @@
|
||||
"dir_country_AU": "Australia",
|
||||
"dir_country_ZA": "South Africa",
|
||||
"dir_country_EG": "Egypt",
|
||||
"dir_country_PL": "Poland",
|
||||
"dir_country_RO": "Romania",
|
||||
"dir_country_CO": "Colombia",
|
||||
"dir_country_HU": "Hungary",
|
||||
"dir_country_KE": "Kenya",
|
||||
"dir_country_CZ": "Czech Republic",
|
||||
"dir_country_QA": "Qatar",
|
||||
"dir_country_NZ": "New Zealand",
|
||||
"dir_country_HR": "Croatia",
|
||||
"dir_country_LV": "Latvia",
|
||||
"dir_country_MT": "Malta",
|
||||
"dir_country_CR": "Costa Rica",
|
||||
"dir_country_CY": "Cyprus",
|
||||
"dir_country_PA": "Panama",
|
||||
"dir_country_SV": "El Salvador",
|
||||
"dir_country_DO": "Dominican Republic",
|
||||
"dir_country_PE": "Peru",
|
||||
"dir_country_VE": "Venezuela",
|
||||
"dir_country_IE": "Ireland",
|
||||
"sp_back": "Back to Directory",
|
||||
"sp_verified": "Verified ✓",
|
||||
"sp_request_quote": "Request Quote →",
|
||||
@@ -620,6 +639,8 @@
|
||||
"map_existing_venues": "existing venues",
|
||||
"map_km_nearest": "km to nearest court",
|
||||
"map_no_nearby": "No nearby courts",
|
||||
"map_score_avg": "Avg. Score",
|
||||
"map_score_top": "Top City",
|
||||
"waitlist_markets_title": "Markets Intelligence — Coming Soon",
|
||||
"waitlist_markets_sub": "Deep-dive market reports for padel investors: construction costs, revenue benchmarks, occupancy data, and ROI analysis by city and region.",
|
||||
"waitlist_markets_feature1": "Real cost data from operating venues across 30+ countries",
|
||||
@@ -1736,49 +1757,47 @@
|
||||
"footer_padelnomics_score": "Padelnomics Score",
|
||||
"pnscore_page_title": "Padelnomics Score — How We Rate Padel Investment Locations",
|
||||
"pnscore_meta_desc": "The Padelnomics Score evaluates investment potential for padel locations across Europe. Learn how supply gaps, catchment area, market maturity, and sports culture combine into a single 0-100 score.",
|
||||
"pnscore_og_desc": "A single score that tells you where to build a padel center. Methodology, components, and data sources explained.",
|
||||
"pnscore_og_desc": "A single score that tells you where to build a padel center. Methodology and components explained.",
|
||||
"pnscore_subtitle": "One score to evaluate padel investment potential — supply gaps, catchment area, market maturity, and sports culture combined into 0-100.",
|
||||
"pnscore_what_h2": "What Is the Padelnomics Score?",
|
||||
"pnscore_what_intro": "The Padelnomics Score is a 0-100 composite index that evaluates how attractive a location is for a new padel facility. It combines supply-side gaps (are there enough courts?) with demand-side signals (population, income, sports culture) and adjusts for market maturity. A high score means: there is addressable demand, the area is underserved, and conditions favor a new investment.",
|
||||
"pnscore_what_intro": "The Padelnomics Score is a 0-100 index that evaluates how attractive a location is for a new padel facility. It combines supply-side gaps (are there enough courts?) with demand-side signals (population, income, sports culture) and adjusts for market maturity. A high score means: there is addressable demand, the area is underserved, and conditions favor a new investment.",
|
||||
"pnscore_components_h2": "What It Measures",
|
||||
"pnscore_components_intro": "Six weighted components combine into the final score. Each captures a different aspect of investment potential.",
|
||||
"pnscore_cat_market_h3": "Addressable Market (20 pts)",
|
||||
"pnscore_cat_market_p": "Catchment population within ~24 km (H3 res-5 cell + neighbors). Log-scaled — a city of 500K scores the maximum. Larger catchment means more potential players.",
|
||||
"pnscore_cat_econ_h3": "Economic Power (15 pts)",
|
||||
"pnscore_cat_econ_p": "Regional income in purchasing power standard (PPS). Higher disposable income supports premium pricing and more frequent play. Data from Eurostat (EU), Census (US), ONS (UK).",
|
||||
"pnscore_cat_gap_h3": "Supply Deficit (40 pts)",
|
||||
"pnscore_cat_gap_p": "The single biggest component. Measures two signals: court density gap (how far below 5 courts per 100K?) and distance gap (how far to the nearest existing court?). Zero courts = maximum score. Already well-served areas score near zero.",
|
||||
"pnscore_cat_sports_h3": "Sports Culture (10 pts)",
|
||||
"pnscore_cat_sports_p": "Tennis court density within 25 km as a proxy for racquet sport adoption. Regions with strong tennis infrastructure have a ready audience for padel — a closely related sport with a lower barrier to entry.",
|
||||
"pnscore_cat_catchment_h3": "Construction Affordability (5 pts)",
|
||||
"pnscore_cat_catchment_p": "Income relative to local construction costs (Eurostat Price Level Index). Higher affordability means better margins on the build — your revenue potential isn’t eaten by construction costs.",
|
||||
"pnscore_cat_maturity_h3": "Market Headroom (10 pts)",
|
||||
"pnscore_cat_maturity_p": "Inverse of the country’s average market maturity. Countries with already saturated markets (e.g. Spain) score lower here — the national market is competitive. Emerging markets score higher — more room to grow.",
|
||||
"pnscore_components_intro": "Several factors combine into the final score. Each captures a different aspect of investment potential.",
|
||||
"pnscore_cat_market_h3": "Catchment Population",
|
||||
"pnscore_cat_market_p": "The population reachable from the location within a defined catchment radius. A larger catchment means more potential players and a bigger addressable market for a new facility.",
|
||||
"pnscore_cat_econ_h3": "Economic Power",
|
||||
"pnscore_cat_econ_p": "Regional income adjusted for purchasing power. Higher disposable income supports premium pricing and more frequent play.",
|
||||
"pnscore_cat_gap_h3": "Supply Deficit",
|
||||
"pnscore_cat_gap_p": "The most heavily weighted factor. Measures two signals: how far court density falls below the national benchmark, and how far the nearest existing court is. Dampened by market existence — a supply gap in a country with no proven padel demand carries less weight than one in an established market.",
|
||||
"pnscore_cat_sports_h3": "Sports Culture",
|
||||
"pnscore_cat_sports_p": "Nearby tennis court density as an indicator of racquet sport adoption. Regions with strong tennis infrastructure have a ready audience for padel — a closely related sport with a lower barrier to entry.",
|
||||
"pnscore_cat_catchment_h3": "Construction Affordability",
|
||||
"pnscore_cat_catchment_p": "Income relative to regional construction costs. Higher affordability means better margins on the build — your revenue potential isn’t eaten by construction costs.",
|
||||
"pnscore_cat_maturity_h3": "Market Headroom",
|
||||
"pnscore_cat_maturity_p": "Countries with already saturated markets (e.g. Spain) score lower here — the national market is competitive. Emerging markets score higher — more room to grow.",
|
||||
"pnscore_read_h2": "How to Read the Score",
|
||||
"pnscore_band_high_label": "80+ — Excellent",
|
||||
"pnscore_band_high_p": "Top-tier investment potential. Significant supply gaps, strong catchment, and favorable market conditions. These locations are prime targets for new facilities.",
|
||||
"pnscore_band_good_label": "60-79 — Good",
|
||||
"pnscore_band_good_p": "Strong potential with some competition or smaller catchment. Still attractive for well-positioned projects with a clear differentiation strategy.",
|
||||
"pnscore_band_mid_label": "40-59 — Moderate",
|
||||
"pnscore_band_high_label": "60+ — High",
|
||||
"pnscore_band_high_p": "Strong investment potential. Significant supply gaps, strong catchment, and favorable market conditions. These locations are prime targets for new facilities.",
|
||||
"pnscore_band_mid_label": "30-59 — Moderate",
|
||||
"pnscore_band_mid_p": "Decent fundamentals but the market is partially served. Success depends on precise site selection, pricing, and facility quality.",
|
||||
"pnscore_band_low_label": "20-39 — Below Average",
|
||||
"pnscore_band_low_label": "<30 — Low",
|
||||
"pnscore_band_low_p": "The area is comparatively well-served or has limited demand signals. New facilities face stiffer competition and need a strong value proposition.",
|
||||
"pnscore_sources_h2": "Data Sources",
|
||||
"pnscore_sources_p": "The score draws on multiple open and proprietary data sources: GeoNames (global city population, 140K+ locations), Eurostat (regional income, price levels, NUTS-2 boundaries), US Census and ONS UK (population/income outside EU), OpenStreetMap via Overpass (padel and tennis court locations), and Playtomic (venue listings, court counts). All data is refreshed on a regular schedule and processed through our SQLMesh pipeline.",
|
||||
"pnscore_sources_h2": "Our Data",
|
||||
"pnscore_sources_p": "The score combines multiple open and proprietary data sources covering venue locations, population, income, and sports infrastructure. All data is refreshed on a regular schedule and processed through our analytics pipeline.",
|
||||
"pnscore_limits_h2": "Limitations",
|
||||
"pnscore_limits_p1": "The score evaluates location-level potential, not project-level feasibility. It cannot account for site-specific factors like land availability, zoning, lease costs, or local competition details. Always combine the score with on-the-ground research.",
|
||||
"pnscore_limits_p2": "Data coverage varies by country. European markets have the strongest data (Eurostat income, dense venue listings). Emerging markets may have less granular income data, which affects the economic power and affordability components.",
|
||||
"pnscore_limits_p2": "Data coverage varies by country. European markets have the strongest data (detailed income statistics, dense venue listings). Emerging markets may have less granular income data, which affects the economic power and affordability components.",
|
||||
"pnscore_cta_markets": "Explore Markets",
|
||||
"pnscore_cta_planner": "Financial Planner",
|
||||
"pnscore_faq_h2": "Frequently Asked Questions",
|
||||
"pnscore_faq_q1": "What is the Padelnomics Score?",
|
||||
"pnscore_faq_a1": "A composite 0-100 index that evaluates how attractive a location is for building a new padel facility. It combines supply gaps, catchment population, economic power, sports culture, construction affordability, and market headroom into a single number.",
|
||||
"pnscore_faq_a1": "A 0-100 index that evaluates how attractive a location is for building a new padel facility. It combines supply gaps, catchment population, economic power, sports culture, construction affordability, and market headroom into a single number.",
|
||||
"pnscore_faq_q2": "How often is the score updated?",
|
||||
"pnscore_faq_a2": "The score is recalculated daily as new data flows through our pipeline. Venue listings, population data, and income statistics are refreshed on their respective schedules (daily for venues, monthly for population, annually for income).",
|
||||
"pnscore_faq_a2": "The score is recalculated regularly as new data flows through our pipeline. Different data sources are refreshed at different intervals to keep the score current.",
|
||||
"pnscore_faq_q3": "Why does a city with many padel courts score low?",
|
||||
"pnscore_faq_a3": "The score rewards underserved areas. A city with high court density relative to population has a small supply deficit — the biggest component (40 of 100 points). Well-served cities can still score moderately if other factors (catchment, economics) are strong.",
|
||||
"pnscore_faq_a3": "The score rewards underserved areas. A city with high court density relative to population has a small supply deficit — the most heavily weighted factor. Well-served cities can still score moderately if other factors (catchment, economics) are strong.",
|
||||
"pnscore_faq_q4": "How does market maturity affect the score?",
|
||||
"pnscore_faq_a4": "Market maturity is captured in two ways: the supply deficit component directly measures local court density, and the market headroom component inversely weighs country-level maturity. Countries where padel is already dominant (like Spain) provide less headroom than emerging markets.",
|
||||
"pnscore_faq_a4": "Market maturity is captured in two ways: the supply deficit component directly measures local court density, and the market headroom component weighs country-level maturity. Countries where padel is already dominant (like Spain) provide less headroom than emerging markets.",
|
||||
"pnscore_faq_q5": "Can I use the score for my business plan?",
|
||||
"pnscore_faq_a5": "Yes — the score is designed as a screening tool for site selection. Use it to shortlist promising locations, then dive deeper with the financial planner to model revenue, costs, and returns for your specific scenario.",
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Drop UNIQUE constraint from articles.slug column.
|
||||
|
||||
The single-column UNIQUE on slug conflicts with the ON CONFLICT(url_path, language)
|
||||
upsert in pSEO generation, causing 'UNIQUE constraint failed: articles.slug' errors
|
||||
when re-running generation for the same template.
|
||||
|
||||
The slug is already unique by construction ({template_slug}-{lang}-{natural_key}),
|
||||
and the real uniqueness key is (url_path, language). The idx_articles_slug index
|
||||
is kept for fast lookups.
|
||||
"""
|
||||
|
||||
|
||||
def up(conn) -> None:
|
||||
# ── 1. Drop FTS triggers + virtual table ──────────────────────────────────
|
||||
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
|
||||
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
|
||||
conn.execute("DROP TRIGGER IF EXISTS articles_au")
|
||||
conn.execute("DROP TABLE IF EXISTS articles_fts")
|
||||
|
||||
# ── 2. Recreate articles without UNIQUE on slug ───────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE articles_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url_path TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
meta_description TEXT,
|
||||
country TEXT,
|
||||
region TEXT,
|
||||
og_image_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TEXT,
|
||||
template_slug TEXT,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
date_modified TEXT,
|
||||
seo_head TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT,
|
||||
group_key TEXT DEFAULT NULL,
|
||||
noindex INTEGER NOT NULL DEFAULT 0,
|
||||
article_type TEXT NOT NULL DEFAULT 'editorial',
|
||||
UNIQUE(url_path, language)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO articles_new
|
||||
(id, url_path, slug, title, meta_description, country, region,
|
||||
og_image_url, status, published_at, template_slug, language,
|
||||
date_modified, seo_head, created_at, updated_at, group_key,
|
||||
noindex, article_type)
|
||||
SELECT id, url_path, slug, title, meta_description, country, region,
|
||||
og_image_url, status, published_at, template_slug, language,
|
||||
date_modified, seo_head, created_at, updated_at, group_key,
|
||||
noindex, article_type
|
||||
FROM articles
|
||||
""")
|
||||
conn.execute("DROP TABLE articles")
|
||||
conn.execute("ALTER TABLE articles_new RENAME TO articles")
|
||||
|
||||
# ── 3. Recreate indexes ───────────────────────────────────────────────────
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_article_type ON articles(article_type)")
|
||||
|
||||
# ── 4. Recreate FTS + triggers ────────────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||
title, meta_description, country, region,
|
||||
content='articles', content_rowid='id'
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ t.export_title }}">
|
||||
<style>
|
||||
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
||||
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% block title %}Business Plan Details — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="Business Plan Details — {{ config.APP_NAME }}">
|
||||
<style>
|
||||
.bp-wrap { max-width: 680px; margin: 0 auto; padding: 3rem 0; }
|
||||
.bp-hero { margin-bottom: 2rem; }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ t.export_success_title }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">✓</div>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
{% block title %}{{ t.export_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ t.export_waitlist_title }} - {{ config.APP_NAME }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-md mx-auto mt-8 text-center">
|
||||
|
||||
@@ -9,6 +9,35 @@
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}?v={{ v }}">
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"name": "{{ t.planner_page_title }} - {{ config.APP_NAME }}",
|
||||
"description": "{{ t.planner_meta_desc }}",
|
||||
"url": "{{ config.BASE_URL }}/{{ lang }}/planner/",
|
||||
"inLanguage": "{{ lang }}",
|
||||
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||
{"@type": "ListItem", "position": 2, "name": "{{ t.nav_planner }}", "item": "{{ config.BASE_URL }}/{{ lang }}/planner/"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Padelnomics Padel Court Planner",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "Web",
|
||||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% macro slider(name, label, min, max, step, value, tip='') %}
|
||||
|
||||
@@ -7,7 +7,7 @@ from quart import Blueprint, abort, g, redirect, render_template, request, sessi
|
||||
|
||||
from ..analytics import fetch_analytics
|
||||
from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one
|
||||
from ..i18n import get_translations
|
||||
from ..i18n import get_country_name, get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"public",
|
||||
@@ -89,6 +89,9 @@ async def opportunity_map():
|
||||
WHERE city_slug IS NOT NULL
|
||||
ORDER BY country_name_en
|
||||
""")
|
||||
lang = g.get("lang", "en")
|
||||
for row in countries:
|
||||
row["country_name"] = get_country_name(row["country_name_en"], lang)
|
||||
user_cc = g.get("user_country", "")
|
||||
selected_slug = ""
|
||||
if user_cc:
|
||||
@@ -98,7 +101,7 @@ async def opportunity_map():
|
||||
break
|
||||
countries = sorted(
|
||||
countries,
|
||||
key=lambda c: (0 if c["country_code"] == user_cc else 1, c["country_name_en"]),
|
||||
key=lambda c: (0 if c["country_code"] == user_cc else 1, c["country_name"]),
|
||||
)
|
||||
return await render_template("opportunity_map.html", countries=countries, selected_slug=selected_slug)
|
||||
|
||||
|
||||
@@ -7,6 +7,28 @@
|
||||
<meta property="og:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="{{ t.features_meta_desc }}">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"name": "{{ t.features_title_prefix }} | {{ config.APP_NAME }}",
|
||||
"description": "{{ t.features_meta_desc }}",
|
||||
"url": "{{ config.BASE_URL }}/{{ lang }}/features",
|
||||
"inLanguage": "{{ lang }}",
|
||||
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||
{"@type": "ListItem", "position": 2, "name": "{{ t.features_title_prefix }}", "item": "{{ config.BASE_URL }}/{{ lang }}/features"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
hx-trigger="change">
|
||||
<option value="">— choose country —</option>
|
||||
{% for c in countries %}
|
||||
<option value="{{ c.country_slug }}" {% if c.country_slug == selected_slug %}selected{% endif %}>{{ c.country_name_en }}</option>
|
||||
<option value="{{ c.country_slug }}" {% if c.country_slug == selected_slug %}selected{% endif %}>{{ c.country_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -40,11 +40,9 @@
|
||||
|
||||
<div class="mt-4 text-sm text-slate" style="display:flex; flex-wrap:wrap; gap:0.5rem 1.5rem; align-items:center;">
|
||||
<span style="display:flex; align-items:center; gap:0.3rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#15803D;"></span>≥80
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0D9488;margin-left:4px;"></span>≥60
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;margin-left:4px;"></span>≥40
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EA580C;margin-left:4px;"></span>≥20
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;margin-left:4px;"></span><20
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#15803D;"></span>≥60
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;margin-left:4px;"></span>≥30
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;margin-left:4px;"></span><30
|
||||
</span>
|
||||
<span><strong>Size:</strong> population</span>
|
||||
</div>
|
||||
@@ -116,7 +114,7 @@ window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues
|
||||
var icon = PNMarkers.makeIcon({
|
||||
size: size,
|
||||
color: hex,
|
||||
pulse: score >= 75,
|
||||
pulse: score >= 60,
|
||||
});
|
||||
L.marker([loc.lat, loc.lon], { icon: icon })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
{% block title %}Datenschutzerklärung - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="Datenschutzerklärung für Padelnomics. DSGVO-konform. Erfahren Sie, wie wir Ihre Daten erheben, verwenden und schützen. Wir nutzen Umami (cookielose Analyse), Paddle (Zahlungen) und Resend (E-Mail). Wir verkaufen Ihre Daten niemals.">
|
||||
<meta name="description" content="Datenschutzerklärung für Padelnomics. DSGVO-konform. Erfahren Sie, wie wir Ihre Daten erheben, verwenden und schützen. Wir nutzen Umami (cookielose Analyse), Microsoft Clarity (Sitzungsaufzeichnungen, nur mit Einwilligung), Paddle (Zahlungen) und Resend (E-Mail). Wir verkaufen Ihre Daten niemals.">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-3xl mx-auto">
|
||||
<h1 class="text-2xl mb-1">Datenschutzerklärung</h1>
|
||||
<p class="text-sm text-slate mb-8">Stand: Februar 2026 — <a href="{{ url_for('public.privacy', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
|
||||
<p class="text-sm text-slate mb-8">Stand: März 2026 — <a href="{{ url_for('public.privacy', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
|
||||
|
||||
<div class="space-y-6 text-slate-dark leading-relaxed">
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<p class="mt-3"><strong>Automatisch erhobene Daten:</strong></p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Aggregierte, anonymisierte Seitenaufruf-Daten über Umami (keine IP-Speicherung, kein siteübergreifendes Tracking)</li>
|
||||
<li>Anonymisierte Interaktionsdaten (Klicks, Scrolltiefe, Sitzungsaufzeichnungen) über Microsoft Clarity — nur mit Ihrer Einwilligung</li>
|
||||
<li>Session-Cookie zur Aufrechterhaltung der Anmeldung</li>
|
||||
</ul>
|
||||
<p class="mt-3"><strong>Beim Checkout erhobene Daten (durch Paddle, unseren Zahlungsdienstleister):</strong></p>
|
||||
@@ -60,6 +61,7 @@
|
||||
<li><strong>Umami</strong> (selbst gehostet auf unserer eigenen Infrastruktur) — cookielose, datenschutzfreundliche Webanalyse. Keine Übermittlung personenbezogener Daten an Dritte.</li>
|
||||
<li><strong>Paddle</strong> (paddle.com, UK/USA) — Zahlungsabwicklung und Abonnementverwaltung. Paddle agiert als Merchant of Record. Siehe <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Paddle</a>.</li>
|
||||
<li><strong>Resend</strong> (resend.com, USA) — Versand transaktionaler E-Mails (Magic Links, Belege). Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Siehe <a href="https://resend.com/legal/privacy-policy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Resend</a>.</li>
|
||||
<li><strong>Microsoft Clarity</strong> (clarity.microsoft.com, USA) — Heatmaps und Sitzungsaufzeichnungen zur Verbesserung der Nutzererfahrung. Nur aktiv mit Ihrer Einwilligung (funktionale Cookies). Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC). Siehe <a href="https://privacy.microsoft.com/privacystatement" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Microsoft</a>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -87,6 +89,8 @@
|
||||
<p class="mt-3 font-semibold text-sm">Funktional (erfordert Einwilligung)</p>
|
||||
<ul class="list-disc pl-6 mt-1 space-y-1">
|
||||
<li><strong>ab_*</strong> — Weist Ihnen eine A/B-Testvariante zu, um unsere Website zu verbessern. Läuft nach 30 Tagen ab. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
|
||||
<li><strong>_clck</strong> — Microsoft Clarity Nutzerkennung. Gültig 12 Monate. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
|
||||
<li><strong>_clsk</strong> — Microsoft Clarity Sitzungskennung. Gültig bis zum Ende der Sitzung. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3 font-semibold text-sm">Zahlung (nur beim Checkout)</p>
|
||||
@@ -115,7 +119,7 @@
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg mb-2">8. Internationale Datenübermittlung</h2>
|
||||
<p>Resend verarbeitet Daten in den USA. Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Paddle unterliegt dem UK-DSGVO mit Angemessenheitsbeschluss. Umami läuft auf unserer eigenen EU-Infrastruktur — keine Daten verlassen die EU.</p>
|
||||
<p>Resend und Microsoft Clarity verarbeiten Daten in den USA. Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Paddle unterliegt dem UK-DSGVO mit Angemessenheitsbeschluss. Umami läuft auf unserer eigenen EU-Infrastruktur — keine Daten verlassen die EU.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="Privacy Policy for Padelnomics. GDPR compliant. Learn how we collect, use, and protect your data. We use Umami (cookieless analytics), Paddle (payments), and Resend (email). We never sell your personal information.">
|
||||
<meta name="description" content="Privacy Policy for Padelnomics. GDPR compliant. Learn how we collect, use, and protect your data. We use Umami (cookieless analytics), Microsoft Clarity (session recordings, with consent), Paddle (payments), and Resend (email). We never sell your personal information.">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-3xl mx-auto">
|
||||
<h1 class="text-2xl mb-1">Privacy Policy</h1>
|
||||
<p class="text-sm text-slate mb-8">Last updated: February 2026 — <a href="{{ url_for('public.privacy', lang='de') }}" style="text-decoration:underline">Datenschutzerklärung auf Deutsch</a></p>
|
||||
<p class="text-sm text-slate mb-8">Last updated: March 2026 — <a href="{{ url_for('public.privacy', lang='de') }}" style="text-decoration:underline">Datenschutzerklärung auf Deutsch</a></p>
|
||||
|
||||
<div class="space-y-6 text-slate-dark leading-relaxed">
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<p class="mt-3"><strong>Data collected automatically:</strong></p>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Aggregated, anonymised page-view data via Umami (no IP address stored, no cross-site tracking)</li>
|
||||
<li>Anonymised interaction data (clicks, scroll depth, session recordings) via Microsoft Clarity — only with your consent</li>
|
||||
<li>Session cookie to keep you signed in</li>
|
||||
</ul>
|
||||
<p class="mt-3"><strong>Data collected at checkout (by Paddle, our payment processor):</strong></p>
|
||||
@@ -60,6 +61,7 @@
|
||||
<li><strong>Umami</strong> (self-hosted on our own infrastructure) — cookieless, privacy-first analytics. No personal data transferred to third parties.</li>
|
||||
<li><strong>Paddle</strong> (paddle.com, UK/USA) — payment processing and subscription management. Paddle acts as merchant of record. See <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Paddle's Privacy Policy</a>.</li>
|
||||
<li><strong>Resend</strong> (resend.com, USA) — transactional email delivery (magic links, receipts). Data is transferred under Standard Contractual Clauses. See <a href="https://resend.com/legal/privacy-policy" target="_blank" rel="noopener" style="text-decoration:underline">Resend's Privacy Policy</a>.</li>
|
||||
<li><strong>Microsoft Clarity</strong> (clarity.microsoft.com, USA) — heatmaps and session recordings for UX improvement. Only active with your consent (functional cookies). Data is transferred under Standard Contractual Clauses. See <a href="https://privacy.microsoft.com/privacystatement" target="_blank" rel="noopener" style="text-decoration:underline">Microsoft Privacy Statement</a>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -87,6 +89,8 @@
|
||||
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
|
||||
<ul class="list-disc pl-6 mt-1 space-y-1">
|
||||
<li><strong>ab_*</strong> — Assigns you to an A/B test variant to help us improve the site. Expires after 30 days. Only set if you accept functional cookies.</li>
|
||||
<li><strong>_clck</strong> — Microsoft Clarity user identifier. Expires after 12 months. Only set if you accept functional cookies.</li>
|
||||
<li><strong>_clsk</strong> — Microsoft Clarity session identifier. Expires at end of session. Only set if you accept functional cookies.</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
|
||||
@@ -115,7 +119,7 @@
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg mb-2">8. International Transfers</h2>
|
||||
<p>Resend processes data in the USA. Transfers are protected by Standard Contractual Clauses (SCCs) approved by the European Commission. Paddle operates under UK GDPR with an adequacy finding. Umami runs on our own EU-based infrastructure — no data leaves the EU.</p>
|
||||
<p>Resend and Microsoft Clarity process data in the USA. Transfers are protected by Standard Contractual Clauses (SCCs) approved by the European Commission. Paddle operates under UK GDPR with an adequacy finding. Umami runs on our own EU-based infrastructure — no data leaves the EU.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
60
web/src/padelnomics/seo/_indexnow.py
Normal file
60
web/src/padelnomics/seo/_indexnow.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""IndexNow push notifications — instant URL submission to Bing, Yandex, Seznam, Naver.
|
||||
|
||||
Fire-and-forget: logs errors but never raises. Content publishing must not fail
|
||||
because IndexNow is down.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INDEXNOW_ENDPOINT = "https://api.indexnow.org/IndexNow"
|
||||
_BATCH_LIMIT = 10_000 # IndexNow max URLs per request
|
||||
|
||||
|
||||
async def notify_urls(
|
||||
urls: list[str], host: str, key: str, timeout_seconds: int = 10
|
||||
) -> bool:
|
||||
"""POST changed URLs to IndexNow. Returns True on success, False on failure.
|
||||
|
||||
Skips silently if key is empty (dev environments).
|
||||
Batches into chunks of 10,000 URLs per IndexNow spec.
|
||||
"""
|
||||
assert 1 <= timeout_seconds <= 60, "timeout_seconds must be 1-60"
|
||||
|
||||
if not key:
|
||||
return False
|
||||
if not urls:
|
||||
return True
|
||||
|
||||
key_location = f"https://{host}/{key}.txt"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
for offset in range(0, len(urls), _BATCH_LIMIT):
|
||||
batch = urls[offset : offset + _BATCH_LIMIT]
|
||||
response = await client.post(
|
||||
_INDEXNOW_ENDPOINT,
|
||||
json={
|
||||
"host": host,
|
||||
"key": key,
|
||||
"keyLocation": key_location,
|
||||
"urlList": batch,
|
||||
},
|
||||
)
|
||||
# IndexNow returns 200 or 202 on success
|
||||
if response.status_code not in (200, 202):
|
||||
logger.warning(
|
||||
"IndexNow returned %d for %d URLs: %s",
|
||||
response.status_code,
|
||||
len(batch),
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
logger.info("IndexNow accepted %d URLs", len(batch))
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("IndexNow notification failed", exc_info=True)
|
||||
return False
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Sitemap generation with in-memory TTL cache and hreflang alternates."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from quart import Response
|
||||
|
||||
from .core import fetch_all
|
||||
from .core import config, fetch_all
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Process-local cache — valid for the current single-Hypercorn-worker deployment
|
||||
# (Dockerfile: `--workers 1`). If worker count increases, replace with a
|
||||
@@ -26,9 +30,10 @@ STATIC_PATHS = [
|
||||
"/imprint",
|
||||
"/suppliers",
|
||||
"/markets",
|
||||
"/market-score",
|
||||
"/padelnomics-score",
|
||||
"/planner/",
|
||||
"/directory/",
|
||||
"/opportunity-map",
|
||||
]
|
||||
|
||||
|
||||
@@ -65,16 +70,16 @@ async def _generate_sitemap_xml(base_url: str) -> str:
|
||||
for lang in LANGS:
|
||||
entries.append(_url_entry(f"{base}/{lang}{path}", alternates))
|
||||
|
||||
# Billing pricing — no lang prefix, no hreflang
|
||||
entries.append(_url_entry(f"{base}/billing/pricing", []))
|
||||
|
||||
# Published articles — both lang variants with accurate lastmod.
|
||||
# Exclude noindex articles (thin data) to keep sitemap signal-dense.
|
||||
# GROUP BY url_path: articles table has one row per language per url_path,
|
||||
# but the for-lang loop already creates both lang entries per path.
|
||||
articles = await fetch_all(
|
||||
"""SELECT url_path, COALESCE(updated_at, published_at) AS lastmod
|
||||
"""SELECT url_path, MAX(COALESCE(updated_at, published_at)) AS lastmod
|
||||
FROM articles
|
||||
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now')
|
||||
ORDER BY published_at DESC
|
||||
GROUP BY url_path
|
||||
ORDER BY MAX(published_at) DESC
|
||||
LIMIT 25000"""
|
||||
)
|
||||
for article in articles:
|
||||
@@ -127,3 +132,22 @@ async def sitemap_response(base_url: str) -> Response:
|
||||
content_type="application/xml",
|
||||
headers={"Cache-Control": f"public, max-age={CACHE_TTL_SECONDS}"},
|
||||
)
|
||||
|
||||
|
||||
async def notify_indexnow(paths: list[str]) -> None:
|
||||
"""Ping IndexNow with full URLs for the given paths (both lang variants).
|
||||
|
||||
Paths should be lang-agnostic (e.g. "/blog/my-article"). Each path gets
|
||||
expanded to all supported language variants.
|
||||
"""
|
||||
if not config.INDEXNOW_KEY or not paths:
|
||||
return
|
||||
base = config.BASE_URL.rstrip("/")
|
||||
urls = []
|
||||
for path in paths:
|
||||
for lang in LANGS:
|
||||
urls.append(f"{base}/{lang}{path}")
|
||||
host = urlparse(config.BASE_URL).hostname or "padelnomics.io"
|
||||
from .seo._indexnow import notify_urls
|
||||
|
||||
await notify_urls(urls, host=host, key=config.INDEXNOW_KEY)
|
||||
|
||||
1
web/src/padelnomics/static/js/Z.js
Normal file
1
web/src/padelnomics/static/js/Z.js
Normal file
@@ -0,0 +1 @@
|
||||
!function(){"use strict";(t=>{const{screen:{width:e,height:a},navigator:{language:r,doNotTrack:n,msDoNotTrack:i},location:o,document:c,history:s,top:u,doNotTrack:d}=t,{currentScript:l,referrer:f}=c;if(!l)return;const{hostname:h,href:m,origin:p}=o,y=m.startsWith("data:")?void 0:t.localStorage,g="data-",b="true",$=l.getAttribute.bind(l),v=$(`${g}website-id`),w=$(`${g}host-url`),S=$(`${g}before-send`),k=$(`${g}tag`)||void 0,N="false"!==$(`${g}auto-track`),T=$(`${g}do-not-track`)===b,A=$(`${g}exclude-search`)===b,j=$(`${g}exclude-hash`)===b,x=$(`${g}domains`)||"",L=$(`${g}fetch-credentials`)||"omit",E=x.split(",").map(t=>t.trim()),K=`${(w||""||l.src.split("/").slice(0,-1).join("/")).replace(/\/$/,"")}/api/Z`,O=`${e}x${a}`,U=/data-umami-event-([\w-_]+)/,_=`${g}umami-event`,D=300,P=t=>{if(!t)return t;try{const e=new URL(t,o.href);return A&&(e.search=""),j&&(e.hash=""),e.toString()}catch{return t}},R=()=>({website:v,screen:O,language:r,title:c.title,hostname:h,url:G,referrer:H,tag:k,id:F||void 0}),W=(t,e,a)=>{a&&(H=G,G=P(new URL(a,o.href).toString()),G!==H&&setTimeout(J,D))},B=()=>Q||!v||y?.getItem("umami.disabled")||x&&!E.includes(h)||T&&(()=>{const t=d||n||i;return 1===t||"1"===t||"yes"===t})(),C=async(e,a="event")=>{if(B())return;const r=t[S];if("function"==typeof r&&(e=await Promise.resolve(r(a,e))),e)try{const t=await fetch(K,{keepalive:!0,method:"POST",body:JSON.stringify({type:a,payload:e}),headers:{"Content-Type":"application/json",...void 0!==z&&{"x-umami-cache":z}},credentials:L}),r=await t.json();r&&(Q=!!r.disabled,z=r.cache)}catch(t){}},I=()=>{M||(M=!0,J(),(()=>{const t=(t,e,a)=>{const r=t[e];return(...e)=>(a.apply(null,e),r.apply(t,e))};s.pushState=t(s,"pushState",W),s.replaceState=t(s,"replaceState",W)})(),(()=>{const t=async t=>{const e=t.getAttribute(_);if(e){const a={};return t.getAttributeNames().forEach(e=>{const r=e.match(U);r&&(a[r[1]]=t.getAttribute(e))}),J(e,a)}};c.addEventListener("click",async e=>{const a=e.target,r=a.closest("a,button");if(!r)return t(a);const{href:n,target:i}=r;if(r.getAttribute(_)){if("BUTTON"===r.tagName)return t(r);if("A"===r.tagName&&n){const a="_blank"===i||e.ctrlKey||e.shiftKey||e.metaKey||e.button&&1===e.button;return a||e.preventDefault(),t(r).then(()=>{a||(("_top"===i?u.location:o).href=n)})}}},!0)})())},J=(t,e)=>C("string"==typeof t?{...R(),name:t,data:e}:"object"==typeof t?{...t}:"function"==typeof t?t(R()):R()),q=(t,e)=>("string"==typeof t&&(F=t),z="",C({...R(),data:"object"==typeof t?t:e},"identify"));t.umami||(t.umami={track:J,identify:q});let z,F,G=P(m),H=P(f.startsWith(p)?"":f),M=!1,Q=!1;N&&!B()&&("complete"===c.readyState?I():c.addEventListener("readystatechange",I,!0))})(window)}();
|
||||
@@ -151,6 +151,14 @@
|
||||
function dismiss(value) {
|
||||
document.cookie = COOKIE_NAME + '=' + value
|
||||
+ ';path=/;max-age=' + MAX_AGE + ';SameSite=Lax';
|
||||
// Bootstrap Clarity immediately on functional consent (no reload needed)
|
||||
if (value.indexOf('functional') !== -1 && window.clarity === undefined) {
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "{{ config.CLARITY_PROJECT_ID }}");
|
||||
}
|
||||
banner.classList.remove('cb-enter');
|
||||
banner.classList.add('cb-exit');
|
||||
setTimeout(function () { banner.style.display = 'none'; }, 280);
|
||||
|
||||
@@ -15,8 +15,22 @@
|
||||
<!-- Tailwind (compiled) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}?v={{ v }}">
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
|
||||
<!-- Umami Analytics (self-hosted script; re-download from umami.padelnomics.io/Z.js on Umami upgrade) -->
|
||||
<script defer src="{{ url_for('static', filename='js/Z.js') }}?v={{ v }}" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70" data-host-url="https://umami.padelnomics.io"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
|
||||
|
||||
<!-- Microsoft Clarity (consent-gated) -->
|
||||
{% if config.CLARITY_PROJECT_ID %}
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
if (!/cookie_consent=[^;]*functional/.test(document.cookie)) return;
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "{{ config.CLARITY_PROJECT_ID }}");
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Paddle.js (only on checkout pages via block override) -->
|
||||
{% block paddle %}{% endblock %}
|
||||
@@ -29,15 +43,16 @@
|
||||
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}">
|
||||
<link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
|
||||
{% endif %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
||||
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
||||
{% block head %}
|
||||
<meta property="og:title" content="{{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
||||
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
||||
{% block head %}{% endblock %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav-bar" id="main-nav">
|
||||
|
||||
@@ -36,7 +36,9 @@ _IDENTICAL_VALUE_ALLOWLIST = {
|
||||
"nav_admin", "nav_dashboard", "nav_feedback",
|
||||
# Country names that are the same
|
||||
"country_uk", "country_us",
|
||||
"dir_country_CN", "dir_country_PT", "dir_country_TH",
|
||||
"dir_country_CN", "dir_country_CR", "dir_country_MT",
|
||||
"dir_country_PA", "dir_country_PE", "dir_country_PT",
|
||||
"dir_country_SV", "dir_country_TH", "dir_country_VE",
|
||||
# Optional annotation
|
||||
"q9_company_note",
|
||||
# Dashboard chrome that stays English in DE (brand term)
|
||||
|
||||
Reference in New Issue
Block a user