feat: admin articles grouped view, live stats, + bug fixes

Admin articles list:
- Group EN/DE language variants into a single row (grouped by url_path)
- Language chips (● EN/● DE) coloured by status: green=live, amber=scheduled, blue=draft
- Inline View ↗ (live only) and Edit buttons per variant — one-click access
- Filter by language switches back to flat single-row view
- Live HTMX polling of article counts while generation runs (every 3s, self-terminates)
- Table overflow fix: card gets overflow:hidden, table wrapped in overflow-x:auto scroll div

Bug fixes:
- X-Forwarded-Proto: pass $http_x_forwarded_proto through Nginx so Quart sees https
- pipeline_routes.py: fix relative import for analytics module (from .analytics → from ..analytics)
- Scheduled articles: redirect to parent path instead of 404 when not yet published
- city-cost-de: change priority_column from population to padel_venue_count
- Quote wizard step 4: make location_status required
- Article generation: use COUNT(*) instead of 501-sentinel hack for row counts
- Makefile: pin Tailwind v4.1.18, add dev/help targets, uv run python, .PHONY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 20:17:28 +01:00
parent ee488b6aca
commit 0fa2bf7c30
17 changed files with 270 additions and 335 deletions

View File

@@ -58,7 +58,7 @@ NTFY_TOKEN=
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment] #ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
PROXY_URLS=ENC[AES256_GCM,data:CzRaK0piUQfvuYYsdz0i2MEQIphKi0BhNvHw9alo46aTH+kqEKvoS7dKEKzyU9VJ4TyNweInlVMxB962DsvRoBtnHwo/pUmYtVeEr2881clNgEiZVYRDFRdEbpULcLPDJa3ey1leqAAHlmiL0RQ6Qa57gPCOVBzVG6npGLKO+K8XVIb+BZMs9kEUOlw7iuqTJW5xPN/t4X/jHidEqfTSAl9b4vU4bsYVuY3yQrL+/V5QpTbyXlf+cMq3flpA3zE2Fxhalzg+c/wHMTrCksFwrCkrInW0kY9yPkA7usUWr1xwwaV3wIDoNQsLXpMd/3RztipNvKtOMRhRJOmjzP7BKhCJvvvKTV5p+mBCulFijbMQgArg3BqcFanfw3YZ4wPd4hp8q/vOhE/U9Wu0yrMmyWYFHYGQnFVARlBH7pwn/ez8W4KqRFveEAuev9CE7K7s5RqzPLelSkoa9UuiiULJ+t0LFgKlgxuLtQ8GdFdgsmBCxY/4U/xzvNdC82hD549z5nMWWlaUJm4onPWirT/RYm7j3v6z4mmNImI2W6rCNbvEvsXwWsciquVaBIgReA47p6/GTzZ9VZMyGr4PdzB87BJGAgX1W57WNdPAsRIF49XP2BU72RtRFxsUG8Ha2dc=,iv:a10Vpk7Zv8QqORuEcMlpcvtHO/zjBLaFphWPYBXwysc=,tag:8N66/R+CLqEZ45wj+tCt6w==,type:str] PROXY_URLS=ENC[AES256_GCM,data:CzRaK0piUQfvuYYsdz0i2MEQIphKi0BhNvHw9alo46aTH+kqEKvoS7dKEKzyU9VJ4TyNweInlVMxB962DsvRoBtnHwo/pUmYtVeEr2881clNgEiZVYRDFRdEbpULcLPDJa3ey1leqAAHlmiL0RQ6Qa57gPCOVBzVG6npGLKO+K8XVIb+BZMs9kEUOlw7iuqTJW5xPN/t4X/jHidEqfTSAl9b4vU4bsYVuY3yQrL+/V5QpTbyXlf+cMq3flpA3zE2Fxhalzg+c/wHMTrCksFwrCkrInW0kY9yPkA7usUWr1xwwaV3wIDoNQsLXpMd/3RztipNvKtOMRhRJOmjzP7BKhCJvvvKTV5p+mBCulFijbMQgArg3BqcFanfw3YZ4wPd4hp8q/vOhE/U9Wu0yrMmyWYFHYGQnFVARlBH7pwn/ez8W4KqRFveEAuev9CE7K7s5RqzPLelSkoa9UuiiULJ+t0LFgKlgxuLtQ8GdFdgsmBCxY/4U/xzvNdC82hD549z5nMWWlaUJm4onPWirT/RYm7j3v6z4mmNImI2W6rCNbvEvsXwWsciquVaBIgReA47p6/GTzZ9VZMyGr4PdzB87BJGAgX1W57WNdPAsRIF49XP2BU72RtRFxsUG8Ha2dc=,iv:a10Vpk7Zv8QqORuEcMlpcvtHO/zjBLaFphWPYBXwysc=,tag:8N66/R+CLqEZ45wj+tCt6w==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str] RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
PROXY_URLS_FALLBACK= PROXY_URLS_FALLBACK=ENC[AES256_GCM,data:95rwI7kKUj1YxLpjChtrM4f2EFUDzQdAg1e1MOHnLwQ9ZY54UNH7v4JcqTsvDk9D+0N/BIdwFSDi7pnCSd6BWFV+cQ==,iv:rm9HdBsibSne7JR6vWl+ao/GHb1rbuVdZZDUWhVbTnE=,tag:NJ2STxmFZPvFayfTrEEYbg==,type:str]
CIRCUIT_BREAKER_THRESHOLD= CIRCUIT_BREAKER_THRESHOLD=
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment] #ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
GSC_SERVICE_ACCOUNT_PATH= GSC_SERVICE_ACCOUNT_PATH=
@@ -67,10 +67,10 @@ BING_WEBMASTER_API_KEY=
BING_SITE_URL= BING_SITE_URL=
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment] #ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str] GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str]
CENSUS_API_KEY= CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-02-24T21:48:18Z sops_lastmodified=2026-02-26T14:31:14Z
sops_mac=ENC[AES256_GCM,data:RmSB5aS5Avl1jzeSmZPdDS6u+QPKDVD/1A55slXXdht96Knbh7IjaRsqggql9uixQO0/6WWkXsxhcKDWhsbYb0el2ATrLWXHaV6GQqfLq7RUynagcGTNHj8ipizQ93MqaDlXnI92ZOEHNcgvJzRuvRLJYhMErSyzwbUxtbaGMNM=,iv:o5wY+9uurzsTOMgmblGi0xcyYMsYGMfICmt4dSBlt2w=,tag:UKhqs3pedmvP/HjGJb0y4Q==,type:str] sops_mac=ENC[AES256_GCM,data:iqFuTexTS9U/Nv8xoTpHljTNQTGX9ITcJ3AjhDEtxrh0Z9/lngfBvGtjiKmpwFGlobQw/x+/YLM+u3MhciwXF7qNwFfJ/StN2Y66uF71SxWotbL70Dxl4oWSVL3sU+2NYbw5yP0p+xCbE+rEd5SqAe6K5yyq5X25hz8fIapxlYA=,iv:foqoWQVMipuOAQ0Kp799PaIhCIrxV8T5cC811wIzxR8=,tag:yNfxSV3R21XEXksjmdsKBw==,type:str]
sops_unencrypted_suffix=_unencrypted sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1 sops_version=3.12.1

