feat: dual market score system — Marktreife + Marktpotenzial scores

Splits the single market score into two branded scores backed by a new
global data pipeline covering all GeoNames locations (pop ≥1K):

Data pipeline:
- GeoNames expanded: cities1000 (~140K locations) vs old cities15000
  (~24K). Added lat/lon/admin1/admin2. Feature codes include PPLA3/4/5.
- Tennis court Overpass extractor (extract-overpass-tennis → stg_tennis_courts)
- foundation.dim_locations: new conformed dim seeded from GeoNames,
  enriched with nearest_padel_court_km (ST_Distance_Sphere), padel venue
  count within 5km, tennis courts within 25km
- DuckDB spatial extension enabled (extensions: [spatial] in config.yaml)
- GEONAMES_USERNAME + CENSUS_API_KEY added to .env.dev.sops + .env.prod.sops

Scoring models:
- city_market_profile.sql (Marktreife-Score): adds x0.85 saturation
  discount when venues_per_100k > 8
- location_opportunity_profile.sql (Marktpotenzial-Score): new model,
  no filter on padel_venue_count, rewards supply gaps + catchment gaps

Methodology page:
- market_score.html: Two Scores intro, 5 Marktpotenzial component cards,
  score bands for both scores, FAQ 5-7, padelnomics wordmark spans on h2s
- en.json + de.json: 30+ new keys, native German (no calques), TM on chips

Docs: CHANGELOG, data-sources-inventory, SQLMesh CLAUDE.md, PROJECT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 17:17:02 +01:00
19 changed files with 858 additions and 206 deletions

View File

