Compare commits
27 Commits
worktree-s
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e71be6da5a | ||
|
|
5df369ef89 | ||
|
|
988daec452 | ||
|
|
e5f9dbacad | ||
|
|
f7d10f39cb | ||
|
|
b8cd58bf8a | ||
|
|
61b072bf7c | ||
|
|
64b82dbd4a | ||
|
|
2d3a53a736 | ||
|
|
bed07974cb | ||
|
|
4e81987741 | ||
|
|
8cc1cef780 | ||
|
|
236f0d1061 | ||
|
|
301f3b76c3 | ||
|
|
018eacb0f3 | ||
|
|
abacaac3f5 | ||
|
|
241e0de78e | ||
|
|
fc21c25c82 | ||
|
|
bd7fa1ae9a | ||
|
|
511a0ebac7 | ||
|
|
97ba13c42a | ||
|
|
1bd5bae90d | ||
|
|
608f16f578 | ||
|
|
927f77ae5e | ||
|
|
9dc705970e | ||
|
|
487722c2f3 | ||
|
|
23c7570736 |
@@ -74,22 +74,28 @@ DUCKDB_PATH=local.duckdb SERVING_DUCKDB_PATH=analytics.duckdb \
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production queries
|
## Production operations
|
||||||
|
|
||||||
Use `scripts/prod_query.py` to query the production DuckDB over SSH. **Always prefer this over raw SSH commands** — it handles escaping, enforces read-only, and blocks mutation keywords.
|
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
|
```bash
|
||||||
# Query analytics.duckdb (serving tables — default)
|
# Query analytics.duckdb (serving tables — default)
|
||||||
uv run python scripts/prod_query.py "SELECT COUNT(*) FROM serving.location_profiles"
|
uv run python scripts/prod.py query "SELECT COUNT(*) FROM serving.location_profiles"
|
||||||
|
|
||||||
# Query lakehouse.duckdb (foundation/staging tables)
|
# Query lakehouse.duckdb (foundation/staging tables)
|
||||||
uv run python scripts/prod_query.py --db lakehouse "SELECT * FROM foundation.dim_countries LIMIT 5"
|
uv run python scripts/prod.py query --db lakehouse "SELECT * FROM foundation.dim_countries LIMIT 5"
|
||||||
|
|
||||||
# JSON output
|
# JSON output
|
||||||
uv run python scripts/prod_query.py --json "SELECT COUNT(*) FROM serving.location_profiles"
|
uv run python scripts/prod.py query --json "SELECT COUNT(*) FROM serving.location_profiles"
|
||||||
|
|
||||||
# Limit rows (default 500)
|
# Pipeline operations
|
||||||
uv run python scripts/prod_query.py --max-rows 1000 "SELECT ..."
|
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
|
## Architecture documentation
|
||||||
|
|||||||
@@ -74,12 +74,13 @@ GSC_SERVICE_ACCOUNT_PATH=
|
|||||||
GSC_SITE_URL=
|
GSC_SITE_URL=
|
||||||
BING_WEBMASTER_API_KEY=
|
BING_WEBMASTER_API_KEY=
|
||||||
BING_SITE_URL=
|
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]
|
#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]
|
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]
|
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
|
||||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_lastmodified=2026-03-03T15:16:35Z
|
sops_lastmodified=2026-03-10T15:07:09Z
|
||||||
sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
|
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_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
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]
|
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_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]
|
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]
|
#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]
|
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]
|
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_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||||
sops_lastmodified=2026-03-05T15:55:19Z
|
sops_lastmodified=2026-03-10T15:05:54Z
|
||||||
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_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_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -9,13 +9,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
### Fixed
|
### 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.
|
- **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)`).
|
- **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
|
### 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 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).
|
- **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).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Non-Latin city names on map** — GeoNames entries with CJK/Cyrillic/Arabic characters (e.g. "Seelow" showing Japanese) now filtered in `stg_population_geonames` via Latin-only regex.
|
- **Non-Latin city names on map** — GeoNames entries with CJK/Cyrillic/Arabic characters (e.g. "Seelow" showing Japanese) now filtered in `stg_population_geonames` via Latin-only regex.
|
||||||
|
- **GeoNames regex DuckDB compatibility** — replaced Python-style `\u00C0` Unicode escapes in `stg_population_geonames` regex with literal Unicode characters (`À-ɏḀ-ỿ`) for DuckDB compatibility.
|
||||||
- **Score range safety** — `location_profiles` clamps both scores to 0-100 via `LEAST/GREATEST`.
|
- **Score range safety** — `location_profiles` clamps both scores to 0-100 via `LEAST/GREATEST`.
|
||||||
- **Pipeline cast fix** — `venue_pricing_benchmarks.sql` defensively casts `snapshot_date` VARCHAR to DATE.
|
- **Pipeline cast fix** — `venue_pricing_benchmarks.sql` defensively casts `snapshot_date` VARCHAR to DATE.
|
||||||
|
|
||||||
|
|||||||
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}")
|
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:
|
def current_deployed_tag() -> str | None:
|
||||||
"""Return the highest-version tag pointing at HEAD, or 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")
|
run_shell("uv sync --all-packages")
|
||||||
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
|
# 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")
|
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;
|
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
|
||||||
# systemd sees it as the same PID and does not restart the unit.
|
# systemd sees it as the same PID and does not restart the unit.
|
||||||
logger.info("Deploy complete — re-execing to load new code")
|
logger.info("Deploy complete — re-execing to load new code")
|
||||||
@@ -408,14 +375,6 @@ def tick() -> None:
|
|||||||
# Export serving tables
|
# Export serving tables
|
||||||
run_export()
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,28 @@ SELECT
|
|||||||
WHEN 'AE' THEN 'UAE'
|
WHEN 'AE' THEN 'UAE'
|
||||||
WHEN 'AU' THEN 'Australia'
|
WHEN 'AU' THEN 'Australia'
|
||||||
WHEN 'IE' THEN 'Ireland'
|
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
|
ELSE ac.country_code
|
||||||
END AS country_name_en,
|
END AS country_name_en,
|
||||||
LOWER(REGEXP_REPLACE(
|
LOWER(REGEXP_REPLACE(
|
||||||
@@ -172,6 +194,28 @@ SELECT
|
|||||||
WHEN 'AE' THEN 'UAE'
|
WHEN 'AE' THEN 'UAE'
|
||||||
WHEN 'AU' THEN 'Australia'
|
WHEN 'AU' THEN 'Australia'
|
||||||
WHEN 'IE' THEN 'Ireland'
|
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
|
ELSE ac.country_code
|
||||||
END, '[^a-zA-Z0-9]+', '-'
|
END, '[^a-zA-Z0-9]+', '-'
|
||||||
)) AS country_slug,
|
)) AS country_slug,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ all_jsonl AS (
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
slots AS slots_json
|
slots AS slots_json
|
||||||
FROM read_json(
|
FROM read_json(
|
||||||
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
|
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
|
||||||
format = 'newline_delimited',
|
format = 'newline_delimited',
|
||||||
columns = {
|
columns = {
|
||||||
date: 'VARCHAR',
|
date: 'VARCHAR',
|
||||||
@@ -46,6 +46,7 @@ all_jsonl AS (
|
|||||||
filename = true
|
filename = true
|
||||||
)
|
)
|
||||||
WHERE tenant_id IS NOT NULL
|
WHERE tenant_id IS NOT NULL
|
||||||
|
AND CAST(date AS DATE) BETWEEN @start_ds AND @end_ds
|
||||||
),
|
),
|
||||||
raw_resources AS (
|
raw_resources AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ WHERE geoname_id IS NOT NULL
|
|||||||
AND lon IS NOT NULL
|
AND lon IS NOT NULL
|
||||||
-- Reject names with non-Latin characters (CJK, Cyrillic, Arabic, Thai, etc.)
|
-- Reject names with non-Latin characters (CJK, Cyrillic, Arabic, Thai, etc.)
|
||||||
-- Allows ASCII + Latin Extended (diacritics: ÄÖÜ, àéî, ñ, ø, etc.)
|
-- Allows ASCII + Latin Extended (diacritics: ÄÖÜ, àéî, ñ, ø, etc.)
|
||||||
AND regexp_matches(city_name, '^[\x20-\x7E\u00C0-\u024F\u1E00-\u1EFF]+$')
|
AND regexp_matches(city_name, '^[\x20-\x7EÀ-ɏḀ-ỿ]+$')
|
||||||
|
|||||||
@@ -1105,6 +1105,10 @@ async def supplier_new():
|
|||||||
category, tier, contact_name, contact_email, contact_role,
|
category, tier, contact_name, contact_email, contact_role,
|
||||||
services_offered, linkedin_url, instagram_url, youtube_url, now),
|
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")
|
await flash(f"Supplier '{name}' created.", "success")
|
||||||
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
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,
|
(slug, title, url_path, language, meta_description,
|
||||||
status, template_slug, group_key, article_type, created_at, updated_at)
|
status, template_slug, group_key, article_type, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(slug) DO UPDATE SET
|
ON CONFLICT(url_path, language) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
url_path = excluded.url_path,
|
slug = excluded.slug,
|
||||||
language = excluded.language,
|
|
||||||
meta_description = excluded.meta_description,
|
meta_description = excluded.meta_description,
|
||||||
template_slug = excluded.template_slug,
|
template_slug = excluded.template_slug,
|
||||||
group_key = excluded.group_key,
|
group_key = excluded.group_key,
|
||||||
@@ -2625,20 +2628,28 @@ async def articles_bulk():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if action == "publish":
|
if action == "publish":
|
||||||
|
affected = await fetch_all(
|
||||||
|
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
|
||||||
|
)
|
||||||
await execute(
|
await execute(
|
||||||
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
|
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
|
||||||
(now, *where_params),
|
(now, *where_params),
|
||||||
)
|
)
|
||||||
from ..sitemap import invalidate_sitemap_cache
|
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||||
invalidate_sitemap_cache()
|
invalidate_sitemap_cache()
|
||||||
|
await notify_indexnow([r["url_path"] for r in affected])
|
||||||
|
|
||||||
elif action == "unpublish":
|
elif action == "unpublish":
|
||||||
|
affected = await fetch_all(
|
||||||
|
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
|
||||||
|
)
|
||||||
await execute(
|
await execute(
|
||||||
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
|
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
|
||||||
(now, *where_params),
|
(now, *where_params),
|
||||||
)
|
)
|
||||||
from ..sitemap import invalidate_sitemap_cache
|
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||||
invalidate_sitemap_cache()
|
invalidate_sitemap_cache()
|
||||||
|
await notify_indexnow([r["url_path"] for r in affected])
|
||||||
|
|
||||||
elif action == "toggle_noindex":
|
elif action == "toggle_noindex":
|
||||||
await execute(
|
await execute(
|
||||||
@@ -2685,16 +2696,26 @@ async def articles_bulk():
|
|||||||
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
|
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
|
||||||
(now, *article_ids),
|
(now, *article_ids),
|
||||||
)
|
)
|
||||||
from ..sitemap import invalidate_sitemap_cache
|
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||||
invalidate_sitemap_cache()
|
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":
|
elif action == "unpublish":
|
||||||
await execute(
|
await execute(
|
||||||
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
|
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
|
||||||
(now, *article_ids),
|
(now, *article_ids),
|
||||||
)
|
)
|
||||||
from ..sitemap import invalidate_sitemap_cache
|
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||||
invalidate_sitemap_cache()
|
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":
|
elif action == "toggle_noindex":
|
||||||
await execute(
|
await execute(
|
||||||
@@ -2808,8 +2829,10 @@ async def article_new():
|
|||||||
(url_path, article_slug, title, meta_description, og_image_url,
|
(url_path, article_slug, title, meta_description, og_image_url,
|
||||||
country, region, language, status, pub_dt, seo_head, article_type),
|
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()
|
invalidate_sitemap_cache()
|
||||||
|
if status == "published":
|
||||||
|
await notify_indexnow([url_path])
|
||||||
|
|
||||||
await flash(f"Article '{title}' created.", "success")
|
await flash(f"Article '{title}' created.", "success")
|
||||||
return redirect(url_for("admin.articles"))
|
return redirect(url_for("admin.articles"))
|
||||||
@@ -2881,6 +2904,9 @@ async def article_edit(article_id: int):
|
|||||||
(title, url_path, meta_description, og_image_url,
|
(title, url_path, meta_description, og_image_url,
|
||||||
country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
|
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")
|
await flash("Article updated.", "success")
|
||||||
return redirect(url_for("admin.articles"))
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
@@ -2976,8 +3002,11 @@ async def article_publish(article_id: int):
|
|||||||
(new_status, now, article_id),
|
(new_status, now, article_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..sitemap import invalidate_sitemap_cache
|
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
|
||||||
invalidate_sitemap_cache()
|
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"):
|
if request.headers.get("HX-Request"):
|
||||||
updated = await fetch_one(
|
updated = await fetch_one(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async def countries():
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/markets/<country_slug>/cities.json")
|
@bp.route("/markets/<country_slug>/cities.json")
|
||||||
|
@login_required
|
||||||
async def country_cities(country_slug: str):
|
async def country_cities(country_slug: str):
|
||||||
"""City-level data for a country overview bubble map."""
|
"""City-level data for a country overview bubble map."""
|
||||||
await _require_maps_flag()
|
await _require_maps_flag()
|
||||||
|
|||||||
@@ -284,6 +284,12 @@ def create_app() -> Quart:
|
|||||||
from .sitemap import sitemap_response
|
from .sitemap import sitemap_response
|
||||||
return await sitemap_response(config.BASE_URL)
|
return await sitemap_response(config.BASE_URL)
|
||||||
|
|
||||||
|
# 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
|
# Error pages
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -401,7 +407,7 @@ def create_app() -> Quart:
|
|||||||
|
|
||||||
@app.route("/market-score")
|
@app.route("/market-score")
|
||||||
async def legacy_market_score():
|
async def legacy_market_score():
|
||||||
return redirect("/en/market-score", 301)
|
return redirect("/en/padelnomics-score", 301)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Blueprint registration
|
# Blueprint registration
|
||||||
|
|||||||
@@ -208,13 +208,14 @@ async def markets():
|
|||||||
SELECT country_code, country_name_en, country_slug,
|
SELECT country_code, country_name_en, country_slug,
|
||||||
city_count, total_venues,
|
city_count, total_venues,
|
||||||
avg_market_score, avg_opportunity_score,
|
avg_market_score, avg_opportunity_score,
|
||||||
|
top_opportunity_score,
|
||||||
lat, lon
|
lat, lon
|
||||||
FROM serving.pseo_country_overview
|
FROM serving.pseo_country_overview
|
||||||
ORDER BY total_venues DESC
|
ORDER BY total_venues DESC
|
||||||
""")
|
""")
|
||||||
lang = g.get("lang", "en")
|
lang = g.get("lang", "en")
|
||||||
for row in map_countries:
|
for c in map_countries:
|
||||||
row["country_name"] = get_country_name(row["country_name_en"], lang)
|
c["country_name"] = get_country_name(c["country_code"], lang)
|
||||||
# Sort so user's country renders last (on top in Leaflet z-order)
|
# Sort so user's country renders last (on top in Leaflet z-order)
|
||||||
user_country = g.get("user_country", "")
|
user_country = g.get("user_country", "")
|
||||||
if user_country and map_countries:
|
if user_country and map_countries:
|
||||||
@@ -367,8 +368,42 @@ async def article_page(url_path: str):
|
|||||||
|
|
||||||
body_html = build_path.read_text()
|
body_html = build_path.read_text()
|
||||||
|
|
||||||
|
# Detect country-overview pages and inline map data (no JSON API fetch)
|
||||||
|
map_locations: list[dict] = []
|
||||||
|
parts = clean_path.strip("/").split("/")
|
||||||
|
if len(parts) == 2 and parts[0] == "markets":
|
||||||
|
country_slug = parts[1]
|
||||||
|
map_locations = await fetch_analytics(
|
||||||
|
"""
|
||||||
|
SELECT location_name AS city_name, city_slug, lat, lon,
|
||||||
|
COALESCE(city_padel_venue_count, 0) AS padel_venue_count,
|
||||||
|
market_score, opportunity_score, population,
|
||||||
|
nearest_padel_court_km
|
||||||
|
FROM serving.location_profiles
|
||||||
|
WHERE country_slug = ? AND opportunity_score > 0
|
||||||
|
ORDER BY opportunity_score DESC
|
||||||
|
LIMIT 300
|
||||||
|
""",
|
||||||
|
[country_slug],
|
||||||
|
)
|
||||||
|
article_rows = await fetch_all(
|
||||||
|
"""SELECT url_path FROM articles
|
||||||
|
WHERE url_path LIKE ? AND status = 'published'
|
||||||
|
AND published_at <= datetime('now')""",
|
||||||
|
(f"/markets/{country_slug}/%",),
|
||||||
|
)
|
||||||
|
article_slugs: set[str] = set()
|
||||||
|
for a in article_rows:
|
||||||
|
a_parts = a["url_path"].rstrip("/").split("/")
|
||||||
|
if len(a_parts) >= 4:
|
||||||
|
article_slugs.add(a_parts[3])
|
||||||
|
for row in map_locations:
|
||||||
|
slug = row.get("city_slug")
|
||||||
|
row["has_article"] = slug in article_slugs if slug else False
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"article_detail.html",
|
"article_detail.html",
|
||||||
article=article,
|
article=article,
|
||||||
body_html=Markup(body_html),
|
body_html=Markup(body_html),
|
||||||
|
map_locations=map_locations,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,8 +62,11 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
||||||
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",click_explore:"{{ t.map_click_explore }}",coming_soon:"{{ t.map_coming_soon }}",courts:"{{ t.map_courts }}",indoor:"{{ t.map_indoor }}",outdoor:"{{ t.map_outdoor }}"};
|
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",click_explore:"{{ t.map_click_explore }}",coming_soon:"{{ t.map_coming_soon }}",courts:"{{ t.map_courts }}",indoor:"{{ t.map_indoor }}",outdoor:"{{ t.map_outdoor }}",km_nearest:"{{ t.map_km_nearest }}",no_nearby:"{{ t.map_no_nearby }}"};
|
||||||
</script>
|
</script>
|
||||||
|
{% if map_locations %}
|
||||||
|
<script id="country-map-data" type="application/json">{{ map_locations | tojson }}</script>
|
||||||
|
{% endif %}
|
||||||
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
hx-include="#market-q, #market-region">
|
hx-include="#market-q, #market-region">
|
||||||
<option value="">{{ t.mkt_all_countries }}</option>
|
<option value="">{{ t.mkt_all_countries }}</option>
|
||||||
{% for c in countries %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
||||||
<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() {
|
(function() {
|
||||||
var sc = PNMarkers.scoreColor;
|
var sc = PNMarkers.scoreColor;
|
||||||
var T = window.__MAP_T;
|
var T = window.__MAP_T;
|
||||||
@@ -103,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 size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
|
||||||
var score = c.avg_opportunity_score || 0;
|
var score = c.avg_opportunity_score || 0;
|
||||||
var hex = sc(score);
|
var hex = sc(score);
|
||||||
|
var topScore = c.top_opportunity_score || 0;
|
||||||
|
var topHex = sc(topScore);
|
||||||
var tip = '<strong>' + c.country_name + '</strong><br>'
|
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="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>';
|
+ '<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 }) })
|
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)] })
|
.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", "")
|
GSC_SITE_URL: str = os.getenv("GSC_SITE_URL", "")
|
||||||
BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
|
BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
|
||||||
BING_SITE_URL: str = os.getenv("BING_SITE_URL", "")
|
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", "")
|
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
||||||
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
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 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: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) }}">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--dir-green: #15803D;
|
--dir-green: #15803D;
|
||||||
|
|||||||
@@ -49,6 +49,25 @@ COUNTRY_LABELS: dict[str, str] = {
|
|||||||
"AU": "Australia",
|
"AU": "Australia",
|
||||||
"ZA": "South Africa",
|
"ZA": "South Africa",
|
||||||
"EG": "Egypt",
|
"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"
|
_LOCALES_DIR = Path(__file__).parent / "locales"
|
||||||
|
|||||||
@@ -345,6 +345,25 @@
|
|||||||
"dir_country_AU": "Australien",
|
"dir_country_AU": "Australien",
|
||||||
"dir_country_ZA": "Südafrika",
|
"dir_country_ZA": "Südafrika",
|
||||||
"dir_country_EG": "Ägypten",
|
"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_back": "Zurück zum Verzeichnis",
|
||||||
"sp_verified": "Verifiziert ✓",
|
"sp_verified": "Verifiziert ✓",
|
||||||
"sp_request_quote": "Angebot anfragen →",
|
"sp_request_quote": "Angebot anfragen →",
|
||||||
@@ -620,6 +639,8 @@
|
|||||||
"map_existing_venues": "bestehende Anlagen",
|
"map_existing_venues": "bestehende Anlagen",
|
||||||
"map_km_nearest": "km zur nächsten Anlage",
|
"map_km_nearest": "km zur nächsten Anlage",
|
||||||
"map_no_nearby": "Keine Anlagen in der Nähe",
|
"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_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_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",
|
"waitlist_markets_feature1": "Echte Kostendaten aus laufenden Anlagen in über 30 Ländern",
|
||||||
|
|||||||
@@ -345,6 +345,25 @@
|
|||||||
"dir_country_AU": "Australia",
|
"dir_country_AU": "Australia",
|
||||||
"dir_country_ZA": "South Africa",
|
"dir_country_ZA": "South Africa",
|
||||||
"dir_country_EG": "Egypt",
|
"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_back": "Back to Directory",
|
||||||
"sp_verified": "Verified ✓",
|
"sp_verified": "Verified ✓",
|
||||||
"sp_request_quote": "Request Quote →",
|
"sp_request_quote": "Request Quote →",
|
||||||
@@ -620,6 +639,8 @@
|
|||||||
"map_existing_venues": "existing venues",
|
"map_existing_venues": "existing venues",
|
||||||
"map_km_nearest": "km to nearest court",
|
"map_km_nearest": "km to nearest court",
|
||||||
"map_no_nearby": "No nearby courts",
|
"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_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_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",
|
"waitlist_markets_feature1": "Real cost data from operating venues across 30+ countries",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ t.export_title }}">
|
||||||
<style>
|
<style>
|
||||||
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
||||||
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block title %}Business Plan Details — {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Business Plan Details — {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
<meta name="description" content="Business Plan Details — {{ config.APP_NAME }}">
|
||||||
<style>
|
<style>
|
||||||
.bp-wrap { max-width: 680px; margin: 0 auto; padding: 3rem 0; }
|
.bp-wrap { max-width: 680px; margin: 0 auto; padding: 3rem 0; }
|
||||||
.bp-hero { margin-bottom: 2rem; }
|
.bp-hero { margin-bottom: 2rem; }
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ t.export_success_title }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
<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>
|
<div style="font-size:3rem;margin-bottom:1rem">✓</div>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}{{ t.export_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-md mx-auto mt-8 text-center">
|
<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) }}">
|
<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 }}">
|
<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 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% macro slider(name, label, min, max, step, value, tip='') %}
|
{% macro slider(name, label, min, max, step, value, tip='') %}
|
||||||
|
|||||||
@@ -7,6 +7,28 @@
|
|||||||
<meta property="og:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
|
<meta property="og:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
|
||||||
<meta property="og:description" content="{{ t.features_meta_desc }}">
|
<meta property="og:description" content="{{ t.features_meta_desc }}">
|
||||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
<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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
{% block title %}Datenschutzerklärung - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Datenschutzerklärung - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-3xl mx-auto">
|
<div class="card max-w-3xl mx-auto">
|
||||||
<h1 class="text-2xl mb-1">Datenschutzerklärung</h1>
|
<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">
|
<div class="space-y-6 text-slate-dark leading-relaxed">
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<p class="mt-3"><strong>Automatisch erhobene Daten:</strong></p>
|
<p class="mt-3"><strong>Automatisch erhobene Daten:</strong></p>
|
||||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
<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>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>
|
<li>Session-Cookie zur Aufrechterhaltung der Anmeldung</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-3"><strong>Beim Checkout erhobene Daten (durch Paddle, unseren Zahlungsdienstleister):</strong></p>
|
<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>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>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>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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -87,6 +89,8 @@
|
|||||||
<p class="mt-3 font-semibold text-sm">Funktional (erfordert Einwilligung)</p>
|
<p class="mt-3 font-semibold text-sm">Funktional (erfordert Einwilligung)</p>
|
||||||
<ul class="list-disc pl-6 mt-1 space-y-1">
|
<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>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>
|
</ul>
|
||||||
|
|
||||||
<p class="mt-3 font-semibold text-sm">Zahlung (nur beim Checkout)</p>
|
<p class="mt-3 font-semibold text-sm">Zahlung (nur beim Checkout)</p>
|
||||||
@@ -115,7 +119,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg mb-2">8. Internationale Datenübermittlung</h2>
|
<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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-3xl mx-auto">
|
<div class="card max-w-3xl mx-auto">
|
||||||
<h1 class="text-2xl mb-1">Privacy Policy</h1>
|
<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">
|
<div class="space-y-6 text-slate-dark leading-relaxed">
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<p class="mt-3"><strong>Data collected automatically:</strong></p>
|
<p class="mt-3"><strong>Data collected automatically:</strong></p>
|
||||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
<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>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>
|
<li>Session cookie to keep you signed in</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-3"><strong>Data collected at checkout (by Paddle, our payment processor):</strong></p>
|
<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>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>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>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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -87,6 +89,8 @@
|
|||||||
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
|
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
|
||||||
<ul class="list-disc pl-6 mt-1 space-y-1">
|
<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>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>
|
</ul>
|
||||||
|
|
||||||
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
|
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
|
||||||
@@ -115,7 +119,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg mb-2">8. International Transfers</h2>
|
<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>
|
||||||
|
|
||||||
<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."""
|
"""Sitemap generation with in-memory TTL cache and hreflang alternates."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from quart import Response
|
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
|
# Process-local cache — valid for the current single-Hypercorn-worker deployment
|
||||||
# (Dockerfile: `--workers 1`). If worker count increases, replace with a
|
# (Dockerfile: `--workers 1`). If worker count increases, replace with a
|
||||||
@@ -26,9 +30,10 @@ STATIC_PATHS = [
|
|||||||
"/imprint",
|
"/imprint",
|
||||||
"/suppliers",
|
"/suppliers",
|
||||||
"/markets",
|
"/markets",
|
||||||
"/market-score",
|
"/padelnomics-score",
|
||||||
"/planner/",
|
"/planner/",
|
||||||
"/directory/",
|
"/directory/",
|
||||||
|
"/opportunity-map",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -65,16 +70,16 @@ async def _generate_sitemap_xml(base_url: str) -> str:
|
|||||||
for lang in LANGS:
|
for lang in LANGS:
|
||||||
entries.append(_url_entry(f"{base}/{lang}{path}", alternates))
|
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.
|
# Published articles — both lang variants with accurate lastmod.
|
||||||
# Exclude noindex articles (thin data) to keep sitemap signal-dense.
|
# 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(
|
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
|
FROM articles
|
||||||
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now')
|
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"""
|
LIMIT 25000"""
|
||||||
)
|
)
|
||||||
for article in articles:
|
for article in articles:
|
||||||
@@ -127,3 +132,22 @@ async def sitemap_response(base_url: str) -> Response:
|
|||||||
content_type="application/xml",
|
content_type="application/xml",
|
||||||
headers={"Cache-Control": f"public, max-age={CACHE_TTL_SECONDS}"},
|
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)}();
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* Leaflet map initialisation for article pages (country + city maps).
|
* Leaflet map initialisation for article pages (country + city maps).
|
||||||
*
|
*
|
||||||
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
||||||
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
|
* Country maps read inline JSON from #country-map-data (no network fetch).
|
||||||
* variable pointing to the Leaflet JS bundle.
|
* City maps still fetch venue data from the JSON API.
|
||||||
*
|
*
|
||||||
* Depends on map-markers.js (window.PNMarkers) being loaded first.
|
* Depends on map-markers.js (window.PNMarkers) being loaded first.
|
||||||
*/
|
*/
|
||||||
@@ -24,28 +24,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initCountryMap(el) {
|
function initCountryMap(el) {
|
||||||
|
var dataEl = document.getElementById('country-map-data');
|
||||||
|
if (!dataEl) return;
|
||||||
|
var data = JSON.parse(dataEl.textContent);
|
||||||
var slug = el.dataset.countrySlug;
|
var slug = el.dataset.countrySlug;
|
||||||
var map = L.map(el, {scrollWheelZoom: false});
|
var map = L.map(el, {scrollWheelZoom: false});
|
||||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||||
var lang = document.documentElement.lang || 'en';
|
var lang = document.documentElement.lang || 'en';
|
||||||
fetch('/api/markets/' + slug + '/cities.json')
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (!data.length) return;
|
if (!data.length) return;
|
||||||
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
|
var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; }));
|
||||||
var bounds = [];
|
var bounds = [];
|
||||||
data.forEach(function(c) {
|
data.forEach(function(c) {
|
||||||
if (!c.lat || !c.lon) return;
|
if (!c.lat || !c.lon) return;
|
||||||
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
var size = 8 + 36 * Math.sqrt((c.population || 1) / maxPop);
|
||||||
var hasArticle = c.has_article !== false;
|
var hasArticle = c.has_article === true;
|
||||||
var score = c.opportunity_score || 0;
|
var score = c.opportunity_score || 0;
|
||||||
var hex = sc(score);
|
var hex = sc(score);
|
||||||
var pop = fmtPop(c.population);
|
var pop = fmtPop(c.population);
|
||||||
|
var venueInfo = (c.padel_venue_count || 0) > 0
|
||||||
|
? (c.padel_venue_count + ' ' + (T.venues || 'venues'))
|
||||||
|
: (c.nearest_padel_court_km
|
||||||
|
? Math.round(c.nearest_padel_court_km) + ' ' + (T.km_nearest || 'km to nearest court')
|
||||||
|
: (T.no_nearby || 'No courts nearby'));
|
||||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
var tip = '<strong>' + c.city_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="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 || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
|
+ '<span style="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
|
||||||
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
|
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
|
||||||
+ (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues')
|
+ venueInfo
|
||||||
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
|
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
|
||||||
if (hasArticle) {
|
if (hasArticle) {
|
||||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
|
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
|
||||||
@@ -74,7 +80,7 @@
|
|||||||
return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase();
|
return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase();
|
||||||
});
|
});
|
||||||
if (match && match.lat && match.lon) {
|
if (match && match.lat && match.lon) {
|
||||||
var hSize = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV);
|
var hSize = 8 + 36 * Math.sqrt((match.population || 1) / maxPop);
|
||||||
var hIcon = PNMarkers.makeIcon({
|
var hIcon = PNMarkers.makeIcon({
|
||||||
size: hSize,
|
size: hSize,
|
||||||
color: sc(match.opportunity_score || 0),
|
color: sc(match.opportunity_score || 0),
|
||||||
@@ -83,8 +89,6 @@
|
|||||||
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
|
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCityMap(el, venueIcon) {
|
function initCityMap(el, venueIcon) {
|
||||||
|
|||||||
@@ -151,6 +151,14 @@
|
|||||||
function dismiss(value) {
|
function dismiss(value) {
|
||||||
document.cookie = COOKIE_NAME + '=' + value
|
document.cookie = COOKIE_NAME + '=' + value
|
||||||
+ ';path=/;max-age=' + MAX_AGE + ';SameSite=Lax';
|
+ ';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.remove('cb-enter');
|
||||||
banner.classList.add('cb-exit');
|
banner.classList.add('cb-exit');
|
||||||
setTimeout(function () { banner.style.display = 'none'; }, 280);
|
setTimeout(function () { banner.style.display = 'none'; }, 280);
|
||||||
|
|||||||
@@ -15,8 +15,22 @@
|
|||||||
<!-- Tailwind (compiled) -->
|
<!-- Tailwind (compiled) -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}?v={{ v }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}?v={{ v }}">
|
||||||
|
|
||||||
<!-- Umami Analytics -->
|
<!-- Umami Analytics (self-hosted script; re-download from umami.padelnomics.io/Z.js on Umami upgrade) -->
|
||||||
<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>
|
<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) -->
|
<!-- Paddle.js (only on checkout pages via block override) -->
|
||||||
{% block paddle %}{% endblock %}
|
{% block paddle %}{% endblock %}
|
||||||
@@ -29,15 +43,16 @@
|
|||||||
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}">
|
<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 }}">
|
<link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
|
||||||
{% endif %}
|
{% 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:title" content="{{ config.APP_NAME }}">
|
||||||
<meta property="og:description" content="">
|
<meta property="og:description" content="">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
|
<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 property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
{% endblock %}
|
||||||
|
|
||||||
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
|
||||||
{% block head %}{% endblock %}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav-bar" id="main-nav">
|
<nav class="nav-bar" id="main-nav">
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ _IDENTICAL_VALUE_ALLOWLIST = {
|
|||||||
"nav_admin", "nav_dashboard", "nav_feedback",
|
"nav_admin", "nav_dashboard", "nav_feedback",
|
||||||
# Country names that are the same
|
# Country names that are the same
|
||||||
"country_uk", "country_us",
|
"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
|
# Optional annotation
|
||||||
"q9_company_note",
|
"q9_company_note",
|
||||||
# Dashboard chrome that stays English in DE (brand term)
|
# Dashboard chrome that stays English in DE (brand term)
|
||||||
|
|||||||
Reference in New Issue
Block a user