View File

@@ -11,27 +11,27 @@ DATABASE_PATH=ENC[AES256_GCM,data:qxQs7dG0RWMA1rs=,iv:5ZUyk02hCPQESr2vFz3mfnUhUF
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:YSE=,iv:GYm1EWku7+OG+fCIbUHWsfYbnEQVNhlBmWBC1OCV1NA=,tag:L2kdm7tMWOO/cf+VDd+OdQ==,type:str] MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:YSE=,iv:GYm1EWku7+OG+fCIbUHWsfYbnEQVNhlBmWBC1OCV1NA=,tag:L2kdm7tMWOO/cf+VDd+OdQ==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9Og=,iv:3nStZVZVB24aAtNrtLXZ0oIehTDyu2IzdXoMH59t+3o=,tag:+FQ4n1XeSS12zUGXt/1RKQ==,type:str] SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9Og=,iv:3nStZVZVB24aAtNrtLXZ0oIehTDyu2IzdXoMH59t+3o=,tag:+FQ4n1XeSS12zUGXt/1RKQ==,type:str]
#ENC[AES256_GCM,data:mtqp/c5zZxlcB4HrOrfi,iv:eJaN+ZnAIaNHF5iovcz0QynILq9GjqVcwoyN2ZhLmpI=,tag:WyXU7ho5T/CE609id9dOzA==,type:comment] #ENC[AES256_GCM,data:mtqp/c5zZxlcB4HrOrfi,iv:eJaN+ZnAIaNHF5iovcz0QynILq9GjqVcwoyN2ZhLmpI=,tag:WyXU7ho5T/CE609id9dOzA==,type:comment]
RESEND_API_KEY=ENC[AES256_GCM,data:U5aEnItbJ/Af,iv:7BTFimeMbPtK6ANXMr7VwO5TJ7IaRk+HAOZy+TEXMVI=,tag:sDhW5icVloSck1iafu3H0A==,type:str] RESEND_API_KEY=ENC[AES256_GCM,data:K7Uvy98abZ2gohUWjKjz/F/C9HhBsxcHTTPqiQkeuEytxfrD,iv:Bhj5zd6OJc97W9WtQwGr6znoHD9IfQ5gDsVytzb96Kw=,tag:N6LqCiHJBS0r5nOg2mUxsg==,type:str]
EMAIL_FROM=ENC[AES256_GCM,data:BTGeWUjG9qCBvRQr9kK5sfdzQ1CfuNgpkU/AL3Qu6GJ2ng==,iv:0XjqD8hCqleSJR2FrDajlnUul8o4GkK0f1MOP96MRkw=,tag:0PwZwxuBbUFYdiRYTlDffg==,type:str] EMAIL_FROM=ENC[AES256_GCM,data:BTGeWUjG9qCBvRQr9kK5sfdzQ1CfuNgpkU/AL3Qu6GJ2ng==,iv:0XjqD8hCqleSJR2FrDajlnUul8o4GkK0f1MOP96MRkw=,tag:0PwZwxuBbUFYdiRYTlDffg==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:jkpWqodUgR2QoB96zvE5aH/tA9Sh0nPcl75P3i43ecFILw==,iv:vNtB/9gdrTDm6vNIjnH6JShYyqmG7h9jd2uzwFwjhO8=,tag:cG5T3CwQfZO/jTYFnwJSgA==,type:str] LEADS_EMAIL=ENC[AES256_GCM,data:jkpWqodUgR2QoB96zvE5aH/tA9Sh0nPcl75P3i43ecFILw==,iv:vNtB/9gdrTDm6vNIjnH6JShYyqmG7h9jd2uzwFwjhO8=,tag:cG5T3CwQfZO/jTYFnwJSgA==,type:str]
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:EQpvkWFyt8H7,iv:6QiZIDo5Ps39vf9MKkiqSJir7BH9zhoLREJ425y3FIs=,tag:kjO4dczb2E5FKfO6OVaQvw==,type:str] RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:Fs9UQ4NINYPsEgkGWepWaCr3ini5zXnBNCoe/Tt0DXeo0ZMgtfI=,iv:Z4sIaE7Ixcbeg00asGALVcTtfLloduHTR5vRbC42dwc=,tag:XHlBmYDFnjHgjKEouwC4VQ==,type:str]
#ENC[AES256_GCM,data:HW8JOkd7Hw==,iv:Qfwm2ZHT8TKANrLrRQqHnceQVUTiuzT2hSjLN8hSq5Q=,tag:hvVLmGGUBRlsm2qy9jxIvA==,type:comment] #ENC[AES256_GCM,data:HW8JOkd7Hw==,iv:Qfwm2ZHT8TKANrLrRQqHnceQVUTiuzT2hSjLN8hSq5Q=,tag:hvVLmGGUBRlsm2qy9jxIvA==,type:comment]
PADDLE_API_KEY=ENC[AES256_GCM,data:d3rKjWFrFepp,iv:TGjG9VTC4pZFgnp5daE+jBrRCUJddqgRaV7rQ61llhU=,tag:KKaYPfUgLC58zhC8s3B4cQ==,type:str] PADDLE_API_KEY=ENC[AES256_GCM,data:KyjFIvxOcKTKbnnLBwGTZSfZmvrYDZO7b3pSXrUdyfYu,iv:rlkyBA6fUvH95B0e+i/HGpvAKnb6ZNWRJ218CZeAqPc=,tag:eQC2ikQS0fzzGKC5Ia15mw==,type:str]
PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:JPmeLZx16WuV,iv:52EczBQM+fvEQuzoY8Aon0RBZiLzf1vrbT9Kx+b/WUE=,tag:+abUTzCgxulamobp13PbWQ==,type:str] PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:nBShxoBK0OzCVlyv0EOmls61YR4XTV9covjz49ZVuwQnj84JxVXoCRvIT+ZgLrTDkSfUHJ9ayM4U5pMZQlEPWyEvq+rS,iv:BJ/l+sGSMjLzhCkoVX3we6g1WafcmAJJruckz0JLhXg=,tag:nyW+WsXVJzEruDDrT0PnFg==,type:str]
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:fk2PbtpwoGRB,iv:QOhOd4rKmVjMA1EUQUjSj/y/OM7I435K/s4aqShjQNw=,tag:RIfbUCXAQGmCiE9FODHgpA==,type:str] PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:iMu0YHlzWH9Csx7feu2HMf63t6lC9ScCCTXesKOKRjfFMLvgYS/SrIVRy2Vj/h8RxwTsbXD74eyv1N3Enk9AI+rD6rOaYw==,iv:0TCypcz27+f0xu5iKqv3ziUJpSmelBVRV0l34xcrP20=,tag:uJKyBaPkNIGiix0FbwBY6Q==,type:str]
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:igRsm8JOO1SP,iv:vQgOZcMHt6YoE+U2d6tT8sILOwsTx3glHVBBatR6Sk8=,tag:1tApDyZmZNiwd3bVm0uZGw==,type:str] PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:hhmwW691SoiKxcgMIUUjyBtg5ntT1WPff5r4DmXqnzYY,iv:9beXEidjNOljigP1dMEZHKvJpyRT1JMNNAzFPUvWw0o=,tag:uOvr4WAlqIoxEX7hQTr4Sg==,type:str]
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:A1qXlv+9hjdIug==,iv:nu9kRQZgGLFXXT2I5GaRzp13YgQxU2ucr9azEA4XTUQ=,tag:RBxwE2j9v/RCiEMIa+6ICw==,type:str] PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:A1qXlv+9hjdIug==,iv:nu9kRQZgGLFXXT2I5GaRzp13YgQxU2ucr9azEA4XTUQ=,tag:RBxwE2j9v/RCiEMIa+6ICw==,type:str]
#ENC[AES256_GCM,data:F3dSfSGV,iv:Zjzmp9Vb+LBkqV6xBIMF2cK8ON9crH3fHcOog4+LOpo=,tag:7V8E9ChwYY9ceTaYdg3Lbw==,type:comment] #ENC[AES256_GCM,data:F3dSfSGV,iv:Zjzmp9Vb+LBkqV6xBIMF2cK8ON9crH3fHcOog4+LOpo=,tag:7V8E9ChwYY9ceTaYdg3Lbw==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:4nJZc/opX4rsqAxO6XxD1Es5ySMh7nUtcGt6Kg==,iv:DcmhRe1IJKS0tOFgdJQQv2A1kO5K8VVT8aW0Vq5hVlY=,tag:Sglu4nnAiLIzr+ovJ/hEKQ==,type:str] UMAMI_API_URL=ENC[AES256_GCM,data:4nJZc/opX4rsqAxO6XxD1Es5ySMh7nUtcGt6Kg==,iv:DcmhRe1IJKS0tOFgdJQQv2A1kO5K8VVT8aW0Vq5hVlY=,tag:Sglu4nnAiLIzr+ovJ/hEKQ==,type:str]
UMAMI_API_TOKEN=ENC[AES256_GCM,data:Xv1eTWtiJ6PL,iv:9sYsI2dJaQt6gpC/ev0b2dSk48PzuojTg18xXnBSWvk=,tag:DAMDHk0b9IG7T9MpkpzAkQ==,type:str] UMAMI_API_TOKEN=ENC[AES256_GCM,data:aCO4SNhNXb8cDW9j38c4RW5798wU4W8f3LCqAFZIptptU+tYF8myu4NRH3d7ZItVacBUi8305y4YbublzuflDMvZ24in+MQpS681bG71gWkao8rK6N8AyTTPTUod3kXC3EMgFeO3T0xxzGN1u09u3JmdWigS8usydfoWwlDI4r82IZ19XtrcdXaj21r41uArmgknyWBXH/hvIfmaRHba4YtYzk1uXi3/7qo6T+wGBXIzc+uo9BGLKCInYUrwqCtqM1/6jN5/zV6kZUb0rm4BoQqzsPCF6fuTOLSw+ARVUWx/ol9hW6WFyPhJq8FRbHbBRM+juvz7cL6RCwSkms16OfT77MN+iHcKnQMp9ADvGTiXGwn/3xKfDr33G+Ne8XH7R+IsCLwjk+gHsUKDNKX39AMj5hI3sPMXWV6s3aTbdIAH/a3mgpB1CpzMnZsmsgV/RN9eKXvxduwatznY/YS+f9Uyci/0EH/1Yq+MMiLz32PL0KLZPmjQOS0psbc=,iv:WkYDIVbjvfIKJqgDc2kHtiq0N9/H4A2pShqjyI/xsUI=,tag:SzNYioXrLJ8nPNgedLGnJg==,type:str]
#ENC[AES256_GCM,data:wAePRqqMZL2oCJB812A=,iv:jaLmjd0GW2dnEQ3KgWcvAs7Q7aDwlCexM9W7pH27kss=,tag:h7/yIdc13+3pmqyCc0OPkg==,type:comment] #ENC[AES256_GCM,data:wAePRqqMZL2oCJB812A=,iv:jaLmjd0GW2dnEQ3KgWcvAs7Q7aDwlCexM9W7pH27kss=,tag:h7/yIdc13+3pmqyCc0OPkg==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:W3Nt,iv:ycMAxrPq44S6qezQIa50rc7GDplo1YvAO6VUERGQUxA=,tag:uzendLuSVbmSPcVPEgLiqQ==,type:str] RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:W3Nt,iv:ycMAxrPq44S6qezQIa50rc7GDplo1YvAO6VUERGQUxA=,tag:uzendLuSVbmSPcVPEgLiqQ==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:r8o=,iv:m5uKo3N8mb7FWI70SgaaHSyC3CNeD8XxjEx8ENit9uI=,tag:gKXEXsIwtBr3sm7xqLRHIw==,type:str] RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:r8o=,iv:m5uKo3N8mb7FWI70SgaaHSyC3CNeD8XxjEx8ENit9uI=,tag:gKXEXsIwtBr3sm7xqLRHIw==,type:str]
#ENC[AES256_GCM,data:E6JgKjxuqFdPtVEv6Xiz1kqcT4ar,iv:hL7P7/X7nEqFwnlf72QEeHhViQ17HZbsCP/M4gcTJiA=,tag:FjCPSvrBboCWjfIS/fab0A==,type:comment] #ENC[AES256_GCM,data:E6JgKjxuqFdPtVEv6Xiz1kqcT4ar,iv:hL7P7/X7nEqFwnlf72QEeHhViQ17HZbsCP/M4gcTJiA=,tag:FjCPSvrBboCWjfIS/fab0A==,type:comment]
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:opg8kQY3PKnZ,iv:lPHUBDwHgBulOyt9WWgZhBQae8t2WKYvLHSFQrG3N/w=,tag:qtyIz4fbh40aLp7ZawBJiA==,type:str] LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:pAqSkoJzsw==,iv:5J1Js7JPH/j1oTmEBdNXjwd1Mj7GtmC8VWpaclXuQVQ=,tag:H0CN6jBeUGXeuMAodbmx9Q==,type:str]
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:6jaEysPtRal7,iv:s5aLx7LdZ3ZLA9oL5vXXDfDDGI7gd5/CukNrMpPLJNk=,tag:Igp3bqW52raBfEeUaUvZ7A==,type:str] LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QfXhwh9L2rhr,iv:OaYlzTiu4onCNu5HfytYTCJa5p2QLShhO5j5Y038IOs=,tag:i13aQ2ICePyCU/Ob+EA7Nw==,type:str] LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:hLneNsFmgQ6+,iv:RNefJ3QbviHPURxcK2xYJU7qWpMfWInCxYQ/4xDIwfw=,tag:FhMiHGrNcsXaSmdG4NXgfQ==,type:str] LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment] #ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str] DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str] SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
@@ -43,7 +43,7 @@ ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:4sXQk8zklruC525J279TUUatdDJQ43qweuoPhtpI82
NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str] NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str]
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHzMjE+4W/W+s4=,tag:czFaOqhHG8nqrQ8AZ8QiGw==,type:str] SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHzMjE+4W/W+s4=,tag:czFaOqhHG8nqrQ8AZ8QiGw==,type:str]
#ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment] #ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment]
PROXY_URLS=ENC[AES256_GCM,data:L2Oobpi6Pq8m,iv:14mXi+8mLv2e20IKVL0VlxZiHW/1BmeQP4a6ns5930g=,tag:pVJasNjv6N/UApVm+KD+XA==,type:str] PROXY_URLS=ENC[AES256_GCM,data:nm4B++SkZZgN3p2xru3WrpVA0X6O8yvb45tH/ovF4006zBy28xqVxbsd44Mz6b5FMinjOXRmGwoI/GDWmdJLzBYdpryQ/FhpbzSUpr1ZOjOz+7P0vn2jfBGAB8ksU3i5kuYglud3EyQGFL+v+uooxwrIUCjfzmmB4vCmf7phssKDsK1CqzmdZ1c54ehSu4bRRdmGp9d0+r+j1SpXb/JbZ8LTqUIhLlZXrHFqkCfN1czhFK9IwMVgR00Q4v2YkjaRBME4lVqwk1NwwatbS9Fq8LlzwuT1uKk+T6ZDkFKC8ZoPW1YRqF13X7hFGFXCNRqABRDZ45lqxYQbBoRrWmH2tfMiAmTrIuRsdPM8bZ/Ol5mXSDhs0HyWX2urX+LD65rIOO0zN/lwjXSwh5mwwBdB61akdzsWRyLZsdafuQUmgGul8y0eGMEbFWaty3bdrtAmqtsvHwxD/Dp/gQWScESXvPd1arn55zaXmefOy+ZLwcmx+FAJPpTMXRaq6Y/Z+D1PZZ+Uhu2D6tsAR4VvqqwlUgpsrAFXk6chJzOry8rmmxoMuIj9mXfjG+BqPFhV2oQsKSuIqFQqd/ZidJLO8ZSxA7L+h1eH4cQjcUd2nfzroG8nnKZ+cA8hQMfLuFiMY1I=,iv:nTaNQlC3px/lnodLphnILWbPVnelaUKKOZAFAaHi8MU=,tag:TYkIX1nrc+PKbvvnWYcvbg==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str] RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
#ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment] #ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment]
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str] GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
@@ -52,10 +52,10 @@ BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHT
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str] BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment] #ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str] GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
CENSUS_API_KEY= CENSUS_API_KEY=ENC[AES256_GCM,data:9RbKlxSD17LqIuuNXaOKSgZ8LnFh9Wbze3XHgpctfV/1TqBMZTIedQ==,iv:WwsmR3HLUEcgUpLliGRaUPhGM9vFNPMGXSAQQ6+9UVc=,tag:R4EMNy5MxxvK0UTaCL0umA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqck9GdHVkUmIzNnlvMW5k\nVkNtazZ0ZytzZ25vMU5SckdFLzcrTFNYOVZZCmNjbU9yV0lTRlB5cEpMVC81QTdu\nS2ZDc0ZkNnRBNFhFMEN1bjY3YVhwZEEKLS0tIGE5TEdYenVOV1IwcE0wYnlKNElF\ncXV1K0xuczZzZ3JnL1lrSC9QWHIwNGsKfW4ARke6Cj83BpQc8weayL3v8SVgQ+Fp\n99aVWp103O1fumksR1w4u0X7fSNRrgAmpY/yyZuEvsoIY8ELFVcqgQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqck9GdHVkUmIzNnlvMW5k\nVkNtazZ0ZytzZ25vMU5SckdFLzcrTFNYOVZZCmNjbU9yV0lTRlB5cEpMVC81QTdu\nS2ZDc0ZkNnRBNFhFMEN1bjY3YVhwZEEKLS0tIGE5TEdYenVOV1IwcE0wYnlKNElF\ncXV1K0xuczZzZ3JnL1lrSC9QWHIwNGsKfW4ARke6Cj83BpQc8weayL3v8SVgQ+Fp\n99aVWp103O1fumksR1w4u0X7fSNRrgAmpY/yyZuEvsoIY8ELFVcqgQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-02-24T21:37:45Z sops_lastmodified=2026-02-26T14:32:28Z
sops_mac=ENC[AES256_GCM,data:FdIU0UvGEc/P7ETNOxYHqfsGMNCdBVqbxHVIrR1v4hAnTWYHelawJqifQOOArTyNGjfsIRGajct7CLADkGE/qVm6vSQO4m6w+veSGEO39Wvlfz6BrVSYMqWMjGuJsTj/TJGSZDBnyC//Jzf3pTTgXrcjM86aoLbqhT/Qbb0JIiE=,iv:fgP4Ro0Cd6u1n9G07UsMkQNDk3fCQPe5hixA3KXhcAk=,tag:2PEKkltbD5TICzZ3WgvXQA==,type:str] sops_mac=ENC[AES256_GCM,data:pyHQHwTtjh7OLiMqbqhUjfrmetEtYS7yB342C/TWfDCwEotWLVwnGWlC4+HIl53pw9+3AgoBVRnW0t86e4kG9O8KyHnk68S9qBcpUsybW3lyGPNXmBydv1W9gQHuK8f/4WGIbkhNxyIToKg9ZAmYWFxNhRKSoYKm5P9Uh7B7CF4=,iv:syrX8VdL3JsDsawvFWbX04Ygcr18hjSSHfEwHkyKETk=,tag:qrhWkh/e+21OKGU2+rCeyg==,type:str]
sops_unencrypted_suffix=_unencrypted sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1 sops_version=3.12.1

View File

@@ -1,32 +1,58 @@
TAILWIND_VERSION := v4.1.18
TAILWIND := ./bin/tailwindcss TAILWIND := ./bin/tailwindcss
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
.PHONY: help dev init-landing-seeds css-build css-watch \
secrets-decrypt-dev secrets-decrypt-prod \
secrets-edit-dev secrets-edit-prod
help:
@echo "Available targets:"
@echo " dev Start full dev environment (reset DB, migrate, seed, app + worker + CSS watcher)"
@echo " init-landing-seeds Create seed landing files for SQLMesh (run once after clone)"
@echo " css-build Build + minify Tailwind CSS"
@echo " css-watch Watch + rebuild Tailwind CSS"
@echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env"
@echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env"
@echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR"
@echo " secrets-edit-prod Edit .env.prod.sops in \$$EDITOR"
# ── Dev environment ───────────────────────────────────────────────────────────
dev:
@./web/scripts/dev_run.sh
# ── Landing seeds ─────────────────────────────────────────────────────────────
# Create seed files for SQLMesh staging models that require at least one landing file.
# Run once after a fresh clone (data/ is gitignored so seeds are not in git).
init-landing-seeds:
@uv run python web/scripts/init_landing_seeds.py
# ── CSS ───────────────────────────────────────────────────────────────────────
bin/tailwindcss: bin/tailwindcss:
@mkdir -p bin @mkdir -p bin
curl -sLo bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 curl -sLo bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
chmod +x bin/tailwindcss chmod +x bin/tailwindcss
# Create seed files for SQLMesh staging models that require at least one landing file.
# Run once after a fresh clone (data/ is gitignored so seeds are not in git).
init-landing-seeds:
@python3 web/scripts/init_landing_seeds.py
css-build: bin/tailwindcss css-build: bin/tailwindcss
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --minify $(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --minify
css-watch: bin/tailwindcss css-watch: bin/tailwindcss
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch $(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch
# -- Secrets (SOPS + age) -- # ── Secrets (SOPS + age) ─────────────────────────────────────────────────────
# .env.*.sops files use dotenv format but sops can't infer from the extension, # .env.*.sops files use dotenv format but sops can't infer from the extension,
# so we pass --input-type / --output-type explicitly. # so we pass --input-type / --output-type explicitly.
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
secrets-decrypt-dev: secrets-decrypt-dev:
$(SOPS_DOTENV) --decrypt .env.dev.sops > .env $(SOPS_DOTENV) --decrypt .env.dev.sops > .env
@echo "Decrypted .env.dev.sops → .env"
secrets-decrypt-prod: secrets-decrypt-prod:
$(SOPS_DOTENV) --decrypt .env.prod.sops > .env $(SOPS_DOTENV) --decrypt .env.prod.sops > .env
@echo "Decrypted .env.prod.sops → .env"
secrets-edit-dev: secrets-edit-dev:
$(SOPS_DOTENV) .env.dev.sops $(SOPS_DOTENV) .env.dev.sops

View File

@@ -111,7 +111,7 @@ server {
proxy_set_header Host \$host; proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme; proxy_set_header X-Forwarded-Proto \$http_x_forwarded_proto;
} }
} }
NGINX NGINX

View File

@@ -15,6 +15,8 @@
**Core insight:** The job is never "I need a calculator." It's "I need to feel confident committing €200K+." Confidence is the product. Calculator is the vehicle. **Core insight:** The job is never "I need a calculator." It's "I need to feel confident committing €200K+." Confidence is the product. Calculator is the vehicle.
**Investment reality (from `research/padel-hall-economics.md`):** 4-court outdoor commercial = €200K€350K. Any indoor hall = €700K€3M+. These are the primary addressable buyers. Single-court installs (€33K€80K, mostly hotel/corporate add-ons) exist but are not the target for the lead marketplace — suppliers filter them out by project size anyway.
### Why This Works (DaaS Thesis) ### Why This Works (DaaS Thesis)
| Check | Answer | | Check | Answer |
@@ -79,7 +81,7 @@ Small direct revenue Main revenue: subscriptions + credits + upsel
**Social job:** Look professional and thorough to banks / investors / spouse. ← underserved, pricing lever. **Social job:** Look professional and thorough to banks / investors / spouse. ← underserved, pricing lever.
**Acquired via:** SEO, free calculator, content marketing. **Acquired via:** SEO, free calculator, content marketing.
**Direct revenue:** Business plan PDF (€99), market intel subscriptions (future). **Direct revenue:** Business plan PDF (€149), market intel subscriptions (future).
#### Beachhead Sub-segment — Tennis/Sports Club Owner (Germany) #### Beachhead Sub-segment — Tennis/Sports Club Owner (Germany)
@@ -311,8 +313,8 @@ After calculator results: prompt for email to save/share scenario. Every planner
| Tier | Price | Includes | State | | Tier | Price | Includes | State |
|------|-------|----------|-------| |------|-------|----------|-------|
| Basic | Free | Listing, no leads | [ ] | | Basic | Free | Listing, no leads | [ ] |
| Growth | €149/mo | Listing + 30 credits/mo + basic analytics | [ ] | | Growth | €199/mo (€1,799/yr) | Listing + 30 credits/mo + basic analytics | [ ] |
| Pro | €399/mo | Listing + 100 credits/mo + full analytics | [ ] | | Pro | €499/mo (€4,499/yr) | Listing + 100 credits/mo + full analytics | [ ] |
### Upsell Stack (RemoteOK playbook) ### Upsell Stack (RemoteOK playbook)
@@ -364,7 +366,7 @@ Suppliers self-select which leads to unlock with credits. Lead cost scales by pr
| Lead credits (Side B) | Suppliers buy credits to unlock leads | [ ] Not live | | Lead credits (Side B) | Suppliers buy credits to unlock leads | [ ] Not live |
| Directory subscriptions (Side B) | Growth €149/mo, Pro €399/mo | [ ] Not live | | Directory subscriptions (Side B) | Growth €149/mo, Pro €399/mo | [ ] Not live |
| Directory upsells (Side B) | Logo, highlight, verified, sticky | [ ] Not live | | Directory upsells (Side B) | Logo, highlight, verified, sticky | [ ] Not live |
| Business Plan PDF (Side A) | €99 one-time | [ ] CTA exists, no product | | Business Plan PDF (Side A) | €149 one-time | [ ] CTA exists, no product |
### Near-term (Phase 23) ### Near-term (Phase 23)

View File

@@ -1,272 +0,0 @@
# State of Padel Q1 2026 — Research Brief
> Compiled 2026-02-25 from FIP World Padel Reports 2024 + 2025 (PDF text extraction),
> padelfip.com press releases, and Premier Padel circuit announcements.
>
> **Raw source files:**
> - FIP 2024 PDF text: `/tmp/fip_2024_text.txt` (11,049 lines)
> - FIP 2025 PDF text: `/tmp/fip_2025_text.txt` (28,135 lines)
> - FIP 2024 PDF: `https://www.padelfip.com/wp-content/uploads/2025/05/WORLD_PADEL_REPORT_2024_FIP-1.pdf`
> - FIP 2025 PDF: `https://www.padelfip.com/wp-content/uploads/2025/12/FIP-WPR-2025_DIGITAL.pdf`
---
## 1. Global Player Population
| Metric | End 2023 (FIP 2024 report) | Mid/Oct 2025 (FIP 2025 report) | Growth |
|--------|---------------------------|-------------------------------|--------|
| Amateur players worldwide (est.) | ~2530 million | **>35 million** | — |
| Federated / licensed players | **600,000** | **850,000** | +42% |
| Players playing ≥1×/week | >50% of amateurs | >50% of amateurs | — |
| Gender split | ~60% M / 40% F | ~60% M / 40% F | — |
| Pro-ranked players | ~4,874 | **11,125** | +128% |
| Junior-ranked players | ~1,209 | **4,219** | +249% |
**Player distribution by continent (FIP 2025 report):**
| Continent | Share |
|-----------|-------|
| Europe | 61.3% |
| South America | 19.0% |
| Central & North America | 7.7% |
| Asia | 6.8% |
| Africa | 4.9% |
| Oceania | ~0.3% |
*Source: FIP World Padel Report 2025, `/tmp/fip_2025_text.txt`*
---
## 2. Courts Worldwide
| Metric | Early 2024 (FIP 2024) | June 2025 (FIP 2025) | Growth |
|--------|----------------------|---------------------|--------|
| **Total courts worldwide** | **60,000** (milestone) | **77,355** | +29% |
| Europe | 42,600 (70%) | 51,000+ (66%) | +20% |
| South America | ~14,850 | ~14,100 (18%) | — |
| Asia | ~3,200 | ~4,633 (6%) | +45% |
| Central & North America | ~2,000 | ~3,976 (5%) | +99% |
| Africa | ~2,300 | ~3,389 (4%) | +47% |
| Oceania | ~50 | ~110 | +120% |
| Court-to-club ratio (global avg) | 3.2 | — | — |
**3-year context (from FIP 2024 report):** +240% court growth globally since 2021.
Excluding Spain, European countries grew courts 6× in the same period.
*Source: FIP 2024 report pp. key facts section; FIP 2025 report country-breakdown section. `/tmp/fip_2024_text.txt` lines ~380414; `/tmp/fip_2025_text.txt` court section.*
---
## 3. Top Countries by Court Count
### 2025 (June 2025 data, FIP 2025 report)
| Rank | Country | Courts |
|------|---------|--------|
| 1 | Spain | 17,300 |
| 2 | Italy | 10,220 |
| 3 | Sweden | 4,220 |
| 4 | Argentina | 4,220 |
| 5 | France | 4,000 |
| 6 | Netherlands | 3,500+ |
| 7 | Belgium | 2,150 |
| 8 | Mexico | 2,000+ |
| 9 | Chile | ~2,000 |
| 10 | Denmark | ~1,560 |
| 11 | Portugal | 1,560 |
| 12 | Saudi Arabia | 1,1271,575 |
### 2024 (early 2024 data, FIP 2024 report)
| Country | Courts |
|---------|--------|
| Spain | 16,000+ |
| Argentina | ~9,053 |
| Sweden | ~4,200 |
| France | ~2,300 |
| Netherlands | ~2,420 |
*Source: FIP 2025 top-15 courts chart, `/tmp/fip_2025_text.txt` ~lines 390412. FIP 2024 country pages, `/tmp/fip_2024_text.txt`.*
---
## 4. Clubs Worldwide
| Metric | FIP 2024 report | FIP 2025 report |
|--------|----------------|----------------|
| Total clubs worldwide | ~19,800 | **24,627+** |
| FIP-affiliated clubs | 5,820 (+48% vs 2022's 3,923) | 8,612 (managing 30,000+ courts) |
| Countries / nations playing padel | 130 + 12 territories | **150 + 20 territories** |
*Source: `/tmp/fip_2024_text.txt` global summary; `/tmp/fip_2025_text.txt` headline stats.*
---
## 5. FIP Member Federations
| Metric | FIP 2024 | FIP 2025 |
|--------|---------|---------|
| Total FIP member federations | **71** (30 joined in prior 3 years) | **87** |
| European | 39 | 50 |
| Asian | 15 | — |
| American | 12 | — |
| African | 3 | — |
| Oceanian | 2 | — |
*Source: `/tmp/fip_2024_text.txt` line 3573; `/tmp/fip_2025_text.txt` federation overview.*
---
## 6. Country-Level Licensed (Federated) Players
### From FIP 2025 report (data as of Oct 2025)
| Country | Licensed Players | Gender split | Amateur est. | Courts | Clubs |
|---------|-----------------|-------------|-------------|--------|-------|
| Spain | **109,182** | 39,851W + 69,331M | 6,200,000+ | 17,300+ | 4,570+ |
| Italy | **86,301** | 23,257W + 61,837M | 2,200,000 | 10,220 | 3,795 |
| France | **250,000** | via FFT* | 800,000 | 4,000 | 2,917 |
| Sweden | **6,118** | 1,900W + 4,218M | 700,000 | 4,220 | 1,202 |
*France figure = FFT (Fédération Française de Tennis) licences covering padel. 100,000 are padel-only; 150,000 are multi-racket licences.*
### From FIP 2024 report (data as of end 2023 / April 2024)
| Country | Licensed Players | Amateur est. |
|---------|-----------------|-------------|
| Spain | **101,326** | 5,500,000+ |
| France | **113,006** (FFT) | 1,000,000 |
| Argentina | **73,741** | 1,500,000 |
| Paraguay | 15,000 | 500,000 |
*Source: `/tmp/fip_2025_text.txt` Spain ~line 25571, Italy ~line 19606, France ~line 17550, Sweden ~line 26084. `/tmp/fip_2024_text.txt` Argentina ~line 4125, France ~line 4488.*
---
## 7. Professional Circuit — Scale & Growth
### FIP-Organized Tournament Volume
| Year | Total FIP tournaments | Countries hosting | Unique players |
|------|-----------------------|-------------------|----------------|
| 2024 (full year) | **182** | — | — |
| H1 2024 | 71 | 21 | 2,419 (65 nationalities) |
| H1 2025 | **132** (+86%) | **38** (+81%) | **3,266** (+35%, 91 nationalities) |
**H1 2025 breakdown:** 12 Premier Padel + 75 CUPRA FIP Tour + 45 FIP Promises
### Digital reach (H1 2025)
- 1.85 million unique website users (+19% vs H1 2024)
- 30+ million page views (+33%)
- 680,000 social followers (+23%)
*Source: padelfip.com press release July 2025, plain HTML.*
---
## 8. Premier Padel Prize Pools
### Per-tournament prize money by tier
| Tier | 2022 | 2023 | 2024 |
|------|------|------|------|
| Major | €525,000 | €525,000 | Not confirmed |
| P1 | €250,000 | **€300,000** | Not confirmed |
| P2 | — | €150,000 | **€250,000** (+67%) |
| Year-end Finals | — | — | **€600,000 total** |
**2024 Finals breakdown (Barcelona, Dec 2024):**
Winner per player €52,500 · Finalist €30,000 · 3rd €21,000 · 4th €13,500 · QF €7,688 · Alternate €2,250
**2025 update:** Women's P2 raised a further €10,000 (announced March 2025, padelfip.com).
> **Note on 2024 Major/P1 amounts:** Not confirmed in any accessible source.
> The 2024 P2 increase (€150k → €250k) was confirmed from Genova P2 announcement.
> Major/P1 for 2024 require one more manual lookup on padelfip.com.
*Source: padelfip.com press releases 2022, 2023, 2024 — plain HTML, fully accessible.
2024 Finals: `padelfip.com/2024/10/countdown-to-the-qatar-airways-premier-padel-finals...`*
---
## 9. Broadcast & Viewership
### 2022 (most granular year — from FIP press release)
| Metric | Value |
|--------|-------|
| YouTube views (season) | 22 million |
| Live stream viewers | 17 million |
| Watch time | 6 million hours |
| Household broadcast reach | 150 million+ |
| Territories | 180+ |
| Multi-year broadcast deals | 11 (ESPN, beIN Sports, Canal+, RTVE, etc.) |
| Live attendance | 200,000+ across 8 events |
### H1 2024
| Metric | Value |
|--------|-------|
| Broadcast territories | 242 (6 continents) |
| Live attendance | **350,000+** (14 tournaments, 12 countries) |
| Social media followers | ~2M total; 4M on Instagram |
| Broadcast countries (full year) | 187 |
> 2024 viewership ratings / unique viewer counts **not published** in any accessible source.
> The 2022 figures remain the most granular available.
*Source: padelfip.com 2022 year-end release; padelfip.com August 2024 US expansion announcement.*
---
## 10. World Padel Tour (WPT)
WPT operated prior to the WPT/Premier Padel merger (completed May 2024).
`worldpadeltour.com` is effectively defunct — `ERR_TLS_CERT_ALTNAME_INVALID` on all fetches, domain being wound down. No aggregate 2023 WPT prize pool figure was recoverable from any accessible source.
The FIP 2024 report notes: from 2019 (birth of Cupra FIP Tour) through April 2024, **355 tournaments** were played across **36 different nations** on the FIP circuit.
---
## 11. What Padelnomics DuckDB Adds
The FIP data is country-level. The Padelnomics pipeline has city-level granularity:
```sql
-- Venue counts per country (OSM + Playtomic deduplicated)
SELECT country_code, COUNT(*) as venues
FROM serving.dim_venues
GROUP BY country_code ORDER BY venues DESC;
-- City-level market scores + venue density
SELECT city_name, country_code, market_score, venue_count, population
FROM serving.city_market_profile
ORDER BY market_score DESC LIMIT 50;
```
The delta between FIP's 17,300 courts in Spain and Padelnomics-tracked venues is meaningful for the article — it illustrates the data methodology gap (FIP = self-reported by federations; Padelnomics = independently scraped OSM + Playtomic).
---
## 12. Data Source Assessment
| Source | Available | Format | Notes |
|--------|-----------|--------|-------|
| FIP 2024 PDF | **Free** | PDF → parseable text | `/tmp/fip_2024_text.txt` — layout scrambles extraction slightly |
| FIP 2025 PDF | **Free** | PDF → parseable text | `/tmp/fip_2025_text.txt` — same |
| padelfip.com press releases | **Free** | Plain HTML | Clean URL pattern `/YYYY/MM/slug/`. Best source for prize pools, circuit stats |
| premierpadel.com | Partial | Next.js SPA | Requires headless browser; static fetch returns empty shell |
| Playtomic / PwC Global Padel Report 2025 | **Gated** | HubSpot form → PDF | 45-page report; only teaser stat (70k courts by 2026) visible on landing page |
| Statista padel topic | **Paywalled** | — | |
| Market research firms (Mordor, GVR, etc.) | **Paywalled** | — | Typical quoted figures: ~$1.52B market, 1520% CAGR through 2030 |
| worldpadeltour.com | **Dead** | — | Invalid TLS cert post-merger |
---
## 13. Data Still Needed Before Writing Session
| Gap | How to fill |
|-----|------------|
| Premier Padel Major/P1 prize pools for 2024 | 1 manual lookup: search padelfip.com for "2024 Premier Padel season announcement" |
| DuckDB pipeline venue counts | Run the 2 SQL queries above |
| FIP 2025 per-country data (all 87 federations) | `/tmp/fip_2025_text.txt` has the full text — grep by country name |

View File

@@ -10,6 +10,6 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
} }
} }