@@ -1,74 +1,77 @@
#ENC[AES256_GCM,data:tO7tjA==,iv:LuaS9vf2rGblW1DTmC/Ih2YmKMCM4yslxCjh5tA9Y2g=,tag:Pj1QU5RWt6g10pCyhLDSkw==,type:comment]
APP_NAME=ENC[AES256_GCM,data:C+gQ1rAnQU7OyOc=,iv:zHplisKDP20NKHjkPMx//z9xtHj/FQadm7lK+URboEo=,tag:cY9lKxBtm8QdGtEi9L/bPA==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:8J1enTv/0q4w+IZUl6F9A/35HxtVAj1KwL08KpAjuAk=,iv:eu2Q8RJ8rDAdABsQRPauFRhOviEGVNtZwCBhM3wKp7o=,tag:NDLHZn5h9lg/91+aoQlKyQ==,type:str]
BASE_URL=ENC[AES256_GCM,data:GSKgprsXcZnYd88t0Kj8ahG2kYg8,iv:dBea81bLhWjtYRVOJGdou+y9WiVUdi6Mvz5QdqaR5Ok=,tag:GhH9veEOHr9wR6YjepbqVQ==,type:str]
DEBUG=ENC[AES256_GCM,data:zy3fFw==,iv:D7eJH06aqgwfVK56I1B7MkBcj/NZ8bU1E4Q8ag3gr5w=,tag:OYx2XqxpkDdKP+Si/If6Wg==,type:str]
#ENC[AES256_GCM,data:RLUbwoSZS+C0Y9+00yiY2RJJp0MRfP2SxLCUw4TmScCqRMq/OAB54Zb10B8j4asqpOPiIl9iOTgZIq3o9JuEXWmc35c9axITfw==,iv:a8KPtv1UKjPPnNcNIKESMugmhW5jzB48ww9ohVjOA9g=,tag:bFaAuQk9VWiuXrG2NzaaJw==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:W0fdsQZCiU/re+wCBA==,iv:aExW76GQodDwAsOk3Sh0R7CRHCf62duDRAkdUe+dobY=,tag:DaPw2bUp3NNMPAI8DuBLcA==,type:str]
#ENC[AES256_GCM,data:iH5Wz+Eb9/13,iv:6GncS8xJ1IxdGNtwj6MSL5XGiiULImuUVEi7l5m3LB4=,tag:ri93HPghLkvHezMzrg7pwA==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:LM5Ya/grtmCyoZs=,iv:B+hSWR57zebC3z4BOlcL62DSugmQLqfOM6k9dl2qT4Y=,tag:pKxijkQugOIyEJvVtiearQ==,type:str]
#ENC[AES256_GCM,data:WUeUFso=,iv:iR8B4lq4ZP96+uAduwRXT9B3XU3DsIwiQSU/Nhj7PL4=,tag:jqladSMwaJtoPxvCjrfcLQ==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:Wfk=,iv:ntrM1YPNh1N10IJfOv5oYz5ndJykriNNPierlnjC8H8=,tag:ibWA++PyqptDNhUAiC8yvQ==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:QMg=,iv:ywt+hBTfLss2CfJ6beBegEdbw/qDw3HEuZigVe6/37w=,tag:vTtukJdxr6QsBQ0WyjjHGA==,type:str]
#ENC[AES256_GCM,data:9IbZDceOMDVQ+buGmgKk,iv:z7JbXd6ygsgK1pf+bWf18Fjwu/aaPnMpXV1PwzNU/Qo=,tag:71XQhXEES5uDR2zmztlsJQ==,type:comment]
#ENC[AES256_GCM,data:5jXlwMXRE8S6Jx/+8pd1zwiYfMDPrF2p2vN4phAF3dMvzTAl2UECbb+Uq3p01zC/jc5kpx82kVGmjrigQbFRUINNk10IyHdb9ywpiQ==,iv:mI44N+RvStxDXjU9SIzyt+WqVPVoXroe/Uhtm6B38ck=,tag:VUysMS9Zv+zSIoghLlPXSg==,type:comment]
#ENC[AES256_GCM,data:OODjUg==,iv:E1PqA4jzCrltGb9T8tiB2wrkLmzefekOVJt3jXze6bI=,tag:t+nmaZ+gsR0Qr3XeJ/szMg==,type:comment]
APP_NAME=ENC[AES256_GCM,data:MIW1LUcXGSRAXQo=,iv:WlHAjnFaoo5HgsyqGnvbiuMfvZIYXeA+ssw43fsP3TM=,tag:BOGuyhHj5+T1Tg7TBbCPHQ==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:F43Bn1FzhYyzdGINTQA90Uw2aZWJ7REhVnM5Tc6KFS8=,iv:ZAbWVSCrVDKqVzPkNfAvH6p8iRISZSTD4U68JVZwL7Q=,tag:B5tmroCp3EJ+QDnGXgbXOg==,type:str]
BASE_URL=ENC[AES256_GCM,data:wmh0BoYwd3GutNhk7uQZKO4MAXin,iv:uZ4FivDic5KL7fQvrH1rhN4bTmXWqnHfzMJq7/4o+5w=,tag:NFfkEGkS1vxjODo/zB5Mcg==,type:str]
DEBUG=ENC[AES256_GCM,data:aqfTbw==,iv:g7jWtA4OD+b7QW4Sor7dX/6pJxif7d2SiU/An21QSfY=,tag:sjSyQQNtk0rLaOhYzNbAKw==,type:str]
#ENC[AES256_GCM,data:dSj2wIiLuhNq9FvDEhTL3F6LBJ4ITNsaKYO1YRmLZLDS3+C0jk7Hx5kPjcor4T9kst5tOE1KMH7HKn+0CHeWf+pdidAWikd1xA==,iv:hbnREdnOSgivoTI9/GwUOpRg1yROIgjbTpiR1R9u3AI=,tag:am/IFpc8a8Du0Wx8X7u9nA==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:yS0kOYrM5VRhAAkxqg==,iv:eCqHA4kcgwt9T03Umq8MYiLYeB+Zh3pL9R7adcSZkYw=,tag:k3O0EbevHeWgG1JQAh30hw==,type:str]
#ENC[AES256_GCM,data:FDydGDKV+yuB,iv:3Q2JHDLqOWS/R+91Kx+5l7IST9DRngBd7Nd2Oau0kFw=,tag:JmavycCv5EeW7nDCum9SsA==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:2INhi9u4WHoC6eA=,iv:JvkmUtrd5oxw0ZGQJAhI4kj1MtPqxRi5keGIzgCHcPQ=,tag:T7Yb1QKkVR3U7Ww5puKinA==,type:str]
#ENC[AES256_GCM,data:c3ikDZY=,iv:ssaKWl8+ddTa48pst3cz2n5ywMqMdrUN69jlLGRbgJU=,tag:7Dk53YiaBzYCyrrddUHb1A==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:gCw=,iv:u+tNE3Jc0GD1JJB7gX4MFelHb0JhqOiFAjP5cvsS1eU=,tag:hQJPei0T/Hz8eUnUmGxnyQ==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:NT0=,iv:wzqXr3Stux9WrJBF89iYtmZMuoog2jr5ScTd+W3T9E4=,tag:Ei/FtIMAmBChc1Q6GS01SA==,type:str]
#ENC[AES256_GCM,data:0gHTU0jfkx3K/aeVQ4ab,iv:MhpRgozzMhtlDw05jDdPvNB9linCCep8WWPXFCxESI0=,tag:yxQncJ6uBHPGM2PsgsqA2w==,type:comment]
#ENC[AES256_GCM,data:N1R78wzjduwR6vj2/2io04RHvDz4bdMZQah2eG6iuQfe76dk4yVA2is6vIAO0wGHQbKOFj2XCFnex9h6LK7yfVB2TNNxndTLDOemlg==,iv:ifFHqlb2oY4flnWX3sDYKEFP3cufi5pfuF1H0zjZGRQ=,tag:ZJC6zkPE5/29GCpRK6G7QA==,type:comment]
#
#ENC[AES256_GCM,data:cDhpSGp27nwp7pafyktXVjAH0DogxTlSJ4RHR1iufQwIv30PCRlNmicBGeS2+N3rvKQj1X+oAabYFYXsmP6O9rVXRsqvo380fLhd0F6fzxM=,iv:X63vVcfNYOSnhKukE16RlbzzZkvk1n92Ujups4UL+Ns=,tag:jg2dextzUmUeJk7z+fZjow==,type:comment]
#ENC[AES256_GCM,data:rBOS1sz/zIaXT+Bc0XvuxgSbr6kgjrtxowCp3TTRq510YESXnY6w8v++0q89lLaEOaBMEfKvehscrGeI9bp4a+g0FA4p,iv:vPDVzz81rES+EmIzWo36vhAOipHMUhyTQJvexCWQvxY=,tag:uhCJgZCU3GrM5n98be/v3A==,type:comment]
#ENC[AES256_GCM,data:b4xnKixaM9wGxcK+Q74+rs9jZXWkmAufUrYGOrjcd0dR8UuZPKsEq2m336O+rl3AnOH44AI=,iv:l+iGNfUq3007Mdb1A+PFYMKzPLJyRJLN+W7Ew5026xg=,tag:BGODJsjM6dMEnVTCD8OQ+w==,type:comment]
#ENC[AES256_GCM,data:h3aIplJ6ggIZ6AcbFNLJfWoIU6eddtotRLRQmbsOYX4aBW1QG8tR1It+YPCGswjCPqHAOnoCK7A=,iv:7BPHezVScIiMfzs2hXqPELNqelm1aBfu+GPYo1mp17I=,tag:iIM4kbSiFRSqLqd7J/5O+A==,type:comment]
#ENC[AES256_GCM,data:uZ0fYJFN8Ts+ybDY4t4+bpvAfysMSk9EaIgKXwkckjLAaGw98o05bnBi5bZdfWu7766e6tKWadZ30Xen2Po=,iv:uzxi3awzBLnn18S5B4mvKekpiQuwZOb0aLQ0X1G10Uk=,tag:CMB7PIKcMB8LRCPTfBQTZw==,type:comment]
#ENC[AES256_GCM,data:jbrU62G5xMmR07WsOaVfV1G4O3CtWViOqKsYDBzSaMldZmcts29ee+zZAvvJ0KzJzQlgb4ib4kU=,iv:Ach1vZiBsulqSYpS8arKP5uIa2XvrNinGCktfXSkt3s=,tag:rG2kP6DRQrgI/xw4B0By2A==,type:comment]
#ENC[AES256_GCM,data:F7Jf895irD1ukk6BlS8vpmb9BG+lRhMeYpnAqyROuZF9iUuppkwU34fZ3APpseoAlbc/ipyaPFIVJs83nMEjaWXGBtkh//0W,iv:6OTb4ElAUxJdp0jZ/wJ/v13uqsteeXxn1uhKnAQyFCs=,tag:Qi6ZCceMxoS8tgxrcg62DA==,type:comment]
#ENC[AES256_GCM,data:tuw7/hEPqtY59Y+L3Nq/Y6op26+U05GzZjQpGpwskwXJk5fauks66lPr+P9vlgEJXPOzuF1ZmB+M9td780mXavW49PKVh5NnjP/GbQ8t0Gc=,iv:wj6MCF/ujeaIoYC+pcgeW+slt1CBYPK5Lj4+LyHfehg=,tag:9EJJ46Exxd2On2SdCba+vQ==,type:comment]
#ENC[AES256_GCM,data:KApVqBUmJYwUpHp6BPBkzzVR+BPe8h/u79lODjPLvQoCT9XIfJ3ulZngRUUIDCJr6+yM4y98qxQJf7bhs4z7CK6oB1L/,iv:FAuFeIOD/Rx81L97cAqYIPZ3bhyrqOReQ5xeOjyN/m8=,tag:Z69JIWO4e/Q8+BQ5ThslVg==,type:comment]
#ENC[AES256_GCM,data:80vv1OoLoWwS+J9cbZ1Nt+UeF6Fc4D8LUHLX5BiEmBQAXh8ti3EZZnwi2QZb0wN+Ma1zwsM=,iv:6xnMzedX62XPU3GGpoTie8RZDFlzvrpFMc4u+fCbKYw=,tag:V7HtIqw1+ltW4dbvqobjQA==,type:comment]
#ENC[AES256_GCM,data:irTPd03a7pDOi2WOsL43GB3c/luAhlm0u6b3Wj/oLKnlzmmUV++aoyDum32wGLI4qH9Sy5XJS44=,iv:HH3uPdh0ZBdvX2bhH5w22TCsn67HaJa+/f5bk8NJNvA=,tag:f4xcS4URJS1EAipjcpFn2A==,type:comment]
#ENC[AES256_GCM,data:uW8E3Yhcepw3FJ3En5N8CLpN70a1ovXZJyS/gSqaWsMtScbTEqlMZ39R0S8hiS3j1y7ST7GaNXburXx0lv8=,iv:Xk8GhHPk7zVnMi4PbazefyxumHSv/XwpO/lk2SCxYEc=,tag:r5IpNFQ0wxQ05+UfOaenig==,type:comment]
#ENC[AES256_GCM,data:Ep/iNZ9JUQTFhk63ukNzs4YltDYEZ1b4jvi3yBpT2yfnSuVvrz1xSc+oU5WbHI8SeGbpaTgn5eA=,iv:vry5r45x5xNcIr1oyCbVAkFxFq2vpvvOEzqlvWI/bQc=,tag:MjTSas4nqwHg7J2ZfxlkfQ==,type:comment]
#ENC[AES256_GCM,data:OwkE2btYFBq2akgxnlHA3lVUJ4eXI4YVjIxlbK4lbrn6cd9U6xz7DhrDuFbOxMo1VtbyxBQ6dCBM5TODPHMFRBCIyuuXQi17,iv:emVmqGfwrpkF5HoQ0OD0UhiN076RezDEgsmBm2e5FZg=,tag:Hj+KHO9yKGqv2W5XkkHCZg==,type:comment]
#
#ENC[AES256_GCM,data:kwl6bmCm7M/SWJwNTMLKApVIaRlrEFu4TCxbyYA4VkE0YsKKnigqu1shhOZ1By2dSpB8O2ai7UleYHmDhttP+wKkwowE/8JZ7AA=,iv:FegyAuJ+x4CBk1nWuUXFbrlx40oabgnKJQitIK3J17E=,tag:ls6ryKivVSZX6gLbnkL2Ig==,type:comment]
#ENC[AES256_GCM,data:1rm7G2bjcRWiQBcHI3v6/iVxnASJKchxrEot8YznFxoaGcEQNyQQXapZUFRR02pT9FT+vy6mCWxDyqRdyrtdlGjIL+N5R0ukNcI=,iv:dAFaoyjPikp3gOCIph6UWVrAR3ascxMKr+weOCAqO0Y=,tag:a8aDQifsAp/6+uemGMwryg==,type:comment]
RESEND_API_KEY=
EMAIL_FROM=ENC[AES256_GCM,data:VfDztVDKyi5UA6naoCF7u/J7sP8=,iv:vHm1yLqTJppte0n7z4Pr3LPoYgg8idyDHOHFYreC3bc=,tag:+hh5lsTroRjmsSU0paSxZg==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:tKfcRjekFpyokkUPQVvp6LoUlgA=,iv:2kD4gnm4pPNwAjdHu2GijnmjpFB5IWS69u2fd3IGTnU=,tag:lv+so7I6uYk7x4pdWznRsQ==,type:str]
EMAIL_FROM=ENC[AES256_GCM,data:aKRFxdRPnDMs5ch7uYx2l2D5kwg=,iv:cxmNdrdXZnRjI0oDXUrz028mI7KnSUXK/pUz71NkUUA=,tag:UjKqpg6oq1t2WIdaXGHzaA==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:BJ9r1b4E2Ck2b162f6K2P6z2rXs=,iv:NmrlbVJU7pfBf7pXc4No+69AyvqgF0j7vZEkSvYV6SE=,tag:fI3oyLbePaqi9TqkiJXZtA==,type:str]
RESEND_WEBHOOK_SECRET=
#ENC[AES256_GCM,data:tGSs8NPV6sn7gd73ra5pwZc6H966bFzBWQq7LGCkJfZ+3HbkMpXjVbfl4dXvzzFJpD8SNRSxlQlLRTdsXvg9b1c4Hw==,iv:09XPpnPnvBRrTCJ/1SiVl+QRnp66jbGnSdWKiQ5zls8=,tag:Rzt6YlKWRwPdVvWCaYF5dA==,type:comment]
#ENC[AES256_GCM,data:2GtBzKD3qNwMMWqp8BR/kY4BmaGGykzKTYKTVZh0OB0oaD9Vn8OFeUzS2+ZZFo8HnYS8mplDbyfEvQQn3nQe1nZFc0Y7WqfAmR1O,iv:IqGpYYWdsf7VBifNVHj8BlhVAPiu4S4j/5G3BPyK+mA=,tag:YB5ml+tfDsf8J71Igplc2A==,type:comment]
#ENC[AES256_GCM,data:DkjB7T1KLXyt9iN+aamrlIxHuGuUHwCdI+YbkJk8iI1nbjhx337mPsJCVVm1sQFEKjTHHK6jicADv3aNeiGGOE1E1DDFmkSjOGLdjA==,iv:g2oFzHPCAYtHthRqUthC0EVcMnz336qNV/DvtzzAOZs=,tag:wIjTcIPHz5752/MvZlkXsQ==,type:comment]
#ENC[AES256_GCM,data:N4pNW0vpIpaROwZh8ZGLwfs33GUxCoLdJDdt3iL3RsedbjhTLgGTszGJwcRzA93X3Tv1,iv:awW1hwGPuqlRIRr8kOKpVwFfROWi+hLCt17MtBpzZEA=,tag:GyyR+k8MJ3BP7B7CVyHWKg==,type:comment]
#ENC[AES256_GCM,data:6T9lVhn+1mcyCxBYy/dsPaGp0zy8+XQRhRCFKxlos4QOIiLBjrYuI11v2oSO80Nn6AfGNms40ewi4RpHtt7Afawb3A==,iv:f9gfD7cM3WJGdvtFQ3Svi8Cui2yadmMXfuF6IAIn/LI=,tag:3jt5lFeI0P827LqQWUGzOA==,type:comment]
#ENC[AES256_GCM,data:ja0Rgj3IVihRU618EIroO3bJg9sWFOd3Ua88HLP9yrzEZ7Ty8Havd+2vroD/TGeidqjiFmpMlXH2R3v1jLrviRbkWqBlbBO4se4G,iv:IfuAx8HcJByEcLaqegqZZZVNfO9H8LnuPzIcXHYRBDY=,tag:FumQcnMmcKHI81FjYrT8Mw==,type:comment]
#ENC[AES256_GCM,data:eHDEBuHGW7rKMPW1NM9b47rBS9BMmtmrwICbijYIVdogMvqJCMEk8zfT/lc/bnXFiamFYfJhhHDNEEOBg69ZdZ7M+mWPCRps6cXP6A==,iv:7SDIHytbnp/v6zXG0j4PbkIIzjeDVqp4BKthmTIqF1M=,tag:+fPDhe9NIe2Qmqp91KuRjw==,type:comment]
#ENC[AES256_GCM,data:VrBNRBK97VxVTcwPZItM14tr5IbWQ2jGTLh1Hca4TPHGWPo75fxUa8MFWSCUcwXIKjMm,iv:FRa+BpQFtyx5BuNt609duHng+QgMbQavvOzDpPb0Ta8=,tag:+OiUXm7Vh+xhdfQ33KEhSQ==,type:comment]
PADDLE_API_KEY=
PADDLE_CLIENT_TOKEN=
PADDLE_WEBHOOK_SECRET=
PADDLE_NOTIFICATION_SETTING_ID=
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:set1HXmlEw==,iv:8OzhWk2wrd/NusWHqS4TpRUZ1UK4mMp9uBEOAAUHu6s=,tag:BE+O5UL98SwcHn0b0HLN1g==,type:str]
#ENC[AES256_GCM,data:K6HI/gEdbbsu4qHjR3OPKwF/Vsp8IaDI+bPZCfn3sPfyapkPPLasC9kjqonOBHBfb49RRx3pezBcOXdo,iv:ESwxwPZeW6mnmfhliszcbcMld5osRgLSeZR9oECjGhs=,tag:Ex8R0N0fhBZjjS2Eaytb9w==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:f6Dma4mYHV1HsYbXupk+23GWTpAmwi+epzns0g==,iv:M3GIeZuRiQeYDK19rgwZ79U7DNrBmzsRCRGHYfiwiV0=,tag:eyAu7CTVFNocNSOi8q0/qg==,type:str]
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:Xm4mm3DBjg==,iv:6k4ZSP7GQF4LwVG0OAMBdInAfaf3hhJLjmIztNfdCWg=,tag:/LJeoFbovsvzP7GBEiLsTA==,type:str]
#ENC[AES256_GCM,data:1c0sgo2l+OssNz8Rgm8/DI5ormrHXNHKDTKx6bWmv/2CchefqxsRq1uS7uR5c86eTxfp023LGhy947i7,iv:PAEdaE/pgfy3jxuod1PJ6VcyIYOKmlKkPmudSs5xbxw=,tag:KYnQmbim+fWsnW6nlEnhGA==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:yj4OYkvZIDrgjgkdM3a8xyzvoGJOk75wUhAgNQ==,iv:JmQMdU9XZ5ABfvk0w3XP/WkdcR1KrgXTPVI39+drhO8=,tag:jW1MUL5Nv+z8KP9FjmYFcg==,type:str]
UMAMI_API_TOKEN=
#ENC[AES256_GCM,data:w7NL+dPofzgK3m+2Fsk=,iv:XyqxWvW/Xk0ktIAW4erwhz9Z6Ujxj6eofrvfOlcBkDU=,tag:4yJ/e8KkSfJD6EkF6JRyZg==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:3AsS,iv:vJHCK/qRR8J2vOJddh9OelVDqWossNkqIWStqLpG3x0=,tag:eEze/syOX3Qr6+z4vk3IVg==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:ACE=,iv:LL5LOIO2LfMm+VaMOwpwKBu6GiPQy3ybKLy7uorJge8=,tag:TPbo4Tne2O0NDHz2XZjG8Q==,type:str]
#ENC[AES256_GCM,data:KsGKuhBQMb07rP7NVQqhLRq4gGMBbbGvjwRK+hvuQt7oGLsLcthsTnMdR3BOXwv3b5GFOUzmLcTHLieXBYc+PeaslfXTTTgLxLIw60WbLes=,iv:QWq6V47ZUpbb0g4vsY6G/z3N+YB6M2qb35dcrc4Z0n0=,tag:1PDVfb7esfjRUS/pjdvyyw==,type:comment]
#ENC[AES256_GCM,data:5nypMO9DFup4c3p0xXM=,iv:GdbOATtVOmFZKvDfv4gdNDDdq4WmB+/yk/D/xgC7Uos=,tag:TWfgxyuzlzvzxJRMF0L0cA==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:o/R0,iv:TGPynY3rpH5fL8zQvI9EAWg/LFv7earnaCbGhkkN2FE=,tag:lpnRZGaONv1TZdwJg17AIg==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:2dU=,iv:6wSl6GAWAVxfVC/LB0uqhB+8gtHhkrFybASXdQseuVk=,tag:gVjomO7E837h/plD/8948Q==,type:str]
#ENC[AES256_GCM,data:7CoH3Rv284IHofVG9bruk5NYF9S4oeGqupZFQ38OpfCde1p9ks7WGicFmfrMQDNTLG1YoR3TD/u/XQ+19SoP+XTcSlhoYwo2mx19ETWnww4=,iv:BvgKjGm1YB97I8EQG74uaoDk1QraWxTVtZoiNGkdx6U=,tag:vbS882eevc0rzOA9pGtzng==,type:comment]
LITESTREAM_R2_BUCKET=
LITESTREAM_R2_ACCESS_KEY_ID=
LITESTREAM_R2_SECRET_ACCESS_KEY=
LITESTREAM_R2_ENDPOINT=
#ENC[AES256_GCM,data:Uu1arRNv56qWcjLzwBo=,iv:lUVJ6FYL821POzm5IFCej8Hn/4u+n7CxKURxjCdrxOY=,tag:g8yWw8d2TJFBHpqJC1Su6w==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:mjde2EBgYYDt/+ct/co1LRAw+1hW,iv:Gc4c4EShP+M2Qr6kyDzH9l4OK5FEWBZu+Pzf6/yvCiw=,tag:kiFxuDrLoqYMHlGULx94Uw==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:xaqVNsB5/AjpYA3vqKlyRyLU0ooK,iv:Xayq0hEICs2NnqPEFOO1MUPLUjDsuCdsvlFd7jAm5eY=,tag:1Pkn3tnuSqDkIaUK/+VaPA==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:mPJitWncQfQCijay,iv:dzk3i0yHRNw4TGT/iYYqAd+S1J4NcOdKtFw32eHmNdo=,tag:4kzYlrq9rBTLH40sibHXcA==,type:str]
#ENC[AES256_GCM,data:2hG2+eK9l6YzRybTFEEUBrirw/cCU0G/Dw9hKILHIvBtw3qATM8mgSyS4CEaNlfM5VReUr86XT0nnTdhAPxsx0aTKY6oGYx5X9k=,iv:snCIvgehKVYuzvr6iOB0Ggrga/DrFUrCbGJU+ggWtW8=,tag:Rs27oLsFWY8HhggJCUuGNQ==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:+Q==,iv:uzfcmAkRXEHtl7mxrXMuThTPQwbmlAvG7mAh4FmcKeE=,tag:yBE749xyNFOXg7Xi83CsdA==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:bM9tttWpHbxWag80ohDTre3lGn8IOHISEjYspu4B9Q==,iv:9uczfTWn27L253iUo87YYSG3Eoq9SGGzcHjBufKCUcc=,tag:T9hnCnv0f2KQn+nPigYFIg==,type:str]
#ENC[AES256_GCM,data:1BAAp6TJWo4w47vsPtE=,iv:mMZHNSUPmFYK+jLv7DJ4QqZ+wu4mm+QJJxNKxVSiXaM=,tag:rqg9uSb26e3YmL2KIGhsHg==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:QupAI4arYuggsPTVJ2E9BGt3Yhco,iv:nuXFZUleLIIVf5bXXKuQ174psZZjR8AgwNo677A1Kx0=,tag:+9bn+OTtBCmxZqPt0cYCzg==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:okf9kDEFoPYreiBk1rEoB/6dAFUr,iv:iE+AvfUPX2jxKKBQG9iVMu4LkDcNTxLB9sHY+bmLrso=,tag:GeVRiwbScPrUSsZ1WtfN1g==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:cMnL22EkJGT+jAgM,iv:nItexm3BrtgUVU7TBjHKxBIMuXnuEoAvZcEFFGTmhSI=,tag:E+uXdhTGjKRpjCtRXgcUCA==,type:str]
#ENC[AES256_GCM,data:Cwg5GJ0vOj3LfvtUHVoePytBmvCqJZbqJ5AWhdHcbVFpFVVDYo6GQARMlaLvlEgTe+4mxiR1BgDn6jTokWvynBxgVvQbNLFUINY=,iv:p0fjB0/TQRwdtSxTqFZPtAz9CFRvNjYwxqFLDw9wiko=,tag:EyUfEr0z4PtwvWSo9LbLkA==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:/A==,iv:gTBC1pW2sn/8ZwU2UWErJSecIGmnl5voru0T/klGHuw=,tag:NlFer7SSK9KyAFhrzwv/DQ==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:lu7JrtxM1TGqY4EErXD13hHMxr4XJg51h2uzXAAFxQ==,iv:j2azHovy7Cu6CREn+cw5hNZkknA5m++lukJskWi7OXU=,tag:d+FSm+hx06SfpD9o7pwr8Q==,type:str]
ALERT_WEBHOOK_URL=
NTFY_TOKEN=
#ENC[AES256_GCM,data:A/KnoE68ouBRyj0=,iv:v2DINooD2UeZrQ9+7Y+PQZVyy0PdLFYV7yh3IQ+E2bQ=,tag:7H1cFfvNpbtv6VYIQVIVJQ==,type:comment]
#ENC[AES256_GCM,data:cz9SOffDAaXZRjw=,iv:7D7YZAyEk5CNlTiL1+KnbPbqfRYMxdtSk40LWErVOfg=,tag:JRHq+gaWSl2h71biklMMwg==,type:comment]
PROXY_URLS=
EXTRACT_WORKERS=ENC[AES256_GCM,data:VQ==,iv:LIlnbGE3SqsnOE+w1P5RLyn7dZ10BIYfDQM9Rn64MiY=,tag:kW5aRY2t18TO0UMWkVC2hw==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:+K8=,iv:emFyt6Q6VXE1RgRSRGUxQNniSycBjYaByWMhCm97ZgA=,tag:M88Q61Kvz96MbSc1k4K5QA==,type:str]
EXTRACT_WORKERS=ENC[AES256_GCM,data:sA==,iv:cZ3Ga4VPJfTlKrKeHIXadW7kZI9RqkkriKHAxGT5mqw=,tag:4kTYW7QlCwTkFWHxsl59zQ==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:RV8=,iv:Jrom3R7f1NYbEL+lzypeUmifhGaayY3+uzjRuBxzoxs=,tag:pjYanguedj9RMnDGKiF7Yw==,type:str]
PROXY_URLS_FALLBACK=
CIRCUIT_BREAKER_THRESHOLD=
#ENC[AES256_GCM,data:9D+Mxt1FmY/wXf5oHY20aYBeaVp/7eelKFxgJ6w4FCgAvVv1PYSi1ddsBghwAkIBVJdICRe6JXKtU7266nknY0MRxlfOIuyj,iv:I76ax9TNOgFrES4mJNKkNFzN3yUcLGhUPB4lrME0RX8=,tag:U5QyhZzMKBp0henjzgzkrA==,type:comment]
#ENC[AES256_GCM,data:cPEkskKyxJENcKo0sFupC9R9qq5bQp7sYT9TdQy06FfHPoDQuWw+JuZ5Bs6pOli4NNQBrPX9fdvRwL1TsqmfajHxrSfGgr3l,iv:wF3I5Y8UzYrrJrIeokXn0P2E51eXnqVFdo8lXpKHZGk=,tag:oTPO6+UjI3ZBY5gOf9RLsQ==,type:comment]
GSC_SERVICE_ACCOUNT_PATH=
GSC_SITE_URL=
BING_WEBMASTER_API_KEY=
BING_SITE_URL=
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFUVVkYnFIZTVIWnNOZytS\neFhGVXN6WkUvQWF3aWxiOHV0eXJ1Z1E4aHc4CitTOEtGUHFieG5LS3JtZWRYeEh5\nOUt1eUlhWTY2ZjlJazZhY3dsYW5FVEEKLS0tIEliUmV3d1oyVFZkUG1FWlRYUkFB\nWU15cnBwTCsybk8wc3VQM0lPRGwrU2cKJdd6xG43194DOswmYMz06zVPm62Ahp1o\nlENmD1yG1c0aU/ZdVGs7wUoY7L5GglHVM60uc9AR+UiLqiG+Qlcnrg==\n-----END AGE ENCRYPTED FILE-----\n
#ENC[AES256_GCM,data:CHMw0ywPH0adeYkcwSndr3JdWyw8f9AdQXQYG62SRCg=,iv:V73eodJud6Z7U8FvuTM52s/2nqNkFz9eneYpPC8OzJM=,tag:obnBC2ERQ19xj8gI8jOosQ==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:mLFHQ76lAa2Ygtc=,iv:XWAkwQ075Ph+8qSNmD36PyvPomROENvb3SebVEuVoZg=,tag:cOyW28h6h8nhTbaBZqhmZQ==,type:str]
CENSUS_API_KEY=
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2L2dtZFVnU3BlODk3WXRQ\nOGxSaTBZSXpBc1lpcUkxSHI5dmlyYUo1K0NFClpycmREMTQxNnIyWnpHWVZOWmFx\nUTNRZDFzcFArMVAvckNBbXJMRVBudHcKLS0tIFRhWWQrNkdVTzlucG5nOVJXUVc2\nREFpWHdpV081TFZON1R2ZDlGNHVsWjQKSgmj4hrVEvrIizGmTpgj93ct1a3lUYXl\nBbuPUT8k+Hj5UmP+SoZqNS3kh2a1Nvr17K6e4PjfRAcEfM0UgNA7Jw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-02-24T15:01:12Z
sops_mac=ENC[AES256_GCM,data:le95na6NB5eTvbQengN0crIn279mfnV50ceb/vZ1Ia5MAIZ8f2bmgXwuYYDpMqCMP4iP93gS1Veht10QWyc3VrjTTNUQq7BOau8zaBXjlsaUD65mhkPxtl9RS+c9rA1bHhZPwz2Hq/7+goGIvmpsupqjYeyqxQS63khiwy0+nZ0=,iv:99wtdYrYQFUkGVXym5L1jQnxrjfvZ2ifdP1Pm3vxJ7M=,tag:fOn1Rt6Md108Xb3ohBBGaQ==,type:str]
sops_lastmodified=2026-02-24T15:38:25Z
sops_mac=ENC[AES256_GCM,data:eZOqrSiA4f6mYUaYPS6TD6vL2ON1DsLchIjzSE7bcGpZuaTspItlkBNUR6bsiPnhZ+RCv0xfFMvWallLJe4Y/8ftlQCeq2fGLJ30ZlktgrBocXw5ZYUcJz99NjAXf5gvXoq7Bn5DPnX81ju2a2D8YIhGCZ4YzzE+ae0b44MK9zg=,iv:BG/M2ugqhacqm7dEaHmH+v3dTbu7aKojRjsQBSy/8vE=,tag:1zSJgTCzBUKVsahvdriQrg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -1,59 +1,62 @@
#ENC[AES256_GCM,data:ZjUQFQ==,iv:c2XVlmYBh7jYljDODjjt4NiaRJYn7sE5Ye+0Sa5PdwY=,tag:TrNGa/stAfnOPANvN538Pw==,type:comment]
APP_NAME=ENC[AES256_GCM,data:6N2K/nexamI5tW0=,iv:MAyi9CtenGEaDSuu/XXto0JccUOvWm32aFhNqqxeMek=,tag:nWITmcO5ak1+4QWbAyvdOg==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:yY53YEmhispB,iv:Js6rZBwp1Hu3a2ij8xIEYE8r+lnIyDDKFDwtZx1Yi6g=,tag:ZKHwm0NE4sYN9ixgFPaOYA==,type:str]
BASE_URL=ENC[AES256_GCM,data:jZCiKr580eylJqzQ+ohYo+oK5HE1Rg==,iv:QgKFr7R/dGRDlcunQN8v3fCP9yi51iLuk1r60o/QcpA=,tag:Ibo+yQAIq+1kBo8RBO1ytQ==,type:str]
DEBUG=ENC[AES256_GCM,data:YWuRfkw=,iv:Txn3+phhL9l/s7gPcJK9ZQfrYgIsklxY2Hx9btiyxIc=,tag:Up2qxaMnLwLO0dyGnkSPlw==,type:str]
#ENC[AES256_GCM,data:MS+spGHJ8vKuuHr8gw==,iv:wMAVcwT8006PkODxO3oUT0G9rXtYDhOeNT3tjnuDKRg=,tag:oY71/NbHVT7m6oE3vrVQwA==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:bkauU7Z1bt7U,iv:wLa9z+xmXRjGZPuvbz+zI2KjnzqvVSXqReYX/TBwS/Y=,tag:SejgSaLD+Mus8+NR5LQYog==,type:str]
#ENC[AES256_GCM,data:CZZP6QlJe1f8,iv:Sat1+y2L02M44Z1nHpO06KxaAXh/ZWuPtkAcc8c4h38=,tag:mv9xlGSIWmzpquGqoUQCdA==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:UZYij1jzKBzw95Q=,iv:/XgtdEyGf5iY/yntPzBYj2K0h0NMuwaK21r2flCd8pk=,tag:17hXzWz43M6vTmL8qV6x1A==,type:str]
#ENC[AES256_GCM,data:/slU6rs=,iv:BNJ9v2nhfOzvnGbtvBvF60IfNMf/A/CnL4zWdC8tu+A=,tag:K3smkVA6WYvJX7M4aI/4yQ==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:Or4=,iv:t23GAb1vCFu/iq+uADbG5dX2K21JiaUJiBI6/xRrOqg=,tag:u/6WgNh2daEPYNpVrH5Dww==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:niI=,iv:VRcJUJeRqcZkbBMmmIFsXZg1ugSCzvrOEcpSmQvtgMk=,tag:pveDNeZzodrZqWjXvemUuA==,type:str]
#ENC[AES256_GCM,data:5QyAfIN1bLQKeAQVpXsY,iv:2rW8pJYfmBtiAo1DhkQjd6tAV/5zu7Qq3KLgVHMnVg4=,tag:x3VwNSfAa6THxFhAw3+5Mg==,type:comment]
RESEND_API_KEY=ENC[AES256_GCM,data:MJ2ibfHlV/0x,iv:HYYfVxpRZJ30AjFi9OrlCWZwywZtyHUEFmSTfPMsj1g=,tag:t4mOE8Vzz0plAGh+ss71+A==,type:str]
EMAIL_FROM=ENC[AES256_GCM,data:zKFSK8lMy2gMBBi0ZAWusF/qzAThevfM0DO/EjtSqyueCw==,iv:iCasIUwSaIfPlCuJYja6DwGN/O4zmx76xedoP6XiZJQ=,tag:C5uobbrKCnPreCvVvrxuqQ==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:XWshE217juO6YY3wMVPB46yvVALrEx4HT3VwTKuVf+I9qA==,iv:SktCHkHpWzuLaRvJctlsjB7RhSlvLxEwThSk8NOKUYY=,tag:afT7QoS75ARqNr2mQL307g==,type:str]
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:jFajN959/lUP,iv:FQI8P33AWTYZXdPyPhiAo1cyjHF2FTpKt5azG68HY+8=,tag:xwkAQeSKGfrRObpXdwcJ1A==,type:str]
#ENC[AES256_GCM,data:IYaHe5F1CQ==,iv:c1zcalp6STJPSe0F5jfPi4SQyCNMxA9l/L6QmwfJpjo=,tag:CxEPk/FjPsVi8JOdS3Z6iA==,type:comment]
PADDLE_API_KEY=ENC[AES256_GCM,data:MoOAgw17UtRV,iv:7hF5tzgfNjo0VvbVnsDTD2BHuxsAUR6qQIB+C0a2pRA=,tag:M7gx3OupL+AcG4gHmNLFog==,type:str]
PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:JZ+hIKDieB8R,iv:Q58f/JgMdbtV3dlYTillF2dFgUaeU2os+oIfvGM4uvE=,tag:IFTfIEg0hOnUssvXF07W6A==,type:str]
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:ljXlE2DUgFHq,iv:bjmH1MzR+TFIrx7BhRkjhd0IkU+2dyTe/uoAmcH5JC0=,tag:AY224RaptRz7y1neJnFlJg==,type:str]
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:kcpe0WKz2hVK,iv:dF+7n5EeVtCZ5hd/xdbSpEWaJR8GGy1gU4Hsl9xBgsY=,tag:37/NFJZ2bQ4NWWG6Q+UKNw==,type:str]
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:hukHtXdIxV6xFg==,iv:YjmfvQ8Av2nc+zKW3M4hm9AdezLEeaTAhvBdS2clqdI=,tag:W8ohvtQqE1JeQ3s3/xw7eA==,type:str]
#ENC[AES256_GCM,data:ILpRggrP,iv:uPBoVraAAUXEVHW8LygwdVFDhD594WV1olUoGtomcXE=,tag:QbkQ6ZDl45VkK1Ffg6TFSQ==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:Yn1FsBNI+UkjSwWfe4Ut+nyZ1yIsb8D5RWza7g==,iv:1xtdyYh1qR8CZtJY9EyzvTPBXCmYllWWOc9j9G/hq5Q=,tag:wxxQg79wzTO0vZZYHm5G7g==,type:str]
UMAMI_API_TOKEN=ENC[AES256_GCM,data:CZf8Bw1gbSJH,iv:kAAZRzXxFKZhLwuAwoXuFearqbz3U0wKed3EQSeZDOQ=,tag:EtYevUn+5lOoRQ49PejUlg==,type:str]
#ENC[AES256_GCM,data:gs9EfxbmFH+rbRAgTiE=,iv:7fA8XrLrojg2RLLv95C6f2eHOwf/KGYozpJtktPmhH0=,tag:NmOi8coTH8Fiw32Qu1bIpQ==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:M0ps,iv:w+OCXLFYqeEhJ4gxQWgTd7H5G92PBY40POagqXEFNfU=,tag:cEv0FUsoEWuyt6RItoNxzA==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:VMk=,iv:yMMQEWN0bYGv4ZeGwMR4nPAGoTABoDoGla4s597WoeM=,tag:d0hcbkVq7jrd4EgzUnoD3A==,type:str]
#ENC[AES256_GCM,data:QLjNwnLVX0bNEbGS6zedQAIGTJcj,iv:R0EQWvvLxnnvgv12NO5IYt4K4slpht234mfI+byVKTg=,tag:pIzqHizkZNfz+VMy8ddLow==,type:comment]
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:nx/nxBllzhmH,iv:OvqX73tZpssCC0rv8nJAc6VUAC61ih4i/MKcmwkZuZM=,tag:fBiJTSGW+1a1cKsd/pimQw==,type:str]
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:r9c3J+sr7evZ,iv:BHwoGkIVcR9IHF8AplitLhWKgAyiROZ8wj/aW3/wHo0=,tag:w49xM8ePVD+YsHs6wv5LkQ==,type:str]
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yB/fNzAIEiWh,iv:TwH4en1Q/FMSjn/BwCSMehjYNT8sL7DoijGbVTqk+r0=,tag:ekLUWbNR0Y0lqKsLeqyGrg==,type:str]
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:jBsfgwFe0sLU,iv:LgfhrVQOVXbSuFO0iDKqePMGPsUvQuUaqku1D7yNUGo=,tag:XNz7j3qZ7eTzrC7qus8RHQ==,type:str]
#ENC[AES256_GCM,data:bxHsA0764qxNXkWUOd8=,iv:eEcLDrWLA1NiVum8oQ5riecnl586mvBj8cztksGw044=,tag:N8wCDF/FG1NK/XOEuLr2Lw==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:ge37CFFFCX6MjvIZRBbWAfTxmbR5,iv:mI0g55JyJh4qb4xw9PJQ58EtXcrM5SqSSj9tY2vCDGA=,tag:KuRoUKtqI+kb531x6TGPWA==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:g6C/8qOnu48PMaa/nL74D1xBqSIl,iv:0p41A1/00MAdDh4NhK0QKyotMz2lBi5lnUaP2+c1Y40=,tag:pDceR9Pt8YmfmDakv+c8gQ==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:5UAYASdESljR+ifW,iv:RShksSTuI+X4ukqtnTjI57/18A/ghrBYDeRhcTJkAuI=,tag:1OhvbyRPZ9XaGj0E+go1Yw==,type:str]
#ENC[AES256_GCM,data:WhulcR23ZoF8J9Y=,iv:PtQjlgmRZhUpVSxmUmc/O95zsSXZvtE5Znsnh0wSTsg=,tag:PZXTE01GE9qzR5zirLZkeg==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:DQ1l258RxzVUfLJKs1LhAQ==,iv:oMOB0a14uPi/U/j6U26Eog+LQn49faO5UC2Q1z5cQ6A=,tag:YojmlgaxBORDsls9e5uU/g==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:SYkoj7KFg80L3kCyrBa3qtE/TiQwSlLX2fl+Yls73Q==,iv:TDhucw5ayQcvB6wnhGNbul7OvbRtsZbQUAJl8ZNCwdo=,tag:UVHlCQirYR1JnvI0S07uXA==,type:str]
ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:u2mmj8SmlGLpxgNfc3Hy74pYu9gN+YjXWr4pwhmM4No=,iv:hJ8uMLgIa9kAcTUb8LZWH3ULy93lQc6JKrhf+v7emxg=,tag:XpEZNjqzjRshm1GJ+q1hWA==,type:str]
NTFY_TOKEN=ENC[AES256_GCM,data:GaRg5+e8tbLfyRVi4nXiblcM9DVnTjSxOfzvIDSJkKM=,iv:apxXu6E6ByHovFb4XHBr0aqtTOIAUw0pVOT4I/r8eNA=,tag:+fYTxaZ5BVOCTI4yKM8c6Q==,type:str]
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:wQ==,iv:A39MWQK65yzbR4lYEHD165qcgvjOReDf8q5docutiFw=,tag:cDPbd/wAgckJ01fLuI0xsw==,type:str]
#ENC[AES256_GCM,data:YnG+eVA2/fv2V7Q=,iv:oqbk1+gpa+Octk9/1tYdMcf/e3Rk0FDalgvebrwqOyY=,tag:LpQWy13O2y8sP+bRNrnWzA==,type:comment]
PROXY_URLS=ENC[AES256_GCM,data:Ow+6WYbSj6WB,iv:PIjPvv76MPkE0cpLy8gYWmKsVPmgrld6He5bIJiG8EA=,tag:TuYb7R8BWDj9gQWq2sDLLw==,type:str]
EXTRACT_WORKERS=ENC[AES256_GCM,data:6A==,iv:OtqQ5H+oK1NZFlV+99Dt+lUFTh+GhTVDoZzan7WC+B4=,tag:e7xWBVufB+AacZ+LOI6UyQ==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:A+Y=,iv:c+5XMViCtqBRc50rIl2LsEV3Le0VtmJVOxBnN/ecLAI=,tag:JQo5ZgF4O3TGeODwZsbv6Q==,type:str]
#ENC[AES256_GCM,data:lmFfKv52m2Zb72zgfSCByso=,iv:iOtHNLO/DBWD/3QtiPuPM+37czqWcZAhgkuctAZYvbg=,tag:SvSyuJ9NvkGFH/Db8hlkXA==,type:comment]
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:hgYixpJbCCwZ,iv:p0y06H1nai1qoqy2Mr/XVM+brsv3XNv67+2JGgLtFJM=,tag:5yI6U+M6Awwd+XbOJE/1Lw==,type:str]
GSC_SITE_URL=ENC[AES256_GCM,data:+8j1vufryYHreeZOO+hx23SngX2hTA==,iv:c/9Ero+t0TuvYgegIbsNIaihI117BmliRUxy9Vhuniw=,tag:8UeNRoUFH+DAoyyRN9uuqw==,type:str]
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:/Q46VCqGYisn,iv:CHOArnTNYfl3/2aigvkRLQJRTan+5y3YWzRvi17U4P8=,tag:88gdL9ftVG4iyxuHqhltvw==,type:str]
BING_SITE_URL=ENC[AES256_GCM,data:Suwqnq/GzA9KHF40q9/80b4n0Etjkg==,iv:SRuEdcVgOCchSKlDrNNOPV6JycelH8N1BldQ4banU+Q=,tag:8xPLf0+vLHcHHRowfFD8hQ==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrZ0tnODRSTTd5WFdCRTJ1\nVWtlNHJwT1IzR1NFZVQ5QngvLzhOSnJlV0RVCmlwVkhQaFA3azNRVDIvYnVoNHRV\nMWR0S1dQVDMzNkR1YXNIZ1ZhRXRHSWsKLS0tIHErckNjTWNIWGhRL3h2YXJwOEhl\nRWNuQTBLaWJKenFIVGw0R28weTNQUmcKR2T4vlxxfTUrlYv/JeFruTBTFvhcv7LX\nDz/FNNUyGHApgf4nwocdDpv1iBEUwM0vnrDMzVSoSHhnwjv/ZDVPCw==\n-----END AGE ENCRYPTED FILE-----\n
#ENC[AES256_GCM,data:ucMhtQ==,iv:sGhlYo+lSjTp5nwwZTSgMqT64ut4T80hx5CVT+g82lY=,tag:RMnG4i6LpK1Y5Bw4gFPqjw==,type:comment]
APP_NAME=ENC[AES256_GCM,data:qruXhXS0DfkEbgM=,iv:tlyiDXsNaIj5vHBaO0dE7mVi2c/IBsLBpsEgibS8DyA=,tag:qnWKM1VcaO3JsIfHz7qo/w==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:9bXIuM5FOXgp,iv:/df/NFPVVNpCtWSWdxfn5UkEeRmbtmJ1coMHvG6c414=,tag:wOtxv/ftApPR07ywoIvO4w==,type:str]
BASE_URL=ENC[AES256_GCM,data:C7l7voU3GttiIRbAZ3/dhHootWm7wQ==,iv:HyDI4yfZkBuRuvUUao//mu6nkfW/lyKDdXS472pJuK8=,tag:IXC5Xpe1YcEOCCTsnBuNDg==,type:str]
DEBUG=ENC[AES256_GCM,data:Aq7nwAo=,iv:px2NR94oiodO8FbCa+VMNptNR51sHavOmiQBQ667pVg=,tag:vHIwuM+sg2Lpvw+ZUMZVRw==,type:str]
#ENC[AES256_GCM,data:Bf/QsEOoDh1gfJIHwA==,iv:nSoT2Bnk9y2VxoL2opvUrSBImRstydELaSk2IO9NYPE=,tag:YAqhk8/qpW4UsMsjdS+RAw==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:CDh9bd1OesYs,iv:C6Dwn2h6BYXc21VBFYplpfhwNj8TPevSRvkPCarL7eo=,tag:BibcNChGFh1b2jCF67Nn0A==,type:str]
#ENC[AES256_GCM,data:56tQlB/WNuue,iv:lh7+zHQuoAC9jDEgI6/g41H/9gj544nDOwAYcFWjpQs=,tag:XIVb5LJKxlSQz5qPidwJQw==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:itpuoRhwDXFgEIg=,iv:2/UkQmyyzd3jaUgcxbMCmsflN9ubY+T/y3U9DIj0+3I=,tag:mLONYoV1YRldPS4fjmWo0A==,type:str]
#ENC[AES256_GCM,data:3AMoLpE=,iv:w3+deBCRpccgTepZ7/j36pkzUIWAmaM3KuJVpeN1qiA=,tag:V9cfSO0NOKy1WLVQ4OSQYA==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:QZ8=,iv:0KWT8VtITdDyBTM41wK4Xe29vbwbVXq5JI+Bk1C0zLk=,tag:y3DG33dd3YLhCC3RHfvJJw==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:J0Y=,iv:CTSBzRzrn5EHO7eNQsZH55vPx1l00WfaQ2tQOQPbNxY=,tag:jvP5sAHd/MkGtzQ5tJc3jw==,type:str]
#ENC[AES256_GCM,data:4AzExSrkvf6tPpvTuvhD,iv:WZBGtFORarHjnBVZbyIzeeY4qFePnrEfUVFIGshiytI=,tag:grHkOyLwjcti7sHvzSdVSg==,type:comment]
RESEND_API_KEY=ENC[AES256_GCM,data:7s9gnzHzzYNy,iv:+2c0tLkLGwx15iLBzlCK+NO0XLLOoar90KRXqN/HwuQ=,tag:2gYY2jxVRYvt5MpBCWb3Jg==,type:str]
EMAIL_FROM=ENC[AES256_GCM,data:66oS65zkYQJ4BXh9clfF861fwsR09gVgRT0hwZegpnURwA==,iv:9fTh7YU+DzIP6Cj+unxuzJa0mnHsHcwNfvhI6zffjC0=,tag:ZLaPF45Ns0F3xNynXW42uw==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:SkoYDLDQOCWLHGPFd6HQ1cCU/VHuU9jK9FSjNMHKxnCbXw==,iv:kt4tkw2BJF+Fz5oquje3OvMosqculK8Kxm6h30LWxj0=,tag:PSy1qSkCV+Z/z8dYGLV3BA==,type:str]
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:M0BNhZKQvfdT,iv:XIGForPlTQl3NO+hUp90D7sW+wZz4CmCWvPKHedL9MA=,tag:wbxHr0YBKKMOt8tGYG0dVQ==,type:str]
#ENC[AES256_GCM,data:XU7LmjtgBw==,iv:mTo7c7tQ4bCrUpRjfpkl/eTMv9qgVVwG2BwDJjDENng=,tag:pJlSVRi3sj1CgapPKDmcMw==,type:comment]
PADDLE_API_KEY=ENC[AES256_GCM,data:F/VGqmpu5Pja,iv:RIIaP3LsvnQ3pPQ4OpXlzz2N3vYnQEyrwFrukpHY3qs=,tag:MZJYLxddceccoAPgmW2tgA==,type:str]
PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:sMUzWxIx835F,iv:mXc20kRmnofJf4Th2O4sjJoyTAlKHUbmbkd3tl19VkA=,tag:a3/N0KT3v1bo/o65sTuB+A==,type:str]
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:AQ5VbLqVyoVr,iv:tqloKV/Vfo3P05QZDsc8p0bfnwcylsQgVjhF17AyE7U=,tag:7gpQEQVcxOu8YgN/518u/w==,type:str]
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:ZBhOIvcGpGWY,iv:ByQcuU88DjAUEs4x++8+3E80vyiDWqbA6VR4bG5oZuU=,tag:TXnesiQ7K+baSOH6XlzBPA==,type:str]
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:8Ayb93UcbMDSAg==,iv:DydRKcY3zHa30+L6g/2ooDZbtyMHy3yJ1ETRssqDkFs=,tag:1nqgd26CAeB6HQEGHQr16g==,type:str]
#ENC[AES256_GCM,data:c1C8AUiw,iv:vU1muGR79S+rr5dTQbzDEYZ5WZdpB2zaHEcEvPIgPYU=,tag:vr07Boz2lZtBscvlHGt10g==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:uZMJ+vSyXZkgJPRwY67HTzKCA5qA9vMp4lmCBw==,iv:/SuiWslYHayfB4eaJ4rOtqv/CFBt2GbtOe/83ZYvCxM=,tag:y0aJ/GVhZigxAHOklmLg9Q==,type:str]
UMAMI_API_TOKEN=ENC[AES256_GCM,data:aYqT3Xwytvrl,iv:MRei8ZxgohwsbDyP2xruEDdiZQaGA41IlSeb4oqr93A=,tag:0VnkdreND5HggCA0LS+sRg==,type:str]
#ENC[AES256_GCM,data:3QFdBeiJqY3UfkClvPs=,iv:2OUB0JZbwSwFOiNo4GO5fTF6WwSD++Gy9iy6EoH8VGM=,tag:mEszf5Zgvv4bUGE7Iqhf+g==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:gPCG,iv:AC3g0YDWdQRexRbod3m8UXNKzy/qn0C4LOy5kNCC3cc=,tag:pX9JwjdauMQkkcddYvk9Dg==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:S2Y=,iv:PzZDhpC06Vh8qZ2/ImgIIp8ENUhEIHkyzTi3Ob+PWw0=,tag:5hpdFo0nJaSUoA63inq3jg==,type:str]
#ENC[AES256_GCM,data:DS4SYOlYWDPAzQe9TbFKC4hSfPgt,iv:HI0pGyQnnBIo6Ufb5QlT6539QVgLNd3Q1E0nVZT2YNw=,tag:VbcykF5tPDULflTGSBw9Bg==,type:comment]
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:N/buXMd8Bmen,iv:AFvbKwo+oipuFOB4noaks//IQ92I7gvalUgLYJmp3h0=,tag:6nljZEU+01I2A6bYmHf3XA==,type:str]
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:AJujAQyPg+7i,iv:x3sC5WeoUtnJGN01J7p+8W5q8QCbI7g8kA6+njdgsHA=,tag:WUAjGE8rTizRppwl4zR3Xw==,type:str]
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:0uAA0+aYhDPS,iv:h3+1Tb7Z8JU+N5MsNOYy9oKfMZirFL1j8q0sCwol3bE=,tag:kUcuX79/CkFx8vXRq9LzaA==,type:str]
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:LevPHY6+xQzE,iv:YKSlo15j2JOpaDFE3fodV2xzrFPma23LoRtbmrcAwKk=,tag:lRKpLojIj91NP3rQj0lujg==,type:str]
#ENC[AES256_GCM,data:hCAkcPu59qPCG+gdRZc=,iv:fsXe8zekkwZsVyKhD15gJUy+nrmlkE3y5GTSxnrsSBI=,tag:QzuZ3hjg0RP5ydk9LLqZtQ==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:7ovwg981vYaSxbPLhliEGiE+f/8V,iv:8+sT3EGhN3qPAGXehVXFibxGqebShA4+fpV8PMH89RY=,tag:Ed6JR6X661riZ19K1Rka4g==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:gxDrCU7+J4hvzJGXPjxsvjZdJx7R,iv:ig+32NjlalAebrl24/V2L9cvtAGxCFLci/b9nBVsmrk=,tag:oJVU7BX7qMp++tGGYNxzqg==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:Pz7vkNm/xxdO+kn9,iv:QleME3pY9gwgDmW6Vly1LVRMWhdkD04BXyzO3gFX3YM=,tag:QOpMsiXccD7xPsNJ/uuHjg==,type:str]
#ENC[AES256_GCM,data:AQyf+O/gIVE40EU=,iv:uJwMEr44+W4hjRUMPoIhofJfWvXxJDQStRLyjGDdw2w=,tag:Ry07aeuhb6d4CXhpcFkv6A==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:yKOuO4XPNcPM14ZnKgLxpg==,iv:JJIkIHqDw7xessJZtwdxhp56UT9f7KtAHT1Hyi7Zd3A=,tag:PYMuH418TtcMuLBwrxDGsA==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:tunk/tbrbvhMHONwRHV1A2l2da50L4n4CeJly3fz9g==,iv:WvxDglP2QRpgOl+RelF2a+JuPvjwusvet4xZs2s+tp8=,tag:CJuzy6A0vUbXd2e5+BXNEQ==,type:str]
ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:oHSBbRbq9tCueTxoEJiTmKVJoBIERiV75rsHhUts2Jo=,iv:qm7O2GJ3Rlp3LE1PdQXZPzlO0lropGEe5Wr+28F05Cs=,tag:5Ha/2D4G3NjiqkCE1NjrEw==,type:str]
NTFY_TOKEN=ENC[AES256_GCM,data:jIWqTX+iEzUvjJbSKJIZ7ZzZn0YbM7+4RU1W16D6j2U=,iv:R6be5ijLBLUNZjxHO2YkbgjjQDZdjD8gGRJedcFRI60=,tag:K2GL+O5TjdnIgC5l7pG0tg==,type:str]
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:HQ==,iv:ZBSitlE8BIhM63+f3niABpM5kUmd07cg66icuVlGzc4=,tag:vxMBFQiuXmaKfeQtkx8oAQ==,type:str]
#ENC[AES256_GCM,data:5IbMzndnVQPyc8g=,iv:jrHRyj1c+AUJ5Jb8Omb3aliG0j0q850wIjA9tIqKbzI=,tag:n1if0sch0E1mBOp3qr2+zg==,type:comment]
PROXY_URLS=ENC[AES256_GCM,data:g8/iWdCPfTVt,iv:ZGvbYkfZk64Y2CC4vuGCj7TpRJGsSOP/psjz2pgKzow=,tag:GYvlKB1xn328bmpBN24W4g==,type:str]
EXTRACT_WORKERS=ENC[AES256_GCM,data:GQ==,iv:mUT35A3XBGaBox2PImgeWZyQx1AMQcPTnS4NJi1QnlI=,tag:dG/ZF2xlGsEJBBvfyG0hZg==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:vi4=,iv:2oPRzyrFgIwOGjEH13P/7VQACA3xqDOz8+O7cDUnPwI=,tag:IFh7z+ZF+zjoADH0gFrPXg==,type:str]
#ENC[AES256_GCM,data:cG6Bl04wlNIwNTjP5TSykDI=,iv:qOrJNlej+elCvc7paLRL50opAD7zSeHYmIwAeFuH11s=,tag:P1dTLqa/DSMjIFvDTZZqhQ==,type:comment]
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:wqtE1aoqcK6v,iv:QejEYIbxDcYDiFC9Wdes81cYx2NL3b9o0v0XVRh7G1w=,tag:d3u6Rf8ptOhM/4pwxtg+6A==,type:str]
GSC_SITE_URL=ENC[AES256_GCM,data:25zMmaOduZznbTQwO29huAo7xFq3Ow==,iv:ahq19rDzdx5PB/5YyHxZc7EPAeHya7loe3cdGm5ot+M=,tag:aVmC7IKkqYo8lAH1T1XDQA==,type:str]
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:pKZGODRwoo4D,iv:tPsrHU1B10lUkVJza2hub0lGAQ15xFZVePZ8RPI1XWo=,tag:zdWhYljkhnKAKRObOfII2A==,type:str]
BING_SITE_URL=ENC[AES256_GCM,data:VyIcIcxveu+dWz3zY6h/JVLjPw7ysw==,iv:Wj1TU7r9Izfyexp4WdoByRP+l01ZWml1mNgp6ys28EQ=,tag:fbC8Q7yFGeZBdiiDBADLwQ==,type:str]
#ENC[AES256_GCM,data:CDAjB0UL7OjgRPMmu97Z5HHjE4o4idn4Pb3N8/y8KHc=,iv:5ELBfYuFLZblCNMjPpZ10UxQqp9CzAIZQt7iSQwdR54=,tag:NguW5ISLMS5xpXSWfpJaIA==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:eaQPCcEreqBdHcw=,iv:CKD9cnL2BOn/yJM5EQs0Y044bAN3d4I6bRyTqhIQkns=,tag:82w/yCiGfKsV8zhpINL29g==,type:str]
CENSUS_API_KEY=
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWdVBTa1owM0JrbFJXcmg2\nZVNzRHJuK0MzY3gwbVdGZW1oWFh4VU84a3hjCmZWQnAybjVFSituRVE2eWt3QkpI\ndzAzMEpXeld6UEFraEZLUjJGSEordGMKLS0tIGtEbGd3ak82UnJiRjFDQXJvYkVO\nL0xYVW5Ya0U0QUYzckI2MWZyLzU0OUkKK7Q+mN6ew8pdpN7Z3zMQhWm/Lgkzu8Hi\n8i74oE6TfyKFQkhaCu4jOcBfYWTytMe38ZYLI0ApS5AeIsr/ZVtWGA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-02-23T21:02:13Z
sops_mac=ENC[AES256_GCM,data:KQefQTETh9w/SgTk37g+SU/fw+SZB2Mya0JTSENM2SLCS43hRUrTpZA/QCGuLbwRacgdkMp646BhYBX6JoEArW6Y/Jq1y5O66V10HliHLVfOEJ7DxaApPnczr9FM/nceYUOVWeYq2IXTmOtfNUhtCwpdXJJzEDRqJ0padGGH2+w=,iv:8Be6A0LThJX2fF3y9/4Hy82BPNb4NJOajrGF5kTaPAE=,tag:TK7qBGDVaA2DfxOmkuqCww==,type:str]
sops_lastmodified=2026-02-24T15:04:59Z
sops_mac=ENC[AES256_GCM,data:BsSyxkjwN8SNSC8fE3iNZPYIAPgrnbMYLGN/waGNkzH11VfcGAw5vQaPR6Il1PBMrx8gJ9daxRbvTW/DkY+G8VTpF3HWB3IoJPRewTLNUdkDSCxwhGuzfTzT1f7FKeNNVxsCEhJZGMYbDiYzcnbU1owgcHivBfCYl0DF0VM8cS0=,iv:aV5Af5nrhaI9NE2ouGnr20s6mRD9VPHLNcdfola9Ybw=,tag:YD08kkuFLksllK4Q9cfYfQ==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -7,6 +7,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Dual market score system** — split the single market score into two branded scores:
- **padelnomics Marktreife-Score™** (market maturity): existing score, refined — only for cities
with ≥1 padel venue. Adds ×0.85 saturation discount when `venues_per_100k > 8`.
- **padelnomics Marktpotenzial-Score™** (investment opportunity): new score covering ALL
GeoNames locations globally (pop ≥1K), including zero-court locations. Rewards supply gaps,
underserved catchment areas, and racket sport culture via inverted venue density signal.
- **Tennis court Overpass extractor** — `extract-overpass-tennis` downloads all OSM
`sport=tennis` nodes/ways/relations globally (~150K+ features). Lands at
`overpass_tennis/{year}/{month}/courts.json.gz`. Staged in `stg_tennis_courts`.
- **`foundation.dim_locations`** — new conformed dimension seeded from GeoNames (all locations
≥1K pop), not from padel venues. Grain `(country_code, geoname_id)`. Enriched with:
- `nearest_padel_court_km` via `ST_Distance_Sphere` (DuckDB spatial extension)
- `padel_venue_count` / `padel_venues_per_100k` (venues within 5km)
- `tennis_courts_within_25km` (courts within 25km)
- **GeoNames expanded** — extractor switched from `cities15000` (50K+ filter, ~24K rows) to
`cities1000` (~140K locations, pop ≥1K). Added `lat`, `lon`, `admin1_code`, `admin2_code`
to output. Expanded feature codes to include `PPLA3/4/5` (Gemeinden/cantons).
- **DuckDB spatial extension** — `extensions: [spatial]` added to `config.yaml`. Enables
`ST_Distance_Sphere` for great-circle distance and future map features (bounding box
queries, geometry columns).
- **SOPS secrets** — `GEONAMES_USERNAME=padelnomics` and `CENSUS_API_KEY` added to both
`.env.dev.sops` and `.env.prod.sops`.
- **Methodology page updated** — `/en/market-score` now documents both scores with:
Two Scores intro section, component cards for each score (4 Marktreife + 5 Marktpotenzial),
score band interpretations, expanded FAQ (7 entries). Section headings use the padelnomics
wordmark span (Bricolage Grotesque). Bilingual EN + DE (native-quality German, no calques).
- **Market Score methodology page** — standalone page at `/{lang}/market-score`
explaining the padelnomics Market Score (Zillow Zestimate-style). Reveals four
input categories (demographics, economic strength, demand evidence, data

View File

@@ -135,7 +135,7 @@
## In Progress 🔄
_Move here when you start working on it._
- [ ] **Dual market score system** — Marktreife-Score + Marktpotenzial-Score + expanded data pipeline (merging to master)
---
@@ -155,6 +155,13 @@ _Move here when you start working on it._
| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools (SEO hub ready — just add env vars) |
| Verify Litestream R2 backup running on prod | |
### Gemeinde-level pSEO (follow-up from dual score work)
| 🛠 Tech |
|--------|
| Gemeinde-level pSEO article template — consumes `location_opportunity_profile` data, targets "Padel in [Ort]" + "Padel bauen in [Ort]" queries (zero SERP competition confirmed) |
| "Top 50 underserved locations" ranking page — high-value SEO content, fully programmatic from `location_opportunity_profile` ORDER BY opportunity_score DESC |
### Week 12 — First Revenue
| 🛠 Tech | 📣 Business |
@@ -196,6 +203,9 @@ _Move here when you start working on it._
- [ ] Padel Hall Accelerator (€999 — report + call + supplier intros)
### Data & Intelligence
- [ ] Sports centre Overpass extract (`leisure=sports_centre`) — additional market signal for `dim_locations`
- [ ] City-level income enrichment (Eurostat NUTS-3 regional income — replaces country-level PPS proxy, higher granularity)
- [ ] Interactive opportunity map / explorer in web app (map UI over `location_opportunity_profile` — bounding box queries via ST_Distance_Sphere)
- [ ] Multi-source data aggregation (add booking platforms beyond Playtomic)
- [ ] Google Maps signals (reviews, ratings)
- [ ] Weather + demographic overlays
@@ -246,3 +256,4 @@ _Move here when you start working on it._
| 2026-02-22 | Credit system over pay-per-lead blast | Suppliers self-select → higher quality perception; scales without manual intervention |
| 2026-02-22 | No soft email gate on planner | Planner already captures emails at natural points (scenario save → login, quote wizard step 9). Gate would add friction without meaningful list value. Revisit if data shows a gap. |
| 2026-02-22 | Wipe test suppliers before launch | 5 `example.com` entries from seed_dev_data.py — empty directory with "Be the first" CTA is better than obviously fake data |
| 2026-02-24 | Split market score into two branded scores | Marktreife-Score (existing market maturity, cities with ≥1 venue) vs Marktpotenzial-Score (greenfield opportunity, all GeoNames locations globally). SERP analysis confirmed zero competition for hyperlocal Gemeinde-level market intelligence pages. |

View File

@@ -13,7 +13,8 @@ Purpose: Identify and track data sources feeding the Padelnomics DuckDB analytic
| Source | Category | Status | Score | Credentials | Pipeline refs |
|--------|----------|--------|-------|-------------|---------------|
| OpenStreetMap / Overpass | Court locations | ✅ Ingested | 5 | None | `extract-overpass``stg_padel_courts` |
| OpenStreetMap / Overpass (padel) | Court locations | ✅ Ingested | 5 | None | `extract-overpass``stg_padel_courts` |
| OpenStreetMap / Overpass (tennis) | Court locations | ✅ Ingested | 4 | None | `extract-overpass-tennis``stg_tennis_courts` |
| Playtomic — tenants | Court locations | ✅ Ingested | 5 | None | `extract-playtomic-tenants``stg_playtomic_venues/resources/opening_hours` |
| Playtomic — availability | Pricing / utilisation | ✅ Ingested | 5 | None | `extract-playtomic-availability``stg_playtomic_availability` |
| Eurostat `urb_cpop1` | Demographics — EU city population | ✅ Ingested | 5 | None | `extract-eurostat``stg_population` |
@@ -21,7 +22,7 @@ Purpose: Identify and track data sources feeding the Padelnomics DuckDB analytic
| Eurostat SDMX city labels | Demographics — EU city lookup | ✅ Ingested | 4 | None | `extract-eurostat-city-labels``stg_city_labels` |
| ONS UK mid-year estimates | Demographics — UK population | ✅ Ingested | 4 | None | `extract-ons-uk``stg_population_uk` |
| US Census ACS 5-year | Demographics — US population | ✅ Ingested† | 3 | `CENSUS_API_KEY` (free) | `extract-census-usa``stg_population_usa` |
| GeoNames cities15000 | Demographics — global fallback | ✅ Ingested† | 3 | `GEONAMES_USERNAME` (free) | `extract-geonames``stg_population_geonames` |
| GeoNames cities1000 | Demographics — global locations ≥1K pop | ✅ Ingested† | 4 | `GEONAMES_USERNAME=padelnomics` (free) | `extract-geonames``stg_population_geonames``dim_locations` |
| ECB / Frankfurter.app | FX rates | 🔲 Planned | 4 | None | `extract-fx``stg_fx_rates` (proposed) |
| FIP World Padel Report | Market reports | 🔲 Planned | 4 | None (PDF) | Annual seed table |
| PadelAPI.org | Tournament data | 🔲 Planned | 3 | Free-tier token | 50k req/mo |

View File

@@ -11,6 +11,7 @@ dependencies = [
[project.scripts]
extract = "padelnomics_extract.all:main"
extract-overpass = "padelnomics_extract.overpass:main"
extract-overpass-tennis = "padelnomics_extract.overpass_tennis:main"
extract-eurostat = "padelnomics_extract.eurostat:main"
extract-playtomic-tenants = "padelnomics_extract.playtomic_tenants:main"
extract-playtomic-availability = "padelnomics_extract.playtomic_availability:main"

View File

@@ -17,6 +17,8 @@ from .ons_uk import EXTRACTOR_NAME as ONS_UK_NAME
from .ons_uk import extract as extract_ons_uk
from .overpass import EXTRACTOR_NAME as OVERPASS_NAME
from .overpass import extract as extract_overpass
from .overpass_tennis import EXTRACTOR_NAME as OVERPASS_TENNIS_NAME
from .overpass_tennis import extract as extract_overpass_tennis
from .playtomic_availability import EXTRACTOR_NAME as AVAILABILITY_NAME
from .playtomic_availability import extract as extract_availability
from .playtomic_tenants import EXTRACTOR_NAME as TENANTS_NAME
@@ -26,6 +28,7 @@ logger = setup_logging("padelnomics.extract")
EXTRACTORS = [
(OVERPASS_NAME, extract_overpass),
(OVERPASS_TENNIS_NAME, extract_overpass_tennis),
(EUROSTAT_NAME, extract_eurostat),
(EUROSTAT_CITY_LABELS_NAME, extract_eurostat_city_labels),
(CENSUS_USA_NAME, extract_census_usa),

View File

@@ -1,17 +1,20 @@
"""GeoNames global city population extractor.
Downloads the cities15000.zip bulk file (~1.5MB compressed, ~26K entries) from
GeoNames and filters to cities with population ≥ 50,000 and feature codes in
{PPLA, PPLA2, PPLC, PPL} (populated places, avoiding parks, airports, etc.).
Downloads the cities1000.zip bulk file (~30MB compressed, ~140K entries) from
GeoNames. Includes all populated places with population ≥ 1,000 and feature codes
in {PPLA, PPLA2, PPLA3, PPLA4, PPLA5, PPLC, PPL}.
Used as the global fallback for population when Eurostat/Census/ONS don't cover
a country. Padel is expanding globally so this catches UAE, Australia, Argentina, etc.
This broader coverage (vs. the old cities15000 with ≥50K filter) supports
Gemeinde-level market intelligence pages — small municipalities often have the
highest padel investment opportunity (white space markets).
Requires: GEONAMES_USERNAME env var (free registration at geonames.org)
Landing: {LANDING_DIR}/geonames/{year}/{month}/cities_global.json.gz
Output: {"rows": [{"geoname_id": 2950159, "city_name": "Berlin",
"country_code": "DE", "population": 3644826,
"lat": 52.524, "lon": 13.411,
"admin1_code": "16", "admin2_code": "00",
"ref_year": 2024}], "count": N}
"""
@@ -31,24 +34,33 @@ logger = setup_logging("padelnomics.extract.geonames")
EXTRACTOR_NAME = "geonames"
DOWNLOAD_URL = "https://download.geonames.org/export/dump/cities15000.zip"
DOWNLOAD_URL = "https://download.geonames.org/export/dump/cities1000.zip"
# Only populated place feature codes — excludes airports, parks, admin areas, etc.
# PPLC = capital of a political entity
# PPLA = seat of a first-order administrative division
# PPLA2 = seat of a second-order admin division
# PPLA3 = seat of a third-order admin division (Gemeinden, cantons, etc.)
# PPLA4 = seat of a fourth-order admin division
# PPLA5 = seat of a fifth-order admin division
# PPL = populated place
VALID_FEATURE_CODES = {"PPLC", "PPLA", "PPLA2", "PPL"}
VALID_FEATURE_CODES = {"PPLC", "PPLA", "PPLA2", "PPLA3", "PPLA4", "PPLA5", "PPL"}
MIN_POPULATION = 50_000
# No population floor — cities1000.zip is pre-filtered to ≥ 1,000.
# Accept all to maximise Gemeinde-level coverage.
MIN_POPULATION = 0
# GeoNames tab-separated column layout for cities15000.txt
# GeoNames tab-separated column layout for cities1000.txt
# https://download.geonames.org/export/dump/readme.txt
COL_GEONAME_ID = 0
COL_NAME = 1
COL_ASCIINAME = 2
COL_COUNTRY_CODE = 8
COL_LAT = 4
COL_LON = 5
COL_FEATURE_CODE = 7
COL_COUNTRY_CODE = 8
COL_ADMIN1_CODE = 10
COL_ADMIN2_CODE = 11
COL_POPULATION = 14
COL_MODIFICATION_DATE = 18
@@ -86,10 +98,21 @@ def _parse_cities_txt(content: bytes) -> list[dict]:
country_code = parts[COL_COUNTRY_CODE].strip().upper()
if not city_name or not country_code:
continue
try:
lat = float(parts[COL_LAT])
lon = float(parts[COL_LON])
except (ValueError, IndexError):
continue
admin1_code = parts[COL_ADMIN1_CODE].strip() if len(parts) > COL_ADMIN1_CODE else ""
admin2_code = parts[COL_ADMIN2_CODE].strip() if len(parts) > COL_ADMIN2_CODE else ""
rows.append({
"geoname_id": geoname_id,
"city_name": city_name,
"country_code": country_code,
"lat": lat,
"lon": lon,
"admin1_code": admin1_code or None,
"admin2_code": admin2_code or None,
"population": population,
"ref_year": REF_YEAR,
})
@@ -102,7 +125,7 @@ def extract(
conn: sqlite3.Connection,
session: niquests.Session,
) -> dict:
"""Download GeoNames cities15000.zip. Skips if already run this month."""
"""Download GeoNames cities1000.zip. Skips if already run this month."""
username = os.environ.get("GEONAMES_USERNAME", "").strip()
if not username:
logger.warning("GEONAMES_USERNAME not set — writing empty placeholder so SQLMesh models can run")
@@ -120,25 +143,25 @@ def extract(
year, month = year_month.split("/")
# GeoNames bulk downloads don't require the username in the URL for cities15000.zip,
# GeoNames bulk downloads don't require the username in the URL for cities1000.zip,
# but the username signals acceptance of their terms of use and helps their monitoring.
url = f"{DOWNLOAD_URL}?username={username}"
logger.info("GET cities15000.zip (~1.5MB compressed)")
resp = session.get(url, timeout=HTTP_TIMEOUT_SECONDS * 4)
logger.info("GET cities1000.zip (~30MB compressed, ~140K locations)")
resp = session.get(url, timeout=HTTP_TIMEOUT_SECONDS * 10)
resp.raise_for_status()
assert len(resp.content) > 100_000, (
f"cities15000.zip too small ({len(resp.content)} bytes) — download may have failed"
assert len(resp.content) > 1_000_000, (
f"cities1000.zip too small ({len(resp.content)} bytes) — download may have failed"
)
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
txt_name = next((n for n in zf.namelist() if n.endswith(".txt")), None)
assert txt_name, f"No .txt file in cities15000.zip: {zf.namelist()}"
assert txt_name, f"No .txt file in cities1000.zip: {zf.namelist()}"
txt_content = zf.read(txt_name)
rows = _parse_cities_txt(txt_content)
assert len(rows) > 5_000, f"Expected >5000 global cities ≥50K pop, got {len(rows)}"
logger.info("parsed %d global cities with population ≥%d", len(rows), MIN_POPULATION)
assert len(rows) > 100_000, f"Expected >100K global locations (pop ≥1K), got {len(rows)}"
logger.info("parsed %d global locations (pop ≥1K)", len(rows))
dest_dir = landing_path(landing_dir, "geonames", year, month)
dest = dest_dir / "cities_global.json.gz"

View File

@@ -0,0 +1,78 @@
"""Overpass API extractor — global tennis court locations from OpenStreetMap.
Queries the Overpass API for all nodes/ways/relations tagged sport=tennis.
Tennis court density near a location is a proxy for racket-sport culture —
areas with many tennis clubs are prime candidates for padel adoption.
The query returns ~150K+ results globally (vs ~5K for padel), so a higher
Overpass timeout is used.
Landing: {LANDING_DIR}/overpass_tennis/{year}/{month}/courts.json.gz
"""
import sqlite3
from pathlib import Path
import niquests
from ._shared import OVERPASS_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import landing_path, write_gzip_atomic
logger = setup_logging("padelnomics.extract.overpass_tennis")
EXTRACTOR_NAME = "overpass_tennis"
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Tennis returns ~150K+ elements globally vs ~5K for padel — use 3× timeout.
TENNIS_OVERPASS_TIMEOUT_SECONDS = OVERPASS_TIMEOUT_SECONDS * 3
OVERPASS_QUERY = (
"[out:json][timeout:180];\n"
"(\n"
' node["sport"="tennis"];\n'
' way["sport"="tennis"];\n'
' relation["sport"="tennis"];\n'
");\n"
"out body;"
)
def extract(
landing_dir: Path,
year_month: str,
conn: sqlite3.Connection,
session: niquests.Session,
) -> dict:
"""POST OverpassQL query for tennis courts and write raw OSM JSON. Returns run metrics."""
year, month = year_month.split("/")
dest_dir = landing_path(landing_dir, "overpass_tennis", year, month)
dest = dest_dir / "courts.json.gz"
logger.info("POST %s (sport=tennis, ~150K+ results expected)", OVERPASS_URL)
resp = session.post(
OVERPASS_URL,
data={"data": OVERPASS_QUERY},
timeout=TENNIS_OVERPASS_TIMEOUT_SECONDS,
)
resp.raise_for_status()
size_bytes = len(resp.content)
logger.info("%s bytes received", f"{size_bytes:,}")
bytes_written = write_gzip_atomic(dest, resp.content)
logger.info("wrote %s (%s bytes compressed)", dest, f"{bytes_written:,}")
return {
"files_written": 1,
"files_skipped": 0,
"bytes_written": bytes_written,
"cursor_value": year_month,
}
def main() -> None:
run_extractor(EXTRACTOR_NAME, extract)
if __name__ == "__main__":
main()

View File

@@ -55,15 +55,16 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
| Dimension | Grain | Used by |
|-----------|-------|---------|
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
| `foundation.dim_cities` | `city_slug` | `serving.city_market_profile` → all pSEO serving models |
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.city_market_profile` → all pSEO serving models |
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
| `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
## Source integration map
```
stg_playtomic_venues ─┐
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ─→ city_market_profile
stg_padel_courts ─┘ └→ dim_venue_capacity
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score)
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
@@ -71,8 +72,33 @@ stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_a
stg_population ──→ dim_cities ─────────────────────────────┘
stg_income ──→ dim_cities
stg_population_geonames ─┐
stg_padel_courts ─┤→ dim_locations ──→ location_opportunity_profile
stg_tennis_courts ─┤ (Marktpotenzial-Score)
stg_income ─┘
```
## Distance calculation pattern (ST_Distance_Sphere)
Use a bounding-box pre-filter before calling `ST_Distance_Sphere` to avoid full cross-joins:
```sql
-- Nearest padel court (km) per location
SELECT l.geoname_id,
MIN(ST_Distance_Sphere(
ST_Point(l.lon, l.lat), ST_Point(p.lon, p.lat)
) / 1000.0) AS nearest_km
FROM locations l
JOIN padel_courts p
ON ABS(l.lat - p.lat) < 0.5 -- ~55km pre-filter
AND ABS(l.lon - p.lon) < 0.5
GROUP BY l.geoname_id
```
Requires `extensions: [spatial]` in `config.yaml` (already set). DuckDB spatial must
`INSTALL spatial; LOAD spatial;` before `ST_Distance_Sphere` / `ST_Point` are available.
## Common pitfalls
- **Don't add business logic to staging.** Even a CASE statement renaming values = business

View File

@@ -4,6 +4,8 @@ gateways:
type: duckdb
catalogs:
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
extensions:
- spatial
default_gateway: duckdb

View File

@@ -0,0 +1,183 @@
-- Location dimension: all known populated places globally (GeoNames cities1000).
-- This is the opportunity-scoring root — NOT filtered to places with padel courts.
-- Grain: (country_code, geoname_id) — stable GeoNames numeric ID per location.
--
-- Unlike dim_cities (seeded from dim_venues / existing padel markets), dim_locations
-- covers all locations with population ≥ 1K so zero-court Gemeinden score fully.
--
-- Enriched with:
-- stg_income → country-level median income PPS
-- stg_padel_courts → padel venue count + nearest court distance (km)
-- stg_tennis_courts → tennis court count within 25km radius
--
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
-- A bounding-box pre-filter (~0.5°, ≈55km) reduces the cross-join before the
-- exact sphere distance is computed.
MODEL (
name foundation.dim_locations,
kind FULL,
cron '@daily',
grain (country_code, geoname_id)
);
WITH
-- Base: all GeoNames locations with valid coordinates
locations AS (
SELECT
geoname_id,
city_name AS location_name,
-- URL-safe location slug
LOWER(REGEXP_REPLACE(LOWER(city_name), '[^a-z0-9]+', '-')) AS location_slug,
country_code,
lat,
lon,
admin1_code,
admin2_code,
population,
population_year AS population_year,
ref_year
FROM staging.stg_population_geonames
WHERE lat IS NOT NULL AND lon IS NOT NULL
),
-- Country income (same source and pattern as dim_cities)
country_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Padel court lat/lon for distance and density calculations
padel_courts AS (
SELECT lat, lon, country_code
FROM staging.stg_padel_courts
WHERE lat IS NOT NULL AND lon IS NOT NULL
),
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
nearest_padel AS (
SELECT
l.geoname_id,
MIN(
ST_Distance_Sphere(
ST_Point(l.lon, l.lat),
ST_Point(p.lon, p.lat)
) / 1000.0
) AS nearest_padel_court_km
FROM locations l
JOIN padel_courts p
-- ~55km bounding box pre-filter to limit cross-join before sphere calc
ON ABS(l.lat - p.lat) < 0.5
AND ABS(l.lon - p.lon) < 0.5
GROUP BY l.geoname_id
),
-- Padel venues within 5km of each location (counts as "local padel supply")
padel_local AS (
SELECT
l.geoname_id,
COUNT(*) AS padel_venue_count
FROM locations l
JOIN padel_courts p
ON ABS(l.lat - p.lat) < 0.05 -- ~5km bbox pre-filter
AND ABS(l.lon - p.lon) < 0.05
WHERE ST_Distance_Sphere(
ST_Point(l.lon, l.lat),
ST_Point(p.lon, p.lat)
) / 1000.0 <= 5.0
GROUP BY l.geoname_id
),
-- Tennis courts within 25km of each location (sports culture proxy)
tennis_nearby AS (
SELECT
l.geoname_id,
COUNT(*) AS tennis_courts_within_25km
FROM locations l
JOIN staging.stg_tennis_courts t
ON ABS(l.lat - t.lat) < 0.23 -- ~25km bbox pre-filter
AND ABS(l.lon - t.lon) < 0.23
WHERE ST_Distance_Sphere(
ST_Point(l.lon, l.lat),
ST_Point(t.lon, t.lat)
) / 1000.0 <= 25.0
GROUP BY l.geoname_id
)
SELECT
l.geoname_id,
l.country_code,
-- Human-readable country name (consistent with dim_cities)
CASE l.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE l.country_code
END AS country_name_en,
-- URL-safe country slug
LOWER(REGEXP_REPLACE(
CASE l.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE l.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
l.admin1_code,
l.admin2_code,
l.population,
l.ref_year AS population_year,
ci.median_income_pps,
ci.income_year,
COALESCE(pl.padel_venue_count, 0)::INTEGER AS padel_venue_count,
-- Venues per 100K residents (NULL if population = 0)
CASE WHEN l.population > 0
THEN ROUND(COALESCE(pl.padel_venue_count, 0)::DOUBLE / l.population * 100000, 2)
ELSE NULL
END AS padel_venues_per_100k,
np.nearest_padel_court_km,
COALESCE(tn.tennis_courts_within_25km, 0)::INTEGER AS tennis_courts_within_25km,
CURRENT_DATE AS refreshed_date
FROM locations l
LEFT JOIN country_income ci ON l.country_code = ci.country_code
LEFT JOIN nearest_padel np ON l.geoname_id = np.geoname_id
LEFT JOIN padel_local pl ON l.geoname_id = pl.geoname_id
LEFT JOIN tennis_nearby tn ON l.geoname_id = tn.geoname_id
-- Enforce grain: deduplicate if city slug collides within same country
QUALIFY ROW_NUMBER() OVER (
PARTITION BY l.country_code, l.geoname_id
ORDER BY l.population DESC NULLS LAST
) = 1

View File

@@ -1,11 +1,16 @@
-- One Big Table: per-city padel market intelligence.
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
--
-- Market score v2 (0100):
-- 30 pts population — log-scaled to 1M+ city ceiling (was 40pts/500K)
-- Padelnomics Marktreife-Score v2 (0100):
-- Answers "How mature/established is this padel market?"
-- Only computed for cities with ≥1 padel venue (padel_venue_count > 0).
-- For white-space opportunity scoring, see serving.location_opportunity_profile.
--
-- 30 pts population — log-scaled to 1M+ city ceiling
-- 25 pts income PPS — normalised to 200 ceiling (covers CH/NO/LU outliers)
-- 30 pts demand — observed occupancy if available, else venue density
-- 15 pts data quality — completeness discount, not a market signal
-- ×0.85 saturation — discount when venues_per_100k > 8 (oversupplied market)
MODEL (
name serving.city_market_profile,
@@ -73,7 +78,11 @@ scored AS (
-- Data quality (15 pts): measures completeness, not market quality.
-- Reduced from 20pts — kept as confidence discount, not market signal.
+ 15.0 * data_confidence
, 1) AS market_score
, 1)
-- Saturation discount: venues_per_100k > 8 signals oversupply.
-- ~8/100K ≈ Spain-tier density; above this marginal return decreases.
* CASE WHEN venues_per_100k > 8 THEN 0.85 ELSE 1.0 END
AS market_score
FROM base
)
SELECT

View File

@@ -0,0 +1,69 @@
-- Per-location padel investment opportunity intelligence.
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
--
-- Padelnomics Marktpotenzial-Score (0100):
-- Answers "Where should I build a padel court?"
-- Covers ALL GeoNames locations (pop ≥ 1K) — NOT filtered to existing padel markets.
-- Zero-court locations score highest on supply gap component (white space = opportunity).
--
-- 25 pts addressable market — log-scaled population, ceiling 500K
-- (opportunity peaks in mid-size cities; megacities already served)
-- 20 pts economic power — country income PPS, normalised to 200
-- 30 pts supply gap — INVERTED venue density; 0 courts/100K = full marks
-- 15 pts catchment gap — distance to nearest padel court (>30km = full marks)
-- 10 pts sports culture — tennis courts within 25km (≥10 = full marks)
MODEL (
name serving.location_opportunity_profile,
kind FULL,
cron '@daily',
grain (country_code, geoname_id)
);
SELECT
l.geoname_id,
l.country_code,
l.country_name_en,
l.country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
l.admin1_code,
l.admin2_code,
l.population,
l.population_year,
l.median_income_pps,
l.income_year,
l.padel_venue_count,
l.padel_venues_per_100k,
l.nearest_padel_court_km,
l.tennis_courts_within_25km,
ROUND(
-- Addressable market (25 pts): log-scaled to 500K ceiling.
-- Lower ceiling than Marktreife (1M) — opportunity peaks in mid-size cities
-- that can support a court but aren't already saturated by large-city operators.
25.0 * LEAST(1.0, LN(GREATEST(l.population, 1)) / LN(500000))
-- Economic power (20 pts): country-level income PPS normalised to 200.
-- Drives willingness-to-pay for court fees (€20-35/hr target range).
+ 20.0 * LEAST(1.0, COALESCE(l.median_income_pps, 100) / 200.0)
-- Supply gap (30 pts): INVERTED venue density.
-- 0 courts/100K = full 30 pts (white space); ≥4/100K = 0 pts (served market).
-- This is the key signal that separates Marktpotenzial from Marktreife.
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(l.padel_venues_per_100k, 0) / 4.0)
-- Catchment gap (15 pts): distance to nearest existing padel court.
-- >30km = full 15 pts (underserved catchment area).
-- NULL = no courts found anywhere (rare edge case) → neutral 0.5.
+ 15.0 * COALESCE(LEAST(1.0, l.nearest_padel_court_km / 30.0), 0.5)
-- Sports culture proxy (10 pts): tennis courts within 25km.
-- ≥10 courts = full 10 pts (proven racket sport market = faster padel adoption).
-- 0 courts = 0 pts. Many new padel courts open inside existing tennis clubs.
+ 10.0 * LEAST(1.0, l.tennis_courts_within_25km / 10.0)
, 1) AS opportunity_score,
CURRENT_DATE AS refreshed_date
FROM foundation.dim_locations l
ORDER BY opportunity_score DESC

View File

@@ -1,5 +1,6 @@
-- GeoNames global city population (cities15000 bulk dataset, filtered to ≥50K).
-- GeoNames global city/municipality population (cities1000 bulk dataset, pop ≥ 1K).
-- Global fallback for countries not covered by Eurostat, Census, or ONS.
-- Broad coverage (140K+ locations) enables Gemeinde-level market intelligence.
-- One row per geoname_id (GeoNames stable numeric identifier).
--
-- Source: data/landing/geonames/{year}/{month}/cities_global.json.gz
@@ -16,6 +17,10 @@ WITH parsed AS (
TRY_CAST(row ->> 'geoname_id' AS INTEGER) AS geoname_id,
row ->> 'city_name' AS city_name,
row ->> 'country_code' AS country_code,
TRY_CAST(row ->> 'lat' AS DOUBLE) AS lat,
TRY_CAST(row ->> 'lon' AS DOUBLE) AS lon,
row ->> 'admin1_code' AS admin1_code,
row ->> 'admin2_code' AS admin2_code,
TRY_CAST(row ->> 'population' AS BIGINT) AS population,
TRY_CAST(row ->> 'ref_year' AS INTEGER) AS ref_year,
CURRENT_DATE AS extracted_date
@@ -32,6 +37,10 @@ SELECT
geoname_id,
TRIM(city_name) AS city_name,
UPPER(country_code) AS country_code,
lat,
lon,
NULLIF(TRIM(admin1_code), '') AS admin1_code,
NULLIF(TRIM(admin2_code), '') AS admin2_code,
population,
ref_year,
extracted_date
@@ -40,3 +49,5 @@ WHERE population IS NOT NULL
AND population > 0
AND geoname_id IS NOT NULL
AND city_name IS NOT NULL
AND lat IS NOT NULL
AND lon IS NOT NULL

View File

@@ -0,0 +1,72 @@
-- Tennis court locations from OpenStreetMap via Overpass API (sport=tennis).
-- Used as a "racket sport culture" signal in the opportunity score:
-- areas with high tennis court density are prime padel adoption markets.
--
-- Source: data/landing/overpass_tennis/{year}/{month}/courts.json.gz
MODEL (
name staging.stg_tennis_courts,
kind FULL,
cron '@daily',
grain osm_id
);
WITH parsed AS (
SELECT
elem ->> 'type' AS osm_type,
(elem ->> 'id')::BIGINT AS osm_id,
TRY_CAST(elem ->> 'lat' AS DOUBLE) AS lat,
TRY_CAST(elem ->> 'lon' AS DOUBLE) AS lon,
elem -> 'tags' ->> 'name' AS name,
elem -> 'tags' ->> 'addr:country' AS country_code,
elem -> 'tags' ->> 'addr:city' AS city_tag,
filename AS source_file,
CURRENT_DATE AS extracted_date
FROM (
SELECT UNNEST(elements) AS elem, filename
FROM read_json(
@LANDING_DIR || '/overpass_tennis/*/*/courts.json.gz',
format = 'auto',
filename = true
)
)
WHERE (elem ->> 'type') IS NOT NULL
),
deduped AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY osm_id ORDER BY extracted_date DESC) AS rn
FROM parsed
WHERE osm_type = 'node'
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN -90 AND 90
AND lon BETWEEN -180 AND 180
),
with_country AS (
SELECT
osm_id, lat, lon,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE'
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
ELSE NULL
END) AS country_code,
NULLIF(TRIM(name), '') AS name,
NULLIF(TRIM(city_tag), '') AS city,
extracted_date
FROM deduped
WHERE rn = 1
)
SELECT
osm_id,
lat,
lon,
country_code,
name,
city,
extracted_date
FROM with_country

View File

@@ -1628,8 +1628,16 @@
"mscore_og_desc": "Ein datengestützter Komposit-Score (0100), der die Attraktivität einer Stadt für Padelanlagen-Investitionen misst. Was steckt dahinter — und was bedeutet er für Deine Planung?",
"mscore_h1": "Der padelnomics Market Score",
"mscore_subtitle": "Ein datengestütztes Maß für die Attraktivität einer Stadt als Padel-Investitionsstandort.",
"mscore_what_h2": "Was der Score misst",
"mscore_what_intro": "Der Market Score ist ein Komposit-Index von 0 bis 100, der das Potenzial einer Stadt als Standort für Padelanlagen-Investitionen bewertet. Vier Datenkategorien fließen in eine einzige Kennzahl ein — damit Du schnell einschätzen kannst, welche Märkte sich genauer anzuschauen lohnen.",
"mscore_dual_h2": "Zwei Scores, zwei Fragen",
"mscore_dual_intro": "Padelnomics veröffentlicht zwei eigenständige Scores für jeden Markt. Sie beantworten unterschiedliche Fragen und basieren auf unterschiedlichen Methoden — beide zu kennen ist entscheidend für eine fundierte Investitionsentscheidung.",
"mscore_reife_chip": "padelnomics Marktreife-Score™",
"mscore_reife_question": "Wie etabliert ist dieser Padel-Markt?",
"mscore_reife_desc": "Berechnet für Städte mit mindestens einer Padelanlage. Kombiniert Bevölkerungsgröße, Wirtschaftskraft, Nachfragesignale aus Buchungsplattformen und Datenvollständigkeit.",
"mscore_potenzial_chip": "padelnomics Marktpotenzial-Score™",
"mscore_potenzial_question": "Wo sollte ich eine Padelanlage bauen?",
"mscore_potenzial_desc": "Berechnet für alle Standorte weltweit, auch dort, wo es noch keine Anlagen gibt. Angebotslücken, unterversorgte Einzugsgebiete und Schlägersportkultur schlagen positiv zu Buche — die entscheidenden Signale für Erstinvestitionen.",
"mscore_what_h2": "Marktreife-Score: Was er misst",
"mscore_what_intro": "Der padelnomics Marktreife-Score ist ein Komposit-Index von 0 bis 100, der bewertet, wie etabliert und attraktiv ein bestehender Padel-Markt ist. Er gilt ausschließlich für Städte mit mindestens einer Padelanlage — vier Datenkategorien fließen in eine einzige Kennzahl ein, damit Du schnell einschätzen kannst, welche Märkte sich genauer anzuschauen lohnen.",
"mscore_cat_demo_h3": "Demografie",
"mscore_cat_demo_p": "Bevölkerungsgröße als Indikator für den adressierbaren Markt. Größere Städte tragen in der Regel mehr Anlagen und höhere Auslastung.",
"mscore_cat_econ_h3": "Wirtschaftskraft",
@@ -1638,7 +1646,7 @@
"mscore_cat_demand_p": "Signale aus dem laufenden Betrieb bestehender Anlagen — Auslastungsraten, Buchungsdaten, Anzahl aktiver Standorte. Wo sich reale Nachfrage bereits messen lässt, ist das der stärkste Indikator.",
"mscore_cat_data_h3": "Datenqualität",
"mscore_cat_data_p": "Wie umfassend die Datenlage für eine Stadt ist. Ein Score auf Basis unvollständiger Daten ist weniger belastbar — wir machen das transparent, damit Du weißt, wo eigene Recherche sinnvoll ist.",
"mscore_read_h2": "Wie Du den Score liest",
"mscore_read_h2": "Marktreife-Score: Wie Du ihn liest",
"mscore_band_high_label": "70100: Starker Markt",
"mscore_band_high_p": "Große Bevölkerung, hohe Wirtschaftskraft und nachgewiesene Nachfrage durch bestehende Anlagen. Diese Städte haben validierte Padel-Märkte mit belastbaren Benchmarks für die Finanzplanung.",
"mscore_band_mid_label": "4569: Solides Mittelfeld",
@@ -1664,5 +1672,28 @@
"mscore_faq_q4": "Kann ich Scores länderübergreifend vergleichen?",
"mscore_faq_a4": "Ja. Die Methodik ist für alle Märkte einheitlich, sodass ein Score von 72 in Deutschland direkt vergleichbar ist mit einem 72 in Spanien oder Großbritannien.",
"mscore_faq_q5": "Garantiert ein hoher Score eine gute Investition?",
"mscore_faq_a5": "Nein. Der Score misst die Marktattraktivität auf Makroebene. Deine konkrete Investition hängt von Anlagentyp, Baukosten, Mietkonditionen und Dutzenden weiterer Faktoren ab. Im Finanzplaner kannst Du Dein Szenario mit echten Zahlen durchrechnen."
"mscore_faq_a5": "Nein. Der Score misst die Marktattraktivität auf Makroebene. Deine konkrete Investition hängt von Anlagentyp, Baukosten, Mietkonditionen und Dutzenden weiterer Faktoren ab. Im Finanzplaner kannst Du Dein Szenario mit echten Zahlen durchrechnen.",
"mscore_pot_what_h2": "Marktpotenzial-Score: Was er misst",
"mscore_pot_what_intro": "Der padelnomics Marktpotenzial-Score bewertet Investitionschancen an Standorten mit wenig oder gar keiner bestehenden Padel-Infrastruktur. Er erfasst alle Standorte weltweit — auch solche ohne eine einzige Anlage. Gedacht für Erstinvestoren auf der Suche nach unbestellten Märkten, nicht für den Vergleich bereits erschlossener Standorte.",
"mscore_pot_cat_market_h3": "Adressierbarer Markt",
"mscore_pot_cat_market_p": "Logarithmisch skalierte Bevölkerungsgröße, begrenzt auf 500.000 Einwohner. Das Potenzial ist bei mittelgroßen Städten am höchsten — groß genug für eine rentable Anlage, aber noch nicht von Großstadt-Betreibern erschlossen.",
"mscore_pot_cat_econ_h3": "Wirtschaftskraft",
"mscore_pot_cat_econ_p": "Kaufkraft auf Länderebene (KKS), normiert auf internationale Benchmarks. Maßgeblich für die Zahlungsbereitschaft bei Platzmieten im Zielbereich von 2035 €/Std.",
"mscore_pot_cat_gap_h3": "Angebotslücke",
"mscore_pot_cat_gap_p": "Invertierte Anlagendichte: null Plätze pro 100.000 Einwohner ergibt die volle Punktzahl. Das ist das zentrale Signal, das den Marktpotenzial-Score vom Marktreife-Score unterscheidet — der weiße Fleck auf der Karte ist die Chance.",
"mscore_pot_cat_catchment_h3": "Versorgungslücke",
"mscore_pot_cat_catchment_p": "Entfernung zur nächsten bestehenden Padelanlage. Standorte mehr als 30 km vom nächsten Platz entfernt erhalten die volle Punktzahl — echte Versorgungslücken ohne nahe gelegene Alternative.",
"mscore_pot_cat_tennis_h3": "Schlägersportkultur",
"mscore_pot_cat_tennis_p": "Tennisplätze im Umkreis von 25 km als Indikator für etablierte Schlägersportnachfrage. Viele neue Padelanlagen entstehen innerhalb bestehender Tennisvereine oder direkt daneben — ein verlässlicher Frühindikator.",
"mscore_pot_read_h2": "Marktpotenzial-Score: So liest Du ihn",
"mscore_pot_band_high_label": "70100: Hohes Potenzial",
"mscore_pot_band_high_p": "Unterversorgtes Gebiet mit solider Bevölkerungsstruktur und Kaufkraft. Geringes Angebot, weit entfernt von der nächsten Anlage, nachgewiesene Schlägersportkultur. Hohe Priorität für Erstinvestoren.",
"mscore_pot_band_mid_label": "4569: Moderates Potenzial",
"mscore_pot_band_mid_p": "Teilweise bereits vorhandenes Angebot, demografische Einschränkungen oder unklare Signallage. Lohnt sich für eine genauere Prüfung — lokale Faktoren können das Bild erheblich verändern.",
"mscore_pot_band_low_label": "Unter 45: Geringeres Potenzial",
"mscore_pot_band_low_p": "Markt bereits gut versorgt, Bevölkerungszahl gering oder Kaufkraft begrenzt. Konzentriere Dich auf höher bewertete Standorte — es sei denn, Du hast einen konkreten lokalen Vorteil.",
"mscore_faq_q6": "Was ist der Unterschied zwischen dem padelnomics Marktreife-Score und dem padelnomics Marktpotenzial-Score?",
"mscore_faq_a6": "Der padelnomics Marktreife-Score misst, wie etabliert und ausgereift ein bestehender Padel-Markt ist — er gilt nur für Städte mit mindestens einer Anlage. Der padelnomics Marktpotenzial-Score bewertet Investitionschancen in noch unbestellten Märkten und erfasst alle Standorte weltweit. Angebotslücken und unterversorgte Einzugsgebiete fließen positiv ein — auch dort, wo es noch gar keine Anlagen gibt.",
"mscore_faq_q7": "Warum hat mein Ort einen hohen padelnomics Marktpotenzial-Score, aber keine Padelanlagen?",
"mscore_faq_a7": "Genau darum geht es. Ein hoher padelnomics Marktpotenzial-Score signalisiert einen unterversorgten Standort: solide Bevölkerungsbasis, wirtschaftliche Kaufkraft, kein bestehendes Angebot und weite Entfernung zur nächsten Anlage. Das sind genau die Signale, die auf eine Pionierchance hinweisen — kein Zeichen für einen schwachen Markt."
}

View File

@@ -1471,7 +1471,6 @@
"sd_flash_valid_email": "Please enter a valid email address.",
"sd_flash_claim_error": "This listing has already been claimed or does not exist.",
"sd_flash_listing_saved": "Listing saved successfully.",
"bp_indoor": "Indoor",
"bp_outdoor": "Outdoor",
"bp_own": "Own",
@@ -1480,24 +1479,20 @@
"bp_payback_not_reached": "Not reached in 60 months",
"bp_months": "{n} months",
"bp_years": "{n} years",
"bp_exec_paragraph": "This business plan models a <strong>{facility_type}</strong> padel facility with <strong>{courts} courts</strong> ({sqm} m\u00b2). Total investment is {total_capex}, financed with {equity} equity and {loan} debt. The projected IRR is {irr} with a payback period of {payback}.",
"bp_exec_paragraph": "This business plan models a <strong>{facility_type}</strong> padel facility with <strong>{courts} courts</strong> ({sqm} m²). Total investment is {total_capex}, financed with {equity} equity and {loan} debt. The projected IRR is {irr} with a payback period of {payback}.",
"bp_lbl_scenario": "Scenario",
"bp_lbl_generated_by": "Generated by Padelnomics \u2014 padelnomics.io",
"bp_lbl_generated_by": "Generated by Padelnomics padelnomics.io",
"bp_lbl_total_investment": "Total Investment",
"bp_lbl_equity_required": "Equity Required",
"bp_lbl_year3_ebitda": "Year 3 EBITDA",
"bp_lbl_irr": "IRR",
"bp_lbl_payback_period": "Payback Period",
"bp_lbl_year1_revenue": "Year 1 Revenue",
"bp_lbl_item": "Item",
"bp_lbl_amount": "Amount",
"bp_lbl_notes": "Notes",
"bp_lbl_total_capex": "Total CAPEX",
"bp_lbl_capex_stats": "CAPEX per court: {per_court} \u2022 CAPEX per m\u00b2: {per_sqm}",
"bp_lbl_capex_stats": "CAPEX per court: {per_court} CAPEX per m²: {per_sqm}",
"bp_lbl_equity": "Equity",
"bp_lbl_loan": "Loan",
"bp_lbl_interest_rate": "Interest Rate",
@@ -1505,24 +1500,20 @@
"bp_lbl_monthly_payment": "Monthly Payment",
"bp_lbl_annual_debt_service": "Annual Debt Service",
"bp_lbl_ltv": "Loan-to-Value",
"bp_lbl_monthly": "Monthly",
"bp_lbl_total_monthly_opex": "Total Monthly OPEX",
"bp_lbl_annual_opex": "Annual OPEX",
"bp_lbl_weighted_hourly_rate": "Weighted Hourly Rate",
"bp_lbl_target_utilization": "Target Utilization",
"bp_lbl_gross_monthly_revenue": "Gross Monthly Revenue",
"bp_lbl_net_monthly_revenue": "Net Monthly Revenue",
"bp_lbl_monthly_ebitda": "Monthly EBITDA",
"bp_lbl_monthly_net_cf": "Monthly Net Cash Flow",
"bp_lbl_year": "Year",
"bp_lbl_revenue": "Revenue",
"bp_lbl_ebitda": "EBITDA",
"bp_lbl_debt_service": "Debt Service",
"bp_lbl_net_cf": "Net CF",
"bp_lbl_moic": "MOIC",
"bp_lbl_cash_on_cash": "Cash-on-Cash (Y3)",
"bp_lbl_payback": "Payback",
@@ -1530,46 +1521,40 @@
"bp_lbl_ebitda_margin": "EBITDA Margin",
"bp_lbl_dscr_y3": "DSCR (Y3)",
"bp_lbl_yield_on_cost": "Yield on Cost",
"bp_lbl_month": "Month",
"bp_lbl_opex": "OPEX",
"bp_lbl_debt": "Debt",
"bp_lbl_cumulative": "Cumulative",
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io",
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. © Padelnomics — padelnomics.io",
"email_magic_link_heading": "Sign in to {app_name}",
"email_magic_link_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.",
"email_magic_link_btn": "Sign In \u2192",
"email_magic_link_btn": "Sign In ",
"email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.",
"email_magic_link_subject": "Your sign-in link for {app_name}",
"email_magic_link_preheader": "This link expires in {expiry_minutes} minutes",
"email_quote_verify_heading": "Verify your email to get quotes",
"email_quote_verify_greeting": "Hi {first_name},",
"email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.",
"email_quote_verify_project_label": "Your project:",
"email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.",
"email_quote_verify_btn": "Verify & Activate \u2192",
"email_quote_verify_btn": "Verify & Activate ",
"email_quote_verify_expires": "This link expires in 60 minutes.",
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",
"email_quote_verify_subject": "Verify your email \u2014 suppliers are ready to quote",
"email_quote_verify_subject": "Verify your email suppliers are ready to quote",
"email_quote_verify_preheader": "One click to activate your quote request",
"email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project",
"email_welcome_heading": "Welcome to {app_name}",
"email_welcome_greeting": "Hi {first_name},",
"email_welcome_body": "You now have access to the financial planner, market data, and supplier directory \u2014 everything you need to plan your padel business.",
"email_welcome_body": "You now have access to the financial planner, market data, and supplier directory everything you need to plan your padel business.",
"email_welcome_quickstart_heading": "Quick start:",
"email_welcome_link_planner": "Financial Planner \u2014 model your investment",
"email_welcome_link_markets": "Market Data \u2014 explore padel demand by city",
"email_welcome_link_quotes": "Get Quotes \u2014 connect with verified suppliers",
"email_welcome_btn": "Start Planning \u2192",
"email_welcome_subject": "You're in \u2014 here's how to start planning",
"email_welcome_link_planner": "Financial Planner model your investment",
"email_welcome_link_markets": "Market Data explore padel demand by city",
"email_welcome_link_quotes": "Get Quotes connect with verified suppliers",
"email_welcome_btn": "Start Planning ",
"email_welcome_subject": "You're in here's how to start planning",
"email_welcome_preheader": "Your padel business planning toolkit is ready",
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building a platform to connect you with qualified leads from padel entrepreneurs actively planning projects.",
"email_waitlist_supplier_perks_intro": "As an early waitlist member, you'll get:",
@@ -1577,20 +1562,19 @@
"email_waitlist_supplier_perk_2": "Exclusive launch pricing (locked in)",
"email_waitlist_supplier_perk_3": "Dedicated onboarding call",
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
"email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility",
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
"email_waitlist_supplier_subject": "You're in \u2014 {plan_name} early access is coming",
"email_waitlist_supplier_link_planner": "Financial Planning Tool model your padel facility",
"email_waitlist_supplier_link_directory": "Supplier Directory browse verified suppliers",
"email_waitlist_supplier_subject": "You're in {plan_name} early access is coming",
"email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding",
"email_waitlist_general_heading": "You're on the Waitlist",
"email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs \u2014 financial modelling, market data, and supplier connections in one place.",
"email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs financial modelling, market data, and supplier connections in one place.",
"email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:",
"email_waitlist_general_perk_1": "Early access before public launch",
"email_waitlist_general_perk_2": "Exclusive launch pricing",
"email_waitlist_general_perk_3": "Priority onboarding and support",
"email_waitlist_general_outro": "We'll be in touch soon.",
"email_waitlist_general_subject": "You're on the list \u2014 we'll notify you at launch",
"email_waitlist_general_subject": "You're on the list we'll notify you at launch",
"email_waitlist_general_preheader": "Early access + exclusive launch pricing",
"email_lead_forward_heading": "New Project Lead",
"email_lead_forward_urgency": "This lead was just unlocked. Suppliers who respond within 24 hours are 3x more likely to win the project.",
"email_lead_forward_section_brief": "Project Brief",
@@ -1607,22 +1591,20 @@
"email_lead_forward_lbl_phone": "Phone",
"email_lead_forward_lbl_company": "Company",
"email_lead_forward_lbl_role": "Role",
"email_lead_forward_btn": "View in Lead Feed \u2192",
"email_lead_forward_btn": "View in Lead Feed ",
"email_lead_forward_reply_direct": "or <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">reply directly to {contact_email}</a>",
"email_lead_forward_preheader_suffix": "contact details inside",
"email_lead_matched_heading": "A supplier wants to discuss your project",
"email_lead_matched_greeting": "Hi {first_name},",
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and contact details.",
"email_lead_matched_body": "Great news a verified supplier has been matched with your padel project. They have your project brief and contact details.",
"email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.",
"email_lead_matched_next_heading": "What happens next",
"email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 24\u201348 hours via email or phone.",
"email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 2448 hours via email or phone.",
"email_lead_matched_tip": "Tip: Responding quickly to supplier outreach increases your chance of getting competitive quotes.",
"email_lead_matched_btn": "View Your Dashboard \u2192",
"email_lead_matched_btn": "View Your Dashboard ",
"email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.",
"email_lead_matched_subject": "{first_name}, a supplier wants to discuss your project",
"email_lead_matched_preheader": "They'll reach out to you directly \u2014 here's what to expect",
"email_lead_matched_preheader": "They'll reach out to you directly here's what to expect",
"email_enquiry_heading": "New enquiry from {contact_name}",
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
"email_enquiry_lbl_from": "From",
@@ -1631,46 +1613,51 @@
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a> to connect.",
"email_enquiry_subject": "New enquiry from {contact_name} via your directory listing",
"email_enquiry_preheader": "Reply to connect with this potential client",
"email_business_plan_heading": "Your business plan is ready",
"email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.",
"email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.",
"email_business_plan_btn": "Download PDF \u2192",
"email_business_plan_quote_cta": "Ready for the next step? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Get quotes from suppliers \u2192</a>",
"email_business_plan_btn": "Download PDF ",
"email_business_plan_quote_cta": "Ready for the next step? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Get quotes from suppliers </a>",
"email_business_plan_subject": "Your business plan PDF is ready to download",
"email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now",
"email_business_plan_preheader": "Professional padel facility financial plan download now",
"email_footer_tagline": "The padel business planning platform",
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request.",
"email_footer_copyright": "© {year} {app_name}. You received this email because you have an account or submitted a request.",
"footer_market_score": "Market Score",
"mscore_page_title": "The padelnomics Market Score \u2014 How We Measure Market Potential",
"mscore_page_title": "The padelnomics Market Score How We Measure Market Potential",
"mscore_meta_desc": "The padelnomics Market Score rates cities from 0 to 100 on their potential for padel investment. Learn how demographics, economic strength, demand signals, and data coverage feed into the score.",
"mscore_og_desc": "A data-driven composite score (0\u2013100) that measures how attractive a city is for padel court investment. See what goes into it and what it means for your planning.",
"mscore_og_desc": "A data-driven composite score (0100) that measures how attractive a city is for padel court investment. See what goes into it and what it means for your planning.",
"mscore_h1": "The padelnomics Market Score",
"mscore_subtitle": "A data-driven measure of how attractive a city is for padel investment.",
"mscore_what_h2": "What It Measures",
"mscore_what_intro": "The Market Score is a composite index from 0 to 100 that evaluates a city\u2019s potential as a location for padel court investment. It combines four categories of data into a single number designed to help you prioritize markets worth investigating further.",
"mscore_dual_h2": "Two Scores, Two Questions",
"mscore_dual_intro": "Padelnomics publishes two distinct scores for every market. They answer different questions and are calculated using different methodologies — knowing both is essential for a well-informed investment decision.",
"mscore_reife_chip": "padelnomics Marktreife-Score™",
"mscore_reife_question": "How established is this padel market?",
"mscore_reife_desc": "Calculated for cities with at least one padel venue. Combines population size, economic power, demand evidence from booking platforms, and data completeness.",
"mscore_potenzial_chip": "padelnomics Marktpotenzial-Score™",
"mscore_potenzial_question": "Where should I build a padel court?",
"mscore_potenzial_desc": "Calculated for all locations globally, including those with zero courts. Rewards supply gaps, underserved catchment areas, and racket sport culture — the signals that matter for greenfield investors.",
"mscore_what_h2": "Marktreife-Score: What It Measures",
"mscore_what_intro": "The padelnomics Marktreife-Score is a composite index from 0 to 100 that evaluates how established and attractive an existing padel market is. It only applies to cities with at least one padel venue, combining four categories of data into a single number designed to help you prioritize markets worth investigating further.",
"mscore_cat_demo_h3": "Demographics",
"mscore_cat_demo_p": "Population size as a proxy for the addressable market. Larger cities generally support more venues and higher utilization.",
"mscore_cat_econ_h3": "Economic Strength",
"mscore_cat_econ_p": "Regional purchasing power and income indicators. Markets where people have higher disposable income tend to sustain stronger demand for leisure sports like padel.",
"mscore_cat_demand_h3": "Demand Evidence",
"mscore_cat_demand_p": "Signals from existing venue activity \u2014 occupancy rates, booking data, and the number of operating venues. Where real demand is already measurable, it\u2019s the strongest indicator.",
"mscore_cat_demand_p": "Signals from existing venue activity occupancy rates, booking data, and the number of operating venues. Where real demand is already measurable, its the strongest indicator.",
"mscore_cat_data_h3": "Data Completeness",
"mscore_cat_data_p": "How much data we have for that city. A score influenced by incomplete data is less reliable \u2014 we surface this explicitly so you know when to dig deeper on your own.",
"mscore_read_h2": "How To Read the Score",
"mscore_band_high_label": "70\u2013100: Strong market",
"mscore_cat_data_p": "How much data we have for that city. A score influenced by incomplete data is less reliable we surface this explicitly so you know when to dig deeper on your own.",
"mscore_read_h2": "Marktreife-Score: How To Read",
"mscore_band_high_label": "70100: Strong market",
"mscore_band_high_p": "Large population, economic power, and proven demand from existing venues. These cities have validated padel markets with reliable benchmarks for financial planning.",
"mscore_band_mid_label": "45\u201369: Solid mid-tier",
"mscore_band_mid_label": "4569: Solid mid-tier",
"mscore_band_mid_p": "Good fundamentals with room for growth. Enough data to plan with confidence, but less competition than top-tier cities. Often the sweet spot for new entrants.",
"mscore_band_low_label": "Below 45: Early-stage market",
"mscore_band_low_p": "Less validated data or smaller populations. This does not mean a city is a bad investment \u2014 it may mean less competition and first-mover advantage. Expect to do more local research.",
"mscore_read_note": "A lower score does not mean a city is a bad investment. It may indicate less available data or a market still developing \u2014 which can mean less competition and better terms for early entrants.",
"mscore_band_low_p": "Less validated data or smaller populations. This does not mean a city is a bad investment it may mean less competition and first-mover advantage. Expect to do more local research.",
"mscore_read_note": "A lower score does not mean a city is a bad investment. It may indicate less available data or a market still developing which can mean less competition and better terms for early entrants.",
"mscore_sources_h2": "Data Sources",
"mscore_sources_p": "The Market Score draws on data from European statistical offices (population and economic indicators), court booking platforms (venue counts, pricing, occupancy), and geographic databases (venue locations). Data is refreshed monthly as new extractions run.",
"mscore_limits_h2": "Limitations",
"mscore_limits_p1": "The score reflects available data, not absolute market truth. Cities where fewer venues are tracked on booking platforms may score lower on demand evidence \u2014 even if local demand is strong.",
"mscore_limits_p1": "The score reflects available data, not absolute market truth. Cities where fewer venues are tracked on booking platforms may score lower on demand evidence even if local demand is strong.",
"mscore_limits_p2": "The score does not account for local factors like real estate costs, permitting timelines, competitive dynamics, or regulatory environment. These matter enormously and require on-the-ground research.",
"mscore_limits_p3": "Use the Market Score as a starting point for prioritization, not a final investment decision. The financial planner is where you model your specific scenario.",
"mscore_cta_markets": "Browse city scores",
@@ -1680,10 +1667,33 @@
"mscore_faq_a1": "A composite index from 0 to 100 that measures how attractive a city is for padel court investment. It combines demographics, economic strength, demand evidence, and data completeness into a single comparable number.",
"mscore_faq_q2": "How often is the score updated?",
"mscore_faq_a2": "Monthly. New data from statistical offices, booking platforms, and venue databases is extracted and processed on a regular cycle. Scores reflect the most recent available data.",
"mscore_faq_q3": "Why is my city\u2019s score low?",
"mscore_faq_a3": "Usually because of limited data coverage or smaller population. A low score doesn\u2019t mean the city is unattractive \u2014 it means we have less data to quantify the opportunity. Local research can fill the gaps.",
"mscore_faq_q3": "Why is my citys score low?",
"mscore_faq_a3": "Usually because of limited data coverage or smaller population. A low score doesnt mean the city is unattractive it means we have less data to quantify the opportunity. Local research can fill the gaps.",
"mscore_faq_q4": "Can I compare scores across countries?",
"mscore_faq_a4": "Yes. The methodology is consistent across all markets we track, so a score of 72 in Germany is directly comparable to a 72 in Spain or the UK.",
"mscore_faq_q5": "Does a high score guarantee a good investment?",
"mscore_faq_a5": "No. The score measures market attractiveness at a macro level. Your specific investment depends on venue type, build costs, lease terms, and dozens of other factors. Use the financial planner to model your scenario with real numbers."
"mscore_faq_a5": "No. The score measures market attractiveness at a macro level. Your specific investment depends on venue type, build costs, lease terms, and dozens of other factors. Use the financial planner to model your scenario with real numbers.",
"mscore_pot_what_h2": "Marktpotenzial-Score: What It Measures",
"mscore_pot_what_intro": "The padelnomics Marktpotenzial-Score evaluates investment opportunity for locations with little or no existing padel infrastructure. It covers all locations globally, including those with zero courts — designed for greenfield investors scouting white-space markets, not for benchmarking established venues.",
"mscore_pot_cat_market_h3": "Addressable Market",
"mscore_pot_cat_market_p": "Log-scaled population, capped at 500K. Opportunity peaks in mid-size cities that can support a court but are not yet served by large-city operators.",
"mscore_pot_cat_econ_h3": "Economic Power",
"mscore_pot_cat_econ_p": "Country-level purchasing power (PPS), normalised to international benchmarks. Drives willingness to pay for court fees in the €2035/hr target range.",
"mscore_pot_cat_gap_h3": "Supply Gap",
"mscore_pot_cat_gap_p": "Inverted venue density: zero courts per 100K residents earns full marks. This is the key signal separating the Marktpotenzial-Score from the Marktreife-Score — white space is the opportunity.",
"mscore_pot_cat_catchment_h3": "Catchment Gap",
"mscore_pot_cat_catchment_p": "Distance to the nearest existing padel court. Locations more than 30km from any court score maximum points — they represent genuinely underserved catchment areas with no nearby alternative.",
"mscore_pot_cat_tennis_h3": "Racket Sport Culture",
"mscore_pot_cat_tennis_p": "Tennis courts within 25km as a proxy for established racket sport demand. Many new padel facilities open inside or next to existing tennis clubs, making this a reliable lead indicator.",
"mscore_pot_read_h2": "Marktpotenzial-Score: How To Read",
"mscore_pot_band_high_label": "70100: High potential",
"mscore_pot_band_high_p": "Underserved area with strong demographics and economic fundamentals. Low supply, significant catchment gap, and proven racket sport culture. Priority market for greenfield investment.",
"mscore_pot_band_mid_label": "4569: Moderate potential",
"mscore_pot_band_mid_p": "Some supply already exists, demographic limitations, or mixed signals. Worth investigating further — local factors may significantly change the picture.",
"mscore_pot_band_low_label": "Below 45: Lower potential",
"mscore_pot_band_low_p": "Market is already well-served, population is small, or economic purchasing power is limited. Focus resources on higher-scoring locations unless you have a specific local advantage.",
"mscore_faq_q6": "What is the difference between the padelnomics Marktreife-Score and the padelnomics Marktpotenzial-Score?",
"mscore_faq_a6": "The padelnomics Marktreife-Score measures how established and mature an existing padel market is — it only applies to cities with at least one venue. The padelnomics Marktpotenzial-Score measures greenfield investment opportunity and covers all locations globally, rewarding supply gaps and underserved catchment areas where no courts exist yet.",
"mscore_faq_q7": "Why does my town have a high padelnomics Marktpotenzial-Score but no padel courts?",
"mscore_faq_a7": "That is exactly the point. A high padelnomics Marktpotenzial-Score indicates an underserved location: strong demographics, economic purchasing power, no existing supply, and distance from the nearest court. These are precisely the signals that suggest a greenfield opportunity — not a sign of a weak market."
}

View File

@@ -56,6 +56,16 @@
"@type": "Question",
"name": "{{ t.mscore_faq_q5 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a5 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q6 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a6 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q7 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a7 }}"}
}
]
}
@@ -77,9 +87,27 @@
<p class="text-lg text-slate">{{ t.mscore_subtitle }}</p>
</header>
<!-- What It Measures -->
<!-- Two Scores -->
<section class="card mb-10" style="background:linear-gradient(135deg,#f0f9ff,#e0f2fe);border-color:#bae6fd">
<h2 class="text-xl mb-3">{{ t.mscore_dual_h2 }}</h2>
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_dual_intro }}</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div style="background:#fff;border-radius:8px;padding:1rem;border:1px solid #bae6fd">
<div style="font-size:0.7rem;font-weight:700;letter-spacing:0.06em;color:#0369a1;text-transform:uppercase;margin-bottom:0.4rem">{{ t.mscore_reife_chip }}</div>
<div class="font-semibold text-navy mb-1">{{ t.mscore_reife_question }}</div>
<p class="text-sm text-slate-dark">{{ t.mscore_reife_desc }}</p>
</div>
<div style="background:#fff;border-radius:8px;padding:1rem;border:1px solid #bae6fd">
<div style="font-size:0.7rem;font-weight:700;letter-spacing:0.06em;color:#0369a1;text-transform:uppercase;margin-bottom:0.4rem">{{ t.mscore_potenzial_chip }}</div>
<div class="font-semibold text-navy mb-1">{{ t.mscore_potenzial_question }}</div>
<p class="text-sm text-slate-dark">{{ t.mscore_potenzial_desc }}</p>
</div>
</div>
</section>
<!-- Marktreife-Score: What It Measures -->
<section class="mb-10">
<h2 class="text-xl mb-4">{{ t.mscore_what_h2 }}</h2>
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_what_h2 }}</h2>
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_what_intro }}</p>
<div class="grid-2">
@@ -106,9 +134,9 @@
</div>
</section>
<!-- How To Read the Score -->
<!-- Marktreife-Score: How To Read -->
<section class="card mb-8">
<h2 class="text-xl mb-4">{{ t.mscore_read_h2 }}</h2>
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_read_h2 }}</h2>
<div class="space-y-4">
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
@@ -135,6 +163,68 @@
<p class="text-sm text-slate mt-4" style="border-left:3px solid #E2E8F0;padding-left:0.75rem">{{ t.mscore_read_note }}</p>
</section>
<!-- Marktpotenzial-Score: What It Measures -->
<section class="mb-10">
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_pot_what_h2 }}</h2>
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_pot_what_intro }}</p>
<div class="grid-2">
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f4ca;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_market_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_market_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f4b6;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_econ_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_econ_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f3af;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_gap_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_gap_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f4cd;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_catchment_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_catchment_p }}</p>
</div>
<div class="card" style="grid-column:span 2">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f3be;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_tennis_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_tennis_p }}</p>
</div>
</div>
</section>
<!-- Marktpotenzial-Score: Score Bands -->
<section class="card mb-8">
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_pot_read_h2 }}</h2>
<div class="space-y-4">
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#16A34A;flex-shrink:0"></span>
<span class="font-semibold text-navy">{{ t.mscore_pot_band_high_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_pot_band_high_p }}</p>
</div>
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#D97706;flex-shrink:0"></span>
<span class="font-semibold text-navy">{{ t.mscore_pot_band_mid_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_pot_band_mid_p }}</p>
</div>
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#64748B;flex-shrink:0"></span>
<span class="font-semibold text-navy">{{ t.mscore_pot_band_low_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_pot_band_low_p }}</p>
</div>
</div>
</section>
<!-- Data Sources -->
<section class="card mb-8">
<h2 class="text-xl mb-4">{{ t.mscore_sources_h2 }}</h2>
@@ -161,7 +251,7 @@
<section>
<h2 class="text-xl mb-4">{{ t.mscore_faq_h2 }}</h2>
<div class="space-y-4">
{% for i in range(1, 6) %}
{% for i in range(1, 8) %}
<details style="border:1px solid #E2E8F0;border-radius:8px;padding:0.75rem 1rem">
<summary class="font-semibold text-navy" style="cursor:pointer">{{ t['mscore_faq_q' ~ i] }}</summary>
<p class="text-sm text-slate-dark mt-2">{{ t['mscore_faq_a' ~ i] }}</p>