View File

@@ -372,7 +372,7 @@ def _is_stale(run: dict) -> bool:
@role_required("admin") @role_required("admin")
async def pipeline_dashboard(): async def pipeline_dashboard():
"""Main page: health stat cards + tab container.""" """Main page: health stat cards + tab container."""
from .analytics import fetch_analytics # noqa: PLC0415 from ..analytics import fetch_analytics # noqa: PLC0415
summary, serving_meta = await asyncio.gather( summary, serving_meta = await asyncio.gather(
asyncio.to_thread(_fetch_extraction_summary_sync), asyncio.to_thread(_fetch_extraction_summary_sync),
@@ -442,7 +442,7 @@ async def pipeline_overview():
] ]
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or None last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or None
else: else:
from .analytics import fetch_analytics # noqa: PLC0415 from ..analytics import fetch_analytics # noqa: PLC0415
schema_rows = await fetch_analytics( schema_rows = await fetch_analytics(
"SELECT table_name FROM information_schema.tables " "SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'serving' ORDER BY table_name" "WHERE table_schema = 'serving' ORDER BY table_name"

View File

@@ -2244,6 +2244,79 @@ async def _get_article_list(
) )
async def _get_article_list_grouped(
status: str = None,
template_slug: str = None,
search: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
"""Get articles grouped by slug; each item has a 'variants' list (one per language)."""
wheres = ["1=1"]
params: list = []
if status == "live":
wheres.append("status = 'published' AND published_at <= datetime('now')")
elif status == "scheduled":
wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft":
wheres.append("status = 'draft'")
if template_slug:
wheres.append("template_slug = ?")
params.append(template_slug)
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
where = " AND ".join(wheres)
offset = (page - 1) * per_page
# Group by url_path — language variants share the same url_path (no lang prefix stored)
path_rows = await fetch_all(
f"""SELECT url_path, MAX(created_at) AS latest_created
FROM articles WHERE {where}
GROUP BY url_path
ORDER BY latest_created DESC
LIMIT ? OFFSET ?""",
tuple(params + [per_page, offset]),
)
if not path_rows:
return []
url_paths = [r["url_path"] for r in path_rows]
placeholders = ",".join("?" * len(url_paths))
variants = await fetch_all(
f"""SELECT *,
CASE WHEN status = 'published' AND published_at > datetime('now')
THEN 'scheduled'
WHEN status = 'published' THEN 'live'
ELSE status END AS display_status
FROM articles WHERE url_path IN ({placeholders})
ORDER BY url_path, language""",
tuple(url_paths),
)
by_path: dict[str, list] = {}
for v in variants:
by_path.setdefault(v["url_path"], []).append(dict(v))
groups = []
for url_path in url_paths:
variant_list = by_path.get(url_path, [])
if not variant_list:
continue
primary = next((v for v in variant_list if v["language"] == "en"), variant_list[0])
groups.append({
"url_path": url_path,
"title": primary["title"],
"published_at": primary["published_at"],
"template_slug": primary["template_slug"],
"variants": variant_list,
})
return groups
async def _get_article_stats() -> dict: async def _get_article_stats() -> dict:
"""Get aggregate article stats for the admin list header.""" """Get aggregate article stats for the admin list header."""
row = await fetch_one( row = await fetch_one(
@@ -2275,6 +2348,13 @@ async def articles():
language_filter = request.args.get("language", "") language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1")) page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None, page=page,
)
else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page, language=language_filter or None, search=search or None, page=page,
@@ -2287,6 +2367,7 @@ async def articles():
return await render_template( return await render_template(
"admin/articles.html", "admin/articles.html",
articles=article_list, articles=article_list,
grouped=grouped,
stats=stats, stats=stats,
template_slugs=[t["template_slug"] for t in templates], template_slugs=[t["template_slug"] for t in templates],
current_search=search, current_search=search,
@@ -2308,6 +2389,13 @@ async def article_results():
language_filter = request.args.get("language", "") language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1")) page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None, page=page,
)
else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page, language=language_filter or None, search=search or None, page=page,
@@ -2315,6 +2403,7 @@ async def article_results():
return await render_template( return await render_template(
"admin/partials/article_results.html", "admin/partials/article_results.html",
articles=article_list, articles=article_list,
grouped=grouped,
page=page, page=page,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )

View File

@@ -7,12 +7,7 @@
<header class="flex justify-between items-center mb-6"> <header class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl">Articles</h1> <h1 class="text-2xl">Articles</h1>
<p class="text-sm text-slate mt-1"> {% include "admin/partials/article_stats.html" %}
{{ stats.total }} total
&middot; {{ stats.live }} live
&middot; {{ stats.scheduled }} scheduled
&middot; {{ stats.draft }} draft
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a> <a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>

View File

@@ -0,0 +1,26 @@
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
<td style="max-width:260px">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
<div class="article-subtitle">{{ g.url_path }}</div>
</td>
<td>
{% for v in g.variants %}
<div class="variant-row">
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
class="lang-chip lang-chip-{{ v.display_status }}"
title="Edit {{ v.language|upper }} variant">
<span class="dot"></span>{{ v.language | upper }}
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
</a>
{% if v.display_status == 'live' %}
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank"
class="btn-outline btn-sm view-lang-btn" title="View live article">View ↗</a>
{% endif %}
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
class="btn-outline btn-sm view-lang-btn">Edit</a>
</div>
{% endfor %}
</td>
<td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</td>
<td class="text-slate">{{ g.template_slug or 'Manual' }}</td>
</tr>

View File

@@ -1,3 +1,40 @@
{% if grouped %}
<style>
.lang-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.6rem; font-weight: 700;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.2rem 0.5rem 0.2rem 0.35rem; border-radius: 0.3rem;
text-decoration: none;
border: 1px solid transparent;
}
.lang-chip-live { background: #DCFCE7; color: #14532D; border-color: #A7F3D0; }
.lang-chip-scheduled { background: #FEF9C3; color: #713F12; border-color: #FDE68A; }
.lang-chip-draft { background: #EFF6FF; color: #1E40AF; border-color: #BFDBFE; }
.lang-chip .dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.lang-chip-live .dot { background: #16A34A; }
.lang-chip-scheduled .dot { background: #D97706; }
.lang-chip-draft .dot { background: #3B82F6; }
.lang-chip .noindex-tag {
font-size: 0.5rem; font-weight: 600; opacity: 0.55;
border-left: 1px solid currentColor; padding-left: 0.3rem; margin-left: 0.1rem;
}
.article-subtitle {
font-size: 0.625rem; font-family: ui-monospace, 'SF Mono', Menlo, monospace;
color: #CBD5E1; margin-top: 0.1rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.variant-row {
display: flex; align-items: center; gap: 0.4rem; white-space: nowrap;
padding: 0.15rem 0;
}
.variant-row + .variant-row {
border-top: 1px solid #F1F5F9; margin-top: 0.15rem; padding-top: 0.3rem;
}
.view-lang-btn { font-size: 0.65rem !important; padding: 0.15rem 0.5rem !important; }
</style>
{% endif %}
{% if is_generating %} {% if is_generating %}
<div class="generating-banner" <div class="generating-banner"
hx-get="{{ url_for('admin.article_results') }}" hx-get="{{ url_for('admin.article_results') }}"
@@ -12,26 +49,34 @@
</div> </div>
{% endif %} {% endif %}
{% if articles %} {% if articles %}
<div class="card"> <div class="card" style="padding:0; overflow:hidden">
<div style="overflow-x:auto">
<table class="table text-sm"> <table class="table text-sm">
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
<th>Status</th> <th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th> <th>Published</th>
<th>Lang</th> {% if not grouped %}<th>Lang</th>{% endif %}
<th>Template</th> <th>Template</th>
<th></th> {% if not grouped %}<th></th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if grouped %}
{% for g in articles %}
{% include "admin/partials/article_group_row.html" %}
{% endfor %}
{% else %}
{% for a in articles %} {% for a in articles %}
{% include "admin/partials/article_row.html" %} {% include "admin/partials/article_row.html" %}
{% endfor %} {% endfor %}
{% endif %}
</tbody> </tbody>
</table> </table>
</div>
{% if articles | length >= 50 %} {% if articles | length >= 50 %}
<div class="flex justify-between items-center" style="padding:0.75rem 1rem; border-top:1px solid #E2E8F0"> <div class="flex justify-between items-center" style="padding:0.75rem 1.5rem; border-top:1px solid #E2E8F0">
{% if page > 1 %} {% if page > 1 %}
<button class="btn-outline btn-sm" <button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}" hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}"

View File

@@ -0,0 +1,15 @@
<p class="text-sm text-slate mt-1"
id="article-stats"
{% if is_generating %}
hx-get="{{ url_for('admin.article_stats') }}"
hx-trigger="every 3s"
hx-swap="outerHTML"
{% endif %}>
{{ stats.total }} total
&middot; {{ stats.live }} live
&middot; {{ stats.scheduled }} scheduled
&middot; {{ stats.draft }} draft
{% if is_generating %}
&middot; <span class="text-blue-500">generating…</span>
{% endif %}
</p>

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup from markupsafe import Markup
from quart import Blueprint, abort, g, render_template, request from quart import Blueprint, abort, g, redirect, render_template, request
from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one
from ..i18n import get_translations from ..i18n import get_translations
@@ -230,6 +230,15 @@ async def article_page(url_path: str):
(clean_path, lang), (clean_path, lang),
) )
if not article: if not article:
# If a scheduled (not yet live) article exists at this URL, redirect to
# the nearest parent path rather than showing a bare 404.
scheduled = await fetch_one(
"SELECT 1 FROM articles WHERE url_path = ? AND language = ?",
(clean_path, lang),
)
if scheduled:
parent = clean_path.rsplit("/", 1)[0] or f"/{lang}/markets"
return redirect(parent, 302)
abort(404) abort(404)
# SSG articles: language-prefixed build path # SSG articles: language-prefixed build path

View File

@@ -9,7 +9,7 @@ url_pattern: "/markets/{{ country_slug }}/{{ city_slug }}"
title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}" title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}"
meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}" meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}"
schema_type: [Article, FAQPage] schema_type: [Article, FAQPage]
priority_column: population priority_column: padel_venue_count
--- ---
{% if language == "de" %} {% if language == "de" %}
# Lohnt sich eine Padelhalle in {{ city_name }}? # Lohnt sich eine Padelhalle in {{ city_name }}?

View File

@@ -181,7 +181,7 @@ def _get_quote_steps(lang: str) -> list:
{"n": 1, "title": t["q1_heading"], "required": ["facility_type"]}, {"n": 1, "title": t["q1_heading"], "required": ["facility_type"]},
{"n": 2, "title": t["q2_heading"], "required": ["country"]}, {"n": 2, "title": t["q2_heading"], "required": ["country"]},
{"n": 3, "title": t["q3_heading"], "required": []}, {"n": 3, "title": t["q3_heading"], "required": []},
{"n": 4, "title": t["q4_heading"], "required": []}, {"n": 4, "title": t["q4_heading"], "required": ["location_status"]},
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]}, {"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]}, {"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]}, {"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},

View File

@@ -275,7 +275,7 @@
/* Cards (replace Pico <article>) */ /* Cards (replace Pico <article>) */
.card { .card {
@apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm; @apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm overflow-hidden;
} }
.card-header { .card-header {
@apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium; @apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium;