merge: bring worktree up to master before pipeline console work

This commit is contained in:
Deeman
2026-02-25 12:40:37 +01:00
139 changed files with 9276 additions and 1730 deletions

View File

@@ -1,72 +1,76 @@
#ENC[AES256_GCM,data:rfm9xw==,iv:yWV+DjVlLNdDXw8brZZ98NGMr5pF88Oy14laCyF9XSk=,tag:EKvfFOCjrJD8NTQ/gOym7A==,type:comment]
APP_NAME=ENC[AES256_GCM,data:H4Ho9hHoL4Fo+4c=,iv:hBnuls1xYBtHMxU/womw+Om3JR0yrKXp7+VeiLcZiyM=,tag:oKJiE3VekDwMjpF+evoumQ==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:+5bv1jlS+1DnmKxVxebcdJ7+ADJjvgk3hOqUM5LnB/0=,iv:Y9obfg6ttf12J6L3hgVJr1S4tJoayFHpp2zfUgT1Vek=,tag:RWN2OMZqpNm0uY4YqV9FZg==,type:str]
BASE_URL=ENC[AES256_GCM,data:DSgPTAuGfA9/ntDJ5JT34zVbJush,iv:Lh6vcDVfPfPBi0Bwd34h2CQX+D8bxqWF8O47Oid8EHg=,tag:PoP8DUX+GsAmc7Ntweeing==,type:str]
DEBUG=ENC[AES256_GCM,data:ibm6FA==,iv:nhDzB8x3pe6ehhU69S1ZN+cNN7Cchj7MK+8NUea3Zug=,tag:kcL4esX+uPNdifenuW0TMQ==,type:str]
#ENC[AES256_GCM,data:LdfNHhD4n53JP3blJVGX7lfg2DuaUsFp0p0mI0SPjCOlgJI661jGXjSpvtZP+3Fh7g09KzUNjlhFuugR7082u5WRiQx6ks2BKA==,iv:lHqw7UTr68hZLYyYYbJuB/Mqfyds87NOPl0yE5x4eyU=,tag:rDrOSOqyBIPFq1BC4Ktw8A==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:0jOhhL5ncjyx7c3hGg==,iv:UdU6U7Qz50KL+Aa1UPo1Vvo0Rhb5aT0MdxN2sFW/sMc=,tag:3LhGEXuXTu2mtMJbnFFsSw==,type:str]
#ENC[AES256_GCM,data:AyuwH3wRLrh7,iv:4A/7vGSqb7CVLePYrgKqGJIz1hqJwC0v5ikKtOhMLUM=,tag:ADfmbAXEZIF8HObsTM3DEg==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:zLXck4opQIMGFqc=,iv:mqX3ONrD/hph6teavhSh9m30FAR3hIxQdeeJb4SnOR4=,tag:LgT7C816/wQlYHECZ+1gww==,type:str]
#ENC[AES256_GCM,data:wiAYWd0=,iv:ngyBfrG1QEBh5TXulXlCKSuzRccFhxYs8GPozCz5Uqo=,tag:kdwHR1nQm0bmTbbTrEUqDg==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:Tvc=,iv:eV62HtqgApJXdiHTWLeWj+ESCK3GK4OyHmSgNd6gsxw=,tag:TJAH5lUx/XWKtDu2Vt/mBw==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:3gk=,iv:gtuxbN4TPF6kuEr3W8WVxH9cDYl4KyYqFpUrIPCjalo=,tag:QE3OBTJoQfG2GcD/UXBVBw==,type:str]
#ENC[AES256_GCM,data:dEwIOVzHW+whu48/e81j,iv:v9Du6iruCsArD8F/JCf/xy9xxzdV23wCnfHCkuIgPY4=,tag:w7RVK4a0pxmkNDwLTn3mRA==,type:comment]
#ENC[AES256_GCM,data:+aUQXMWkNNHvFcINaDzgi85NLBsSHISWr3oAkB5qJwqKv+uC+MtqkQ2rVaDbfBAfKUKU6r64ShcWiFc/p7ikNEIaeVQYNK8JQ+Tn+A==,iv:w7G2AF3FPh7Qe++HhFuuLQjZB4vrcrG0uI37L2rXuDA=,tag:zU0czMUSD0qKIT8sLKviWA==,type:comment]
#ENC[AES256_GCM,data:9JNa0g==,iv:KnGl3/4KQWkVnFXn9iKU5z5Ys6KXWOnSEoE/Jjks2pw=,tag:ZD3nrOQmhUjPkZiwtV330g==,type:comment]
APP_NAME=ENC[AES256_GCM,data:Vic/MJYoxZo8JAI=,iv:n1SEGQaGeZtYMtLmDRFiljDBbNKFvCzZPNtaFBNauYY=,tag:Smsd20Ba56QZKVFpRmhRPQ==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:a3Bhj3gSQaE3llRWBYzpjoFDhhhSsNee67jXJs7+qn4=,iv:yvrx78X5Ut4DBSlmBnIn09ESVc/tuDiwiV4njmjcvko=,tag:cbFUTAEpX+isQD9FCVllsw==,type:str]
BASE_URL=ENC[AES256_GCM,data:LcbPDZf9Pwcuv7RxN9xhNfa9Tufi,iv:cOdjW9nNe+BuDXh+dL4b5LFQL2mKBiKV0FaEsDGMAQc=,tag:3uAn3AIwsztIfGpkQLD5Fg==,type:str]
DEBUG=ENC[AES256_GCM,data:qrEGkA==,iv:bCyEDWiEzolHo4vabiyYTsqM0eUaBmNbXYYu4wCsaeE=,tag:80gnDNbdZHRWVEYtuA1M2Q==,type:str]
#ENC[AES256_GCM,data:YmlGAWpXxRCqam3oTWtGxHDXC+svEXI4HyUxrm/8OcKTuJsYPcL1WcnYqrP5Mf5lU5qPezEXUrrgZy8vjVW6qAbb0IA2PMM4Kg==,iv:dx6Dn99dJgjwyvUp8NAygXjRQ50yKYFeC73Oqt9WvmY=,tag:6JLF2ixSAv39VkKt6+cecQ==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:hlG8b32WlD4ems3VKQ==,iv:wWO08dmX4oLhHulXg4HUG0PjRnFiX19RUTkTvjqIw5I=,tag:KMjXsBt7aE/KqlCfV+fdMg==,type:str]
#ENC[AES256_GCM,data:b2wQxnL8Q2Bp,iv:q8ep3yUPzCumpZpljoVL2jbcPdsI5c2piiZ0x5k10Mw=,tag:IbjkT0Mjgu9n+6FGiPVihg==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:pEpMUrL7ZHAzMT4=,iv:eDGudDVsW5vF0sENri7gQrFlCEdoWYP6hT5ZeXXs3Zg=,tag:Gl91C6uRdCiJ7Jo1Z/MQsg==,type:str]
#ENC[AES256_GCM,data:xVzlko4=,iv:glHTshoRIkIaJNpn4onyAxPOtXTsNh/JohXJyyu4Ars=,tag:fQ/53HdxYXs2JTMx6O8rrA==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:Ua4=,iv:ou1kEn+fa42lZDsXaPvpodJcvAF+EZC9UIGNK/tBV/U=,tag:+ed8Bm/8pdIksH7O2X8WwQ==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:/gk=,iv:kKWy+FaoLp8kAWpZzpoUHX8nVFRaA4yuTTVzN2TSYTs=,tag:QypZTVmTo4lXd7PKTWrBdA==,type:str]
#ENC[AES256_GCM,data:8HLEkeUESRt3bOxIQsma,iv:kzt8+SFNJw2r3LqwwQPzs9bCdacYSfHWPzIvTxARI4k=,tag:n+F13eILUiJCZ3NtQdo26g==,type:comment]
#ENC[AES256_GCM,data:cGkjKPIfdOPWoZFEXTAgw5lsu0LIcNhu1y3ab47kKbVEZMiCk+0KrEUNJcqbQ+ProBQ6F6N38PUhUl0lhKKjMqjepMZUUrUTqFp0Tw==,iv:WyFISLnRnSSOkra/p7bOs5BQWx+qFaaeeZM50EdrIgA=,tag:UM0EiRGy+RFXfdJqRuv3Jw==,type:comment]
#
#ENC[AES256_GCM,data:yHT0cngMLYrKnRX+theDXOQP7Y8jVK2f/2dwbHRq8Z5spK2IUsOChU2WYpuGWFqMMkXPwjiy9Oe7ABArRdxfX849gwuhXt9Y+re45N6o3Wo=,iv:u3TWONVcLrKVAypavvk7ioTLePlqdJByjxOwU128ubU=,tag:lfsQxzorm/d+bWQAXUK2Qg==,type:comment]
#ENC[AES256_GCM,data:ua/erKnlXzB+Wae1Jwdr4Xg0Cy3xIiaEpPJefBq1eEOm54ZTNeqZGFfYS7LuijgueuOdq/Bmacq56sfNbiEEbUeehf5f,iv:GWuX4YPyG/9KBdK5RNrH+hFB5QXc+Ep8Ao2mYm6/EFU=,tag:dTPTsWaloxADlplot5SNfw==,type:comment]
#ENC[AES256_GCM,data:K5FoYsnz7dZBR/HZh4KX93WipQDQpvUP+o3xDUNALCm2zZbtQ8pPmTwbug7NpJnrm75MGkI=,iv:pxm+cMZhJOzI5Uys25JRL2g2allAHG5v6VFqNpyvtrI=,tag:GTy5/FtmT/sqADxoX3Yg1g==,type:comment]
#ENC[AES256_GCM,data:Apk0ukZWg1ZguSFLaRmjDq4hw+RQc1CnSqcJAybC7m+SDW0nEkMY953nVkqY7oDvKeuhEIUySg8=,iv:3QOosRhjJHve9xw8y3rD4guZ5cv9B9uCJaMHqgkyO1A=,tag:fETCJefFt1G5fIonehAlXQ==,type:comment]
#ENC[AES256_GCM,data:PsEk9JIsPZJ8sHjVLugtfCiKx1ulPXjjtz2+zS1fKrlAgDTUJ7Pe0vCuSgemfCZG5hlfK/DN8ows5oaeb28=,iv:d8S9eXEM+U5vBiDj8nCv949qFiec1deN348QUDZMDII=,tag:tJAn2b2U5OmaWSWBJRmfAw==,type:comment]
#ENC[AES256_GCM,data:sCb3wLMhMX592Si7cIPgvB2hfl94qWNWifpDVgpkdMyF5y15PS+SZ1ouetU7Gi7UEVzwWxuct80=,iv:LpSZ+QZG/VqK1cUxVakdIi0bRjwBPMCLNEPr7D5xIu8=,tag:XPwk5T66WmViYf0faBsm9A==,type:comment]
#ENC[AES256_GCM,data:Ki5g8TW+PpA/WUDk5zfZdRJrXfY24L6hSTG82PtwxbzZwKxuj/URwS7SxJYJMfuMYvOIcz/l+GHX7iKf6/SLIM12u454hoTj,iv:gMePS2eidgVf6ccnNUlJlGjUAcm48H2mCVGlxbgZxFI=,tag:hPXD2Lq+iWut+Vt2YU/LRg==,type:comment]
#ENC[AES256_GCM,data:icBc0Zv+oedobh8DOTwV2Fc+N0C9CqjZvLciC1dmEIygr/P5oOBLH9Bnhf7XW3X+fiLUtLPQPUIh9CjX+wVeX+MpUZj8ksS0meoz1O2kSBs=,iv:oWreqF6QxaFZn2r35uqY7yy/nItwy3k3VuXAcLyqMbI=,tag:ezQO+m6qkQSEwe17vYtYcw==,type:comment]
#ENC[AES256_GCM,data:BmlWl0f3aiOrEVglJisqHb507/ipmyRCUvkygs2jBfh2gw3BJgrCAAqoK+DekvIls2myRn7RynqWTMZKGXtJMHu5SEA8,iv:QjPoSzyNl6CBYmJAd2OfFEEoXO3jz0LL2VNegP0mY8Q=,tag:miTvj9PK1a00BKaAjUTICw==,type:comment]
#ENC[AES256_GCM,data:kzSYQCopgU9wXpw61WGfYpRtOjV3iEVvZ09RP4OqVl+Rqnd2wCKREKKrB7F15bp4BB3OzMo=,iv:TFFpROfYKaUlWQm3ISYkyYdZCarSJbqHItLMUplYiXc=,tag:Xbk3ii3DYE4yPe8cJOt+Tw==,type:comment]
#ENC[AES256_GCM,data:qbh0Mnu6wEbDaBideJQCZa74G/DqSWuoiy22zCGoKqKZ2YVQEf0QxRCO/DgOD8rdp1Neqp8u4oE=,iv:dKV76b8sT1ghlyEadeAqTjtNTXrBp9n5ZbGMGi2/GyU=,tag:1HsI5iDekTqxeYmcEpL3HA==,type:comment]
#ENC[AES256_GCM,data:kTLnLnwPVVDFKYncBbFjGmnbxmNfpPXSpKyZu5ZQx6PeVs5s6hpDa55zRXxAetyBAHsmV99ZV2q1NTDXF6Q=,iv:m/ulRFQcGl15vi2ohMwVeYBmcRtp274ROiKXPsyJkfQ=,tag:T0E5p4d/inHyuupbg7bZHQ==,type:comment]
#ENC[AES256_GCM,data:aziyEFGCGxbc+q2ma2QN4MvdhQ6bnYuZA7Cgqr6p3zGjPG3oybTWwILejJqD2lHmULXh0UN0qco=,iv:XZwwEUOAEXIUyXiiHFS/bdL91bWKIhZ5IzcXWXAR63Y=,tag:JLsDM4s2yh4aBFQtxWLhDA==,type:comment]
#ENC[AES256_GCM,data:JroBWIbSs5a7+lg/AtBNPxgbtxaztjmVzI1JXuhBmfWD45Qp+w8ePZg9PA1FYzPtEATPHso6m3tZdMTrPtv4Jj6ig/+KHOLn,iv:CbJp6MHOU4yk9OdynQTPwVgZj9Djw9IC9TE90go5RDk=,tag:NTVDg0mFeZsxfVeIFwFhPw==,type:comment]
#
#ENC[AES256_GCM,data:97HOuSOoYawq8c9bZgNaPEVLxJ2Wew/IbljdGuevTk3cSLeFJw1Ih+JELvBMfN7s6GaTzI5NXt0+fQGA7AnihKJw5vQPshUMTXI=,iv:gFNm/7O6GxLDhqtkBXMXts5XC22qhagCr7y1QIw+k8I=,tag:eKhFM/n25eROi+JoYN3ZBA==,type:comment]
#ENC[AES256_GCM,data:JDxXJ3IJVaBF+MOWe0WXBOPnee48RyjNDtflwVM2FFbLSp1h2uYf6+aRjC4w6pb/R1pl9+AzjjlQaTukjQVwXfbBVKH8SAbhOdU=,iv:K9kvgAbltLIcBo4vZ8NUiaL/Ik+x5Arl9Pj5sh3SIHo=,tag:IjneoyVD+dxd6N2PVV43ww==,type:comment]
RESEND_API_KEY=
EMAIL_FROM=ENC[AES256_GCM,data:8D4sqeCDj0dw1Kh0sHi9h3q4ckg=,iv:GlWgA3OzZUgMbg5MQwpiiWpn20at/tkgxbpR16io5qo=,tag:Qpq6B2tjzPXBiMzvNgjSaQ==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:Pj74LSKvkjJ48RLqUuAPpOzrgLI=,iv:iZinDeQbqL1DfbqYu0Duux5GQNRBYG2JhTXdjXQgpOA=,tag:ZWXSf4WlSL0wykOVPFjf3A==,type:str]
EMAIL_FROM=ENC[AES256_GCM,data:QX6duq5wx3z98o39nRXTrPpNXwI=,iv:ikpykHOeHRay+k3B4MvHn2SOuHNGOIuvjetOt+cjTrQ=,tag:8ryM56ogWySp9RAv0/ABTg==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:aVFgeh0Yx7W/88noeURvf8rirv8=,iv:5KjsCMAsu1Ywz4BI2JjB1KmQ6QM94U1zlNGJ3BKl7Uo=,tag:voi0kjdhz0SsOQHqtMID4Q==,type:str]
RESEND_WEBHOOK_SECRET=
#ENC[AES256_GCM,data:gjvHsdCmiGT0hw/lvUuu7yMfXWMBjvwAvvwTl0RpZxptLiG7Wz4s6A5saBnZCZbnvrHpXoJJ2lyPqWdt7XsnpRBAQQ==,iv:jMWd+hNbwtB4DCUM+pjTihrRSSCVr+qNuoAT4pZp7QQ=,tag:ncxbxmCRgiR1VNuGZTu0mg==,type:comment]
#ENC[AES256_GCM,data:prllSnPTUbTo1E26lMhrbrrgTmdCK+E+Z++N7BhW4tXJBFpSgJ/vmrISWB/X6cLdYgIewyByPPmKlHCoLsAVwdb5/uDeIKKwdJXp,iv:c2JxexmzVOeiicv5b+0SXq9ylTRk9+ad2umgsJ/5IPo=,tag:uD2m9Ghbt6ZxV7cupFJpvw==,type:comment]
#ENC[AES256_GCM,data:NU2hol5nqs8ffhhDqAXZg48eFzPTw14gO4zuyJhlO075bC18EIMi+2xz0Hg7CC5aa+AuywdjSXeO92j1CvM4YKfng63biI4b/NIdXQ==,iv:IYA4mY+V8jQD/jElsgbwa5fRQ336XSzYv3q9OlZ/DG0=,tag:OXZwhCSmSADMxDotptCKvA==,type:comment]
#ENC[AES256_GCM,data:xRM7eWDm3yrN4gdmWR6nlafMlL5F+0CbNa+45c6dU53fwlf5eFnpKF2700/8XwWe5h6s,iv:v5/tdyxozElqXLjC4Dr1HzHVPwI7e9DgK53nB77pArs=,tag:md4YiR0gkSaTiEMhrgV/4w==,type:comment]
#ENC[AES256_GCM,data:1HqXvAspvNIUNpCxJwge3mEsyO0Y/EWvD3vbLxkgGqIex0hABcupX/Nzk15u8iOY5JWvvEuAO414MNt6mFvnWBDpEw==,iv:N7gCzTNJAR/ljx5gGsX+ieZctya8vQbCIb3hw49OhXg=,tag:PJKNyzhrit5VgIXl+cNlbQ==,type:comment]
#ENC[AES256_GCM,data:do6DZ/1Osc5y4xseG8Q8bDX84JBHLzvmVbHiqxP7ChlicmzYBkZ85g43BuM7V0KInFTFgvaC8xmFic+2d37Holuf1ywdAjbLkRhg,iv:qrNmhPbmFDr2ynIF5EdOLZl3FI5f68WDrxuHMkAzuuU=,tag:761gYOlEdNM+e1//1MbCHg==,type:comment]
#ENC[AES256_GCM,data:dseLIQiUEU20xJqoq2dkFho9SnKyoyQ8pStjvfxwnj8v18/ua0TH/PDx/qwIp9z5kEIvbsz5ycJesFfKPhLA5juGcdCbi5zBmZRWYg==,iv:7JUmRnohJt0H5yoJXVD3IauuJkpPHDPyY02OWHWb9Nw=,tag:KcM6JGT01Aa1kTx+U30UKQ==,type:comment]
#ENC[AES256_GCM,data:GgXo4zkhJsxXEk8F5a/+wdbvBUGN00MUAutZYLDEqqN4T1rZu92fioOLx7MEoC0b8i61,iv:f1hUBoZpmnzXNcikf/anVNdRSHNwVmmjdIcba3eiRI4=,tag:uWpF40uuiXyWqKrYGyLVng==,type:comment]
PADDLE_API_KEY=
PADDLE_CLIENT_TOKEN=
PADDLE_WEBHOOK_SECRET=
PADDLE_NOTIFICATION_SETTING_ID=
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:YzbXeOJr4Q==,iv:eZ0lAAfjVTtHHEkBR80fZACE6VTXrow4bnogAz8VI48=,tag:Uxs9oEZZI5jBloSSlOPoLg==,type:str]
#ENC[AES256_GCM,data:JZ+dTFncUwrhh5kdBeKbHkPk4HNOu5Ka7l8IhPnkcpbC4+opxuviWV8QXG/lcOlg9SN004FQ83kOPjrI,iv:3txict0Am1Gp/qNFgB5d7d46nVLtyBBixXdJjGiRoVo=,tag:Y1D5nzSveG0c3Qoyvy59lQ==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:VLov17JIMAAmiv0Rq8TR637k1ablVBtJ9GKgWQ==,iv:MqV0T/4xqWit/vZm+sMu0LNTzCH3ILFCivQnD8LTpXA=,tag:5VdbDoubaaFy67+y4u0EQQ==,type:str]
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:KIGNxEaodA==,iv:SRebaYRpVJR0LpfalBZJLTE8qBGwWZB/Fx3IokQF99Q=,tag:lcC56e4FjVkCiyaq41vxcQ==,type:str]
#ENC[AES256_GCM,data:2Hs7ds2ppeRqKB7EiAAbWqlainKdZ+eTYZSvPloirT4Hlsuf+zTwtJTA6RzHNCuK4em//jhOx8R2k80I,iv:1N6CNPqYWp3z8lm5e2Vp6OlpgHdMOiD7dsEYp23nMtA=,tag:ulWP/BFFoLljLMVCrsgizw==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:oX/m95YB+S2ziUKoxDhsDzMhGZfxppw+w603tQ==,iv:GAj7ccF6seiCfLAh2XIjUi13RpgNA3GONMtINcG+KMw=,tag:mUfRlvaEWrw2QWFydtnbNA==,type:str]
UMAMI_API_TOKEN=
#ENC[AES256_GCM,data:hxmk761Ynp57ssLcCIM=,iv:ApzwBN4h8ZU7XvJEG3V8Jr+OH3yiTxq2hx0ts+1MP0M=,tag:msUwMv/h8Z5pyrKrYzyjHw==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:p0XT,iv:FXMjZ/Vi0O3ZvvgT9P12fYV57ksWkIKKHsXTFAtJ1BQ=,tag:oHLBpOp6WIGxxtkEdJyutA==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:+6s=,iv:vwMf5cyfkwxSB4mA8/OJabURcGHHQNS5I9jIA+CP74I=,tag:HwT077P9h6+YkIwVJe9HTg==,type:str]
#ENC[AES256_GCM,data:DWa+fY4uRmAYEQyxQUepVUiZ23Kw10IqUdiZvqqo5lKn83IPOqqutM7TO//QSeCCGtExATPXx4WumfsWWfAnyfH5W/LndJ1AlwpVOoVtpWI=,iv:Uj80Naei97O7hGyEcxfr6iFzgERUCEkRu1iKH4DuJX4=,tag:xcz9zPrEEauLyRtoVffsjQ==,type:comment]
#ENC[AES256_GCM,data:HTG/nKNl9NMicZVt5nU=,iv:MfRqX6tzdl6SC61xjRxTrVRpTWGmmqslL/Vdy88Jtyo=,tag:NhOgm3+qJelmQaAAnITFKA==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:hy3B,iv:kouDI24Gac/S7aQMXRcl+emwE6/WU+F9egNhQ+MayrA=,tag:iZXV92kqnS0MppvW6Km5oQ==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:vE8=,iv:lS6cQX3VzHeVrlYHQTXYGgib1rYI9G4XoW/f5YSjVWs=,tag:3Bn8PIktDxD7HvUTHw6mnw==,type:str]
#ENC[AES256_GCM,data:KRlMK35PPFBTe7FOkbanuskbA4oFj51Fg290lRtwyHKoJxi7fHg7cueojwCiRSJestRguwV8g9UP4MC9bKzWssdFqvfdr7XEUuA3a+WWD9I=,iv:RZhJJS6tNZHecxn/862nnl8dg8OwsVYB/R0yPxYMXgw=,tag:dqXgcU8OSyJzOPJp+7Z+cA==,type:comment]
LITESTREAM_R2_BUCKET=
LITESTREAM_R2_ACCESS_KEY_ID=
LITESTREAM_R2_SECRET_ACCESS_KEY=
LITESTREAM_R2_ENDPOINT=
#ENC[AES256_GCM,data:Wh8wbI1ONGwz7YmTh3g=,iv:hsgNVrb1ZmItszvWSW5XozSTSoORc48ePg/L7wk3k0E=,tag:ryrs1Myp6JMNO4PppsEy5A==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:MA6E5KnIZxOd1rOA5cLGk0oXoTk5,iv:Q+xoCHnf6x4ismgOqXSqePEV2T5RV8J2KIyD+Pdidbs=,tag:80GfhXAZ4AeXQ4HEz72K9g==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:ubhnX43J3bEw1g2xJDhQWJiLNYrd,iv:Z9ltDGDYhTl98Pg18wCmU+Qxco8+PKleZ/SkhD9XGCs=,tag:7BtwbKiHcM17DpvnwLSB/Q==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:e+ZJClS0YWbTOgVo,iv:Mh13edgTjG/cW/0hsdvM32uQOlBJwVpC2nju67+n84Q=,tag:S6/50MMQOmxMmpCC7XRavQ==,type:str]
#ENC[AES256_GCM,data:hrtFixymQ0XR1t288qEETWAajvEe13tZlSAmnwpaEGr11wzr+b6rd7QHc+enb2/lkSOcnIKRxCOZtu6y+tEAGuZImpijf5+Lza8=,iv:CLniQ1Jf6JcmzgHpzbCn9WFTJOPLGTFXu/5jVCdIrtQ=,tag:vv5Hwu2TGQeGRejEOwa/dg==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:ZA==,iv:TH+5LUPD7fKSj+kgtFCmsxEbG0sO5gtNPBi0k5yuiAc=,tag:pVy/nn7YalVV44zELdMIyg==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:KcdUD1rSa1VBKzktiuHGA+a/cI7m/GWXkrKr50NhgQ==,iv:VgF8+wZmg61+sVoHeL2U7PJuTQ5UuOeonaTPX7mdHBA=,tag:CIRYpkEFnbCFN8+zvRZVag==,type:str]
#ENC[AES256_GCM,data:4To0MRZIt3HxO7qjh4E=,iv:/caczOlTPECDF6mA1PKO8Xm4NeR1RZjgpt2Vuq+rfkQ=,tag:S/UGMqHZQX/Q20N+Ah30WQ==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:sql4dtOLeX1aY/kdaxAzCk47hm3t,iv:S63x40+5blcF8qYxMjqUhs2moukuy2yEQRPbUvXZSYo=,tag:lTLYjtyZNiv06o/hm6Grxg==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:xE05ajjqmYggI9oz4w1GBucUn0bI,iv:/C3D+iWSNk1XJ/xclTzdJTqOHR12Gwmo1xIxH/4nyL0=,tag:eNcgrb+QvL/y1jE8mb0DHg==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:PNPOE7/MV/iQ24mf,iv:lg486nzb/vlOyTHVQ0HEO4fK18IEJNnuSc/CrQwUsHk=,tag:zecZp+Xfw+dL5GtUeIOg/A==,type:str]
#ENC[AES256_GCM,data:bsiiPYvTz0LtdIgopkPEtcgmtDzZU0W6uton/sqm++5UymV33B0m47LIpdH9xQurQtmoZwMCBkAe0FCqqz62D1dAIH1Q6lzzLqg=,iv:rr7aShvtJtAnBzcbr/O0wOONpDBzwbR/Wbx/YPPsKpM=,tag:YH1wdokUuudFvagnPuT8aA==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:vg==,iv:TNMZ6lrajWy6C9q89/AbRkBawBc2YaGsn2elbO8V2Wk=,tag:va77fkt8VDpPG8pZu490uw==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:PehxEUMb1K3F1557BY3IqKD7sbJcoaIjnQvboBRJ1g==,iv:WfniguOksC3onCSyDlBpfKC8bE9DAt7evoeYX0K0lvE=,tag:sdRWDqkk9dtuESvfbRBfCQ==,type:str]
ALERT_WEBHOOK_URL=
NTFY_TOKEN=
#ENC[AES256_GCM,data:407y6mp/tJLef0I=,iv:661eXVnxobVG8pWCYq3MZ6WO9yYzdMBskwtReeiVe+Y=,tag:+6W6h8EGU68KZ7Vz8QozNw==,type:comment]
PROXY_URLS=
EXTRACT_WORKERS=ENC[AES256_GCM,data:Cg==,iv:w6JWrCAfBJuUS7Kwc4JsvCCbYGU4FIc18JTd7C6kiak=,tag:yJ53uTn9cs+GtghTj9tjxA==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:/3Q=,iv:xe8I/VBCGK49qTDY2Ehci9jrY4j1gPOzbx39mAJjf0w=,tag:Ekx3G4cs60CvCehCJuyl9Q==,type:str]
#ENC[AES256_GCM,data:K8tsERccx4RgTurYruu6tctL6+sHz471+pAWRDld9sLTBFWD1HgTd2MtGqybuEJyU6lp4/fYPXZKOc6sff9EaqiK52dGtqbe,iv:2hlV+RsPEBOx90ZnBx4Hb2tPdrJqyri4Ic/cxReiV2o=,tag:lHT4nLUsb//ncn/E4irfWg==,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]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
PROXY_URLS_FALLBACK=
CIRCUIT_BREAKER_THRESHOLD=
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,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+IFgyNTUxOSBYR0RXNG5kczUyUVp5VnE3\nRUdFdmxpY2I2SUNPR1JVbkZYd0pMblFpV3lZCitMNnl6OFdIT1hCdVN6Z0lzVU5D\nK2FzcFpWdUJsZ09ubXYvemVuT0dEUG8KLS0tIGcrcER0dUVnSEdHS21MQlVzLzQr\nc1VnTERieVJNTE5UUjNXTWVESzVUcE0KYyHa1Y42l54gblStQHKKPZZ0FJJBr9FT\n68A1DVRU/zXgvO/wkBaumKqBDQqMVKOPzQGRggb+RoQtlVEfU57DGA==\n-----END AGE ENCRYPTED FILE-----\n
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str]
CENSUS_API_KEY=
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-02-23T21:01:40Z
sops_mac=ENC[AES256_GCM,data:xehhYZcf8o/AWztlWOM/QGUl/SGf2ZXXJHl0GOiZ5s/VfItoXGx0elcV13wWnlMLOb4oRnFzblt8J0IgqCINDdKsh4JHDqKAEVjBm0cTulA6ZmKELB4hopPZve3c9FwU0AAO7jKWJpNzg0ymIxNvF05JwZKL3ILr+55s9Tun7BE=,iv:VcMqkoaLgn5P8ds/oRfObnf6uDnULBSJMJgrozDyw78=,tag:84UvqnHen+qe7rS/8HffFQ==,type:str]
sops_lastmodified=2026-02-24T21:48:18Z
sops_mac=ENC[AES256_GCM,data:RmSB5aS5Avl1jzeSmZPdDS6u+QPKDVD/1A55slXXdht96Knbh7IjaRsqggql9uixQO0/6WWkXsxhcKDWhsbYb0el2ATrLWXHaV6GQqfLq7RUynagcGTNHj8ipizQ93MqaDlXnI92ZOEHNcgvJzRuvRLJYhMErSyzwbUxtbaGMNM=,iv:o5wY+9uurzsTOMgmblGi0xcyYMsYGMfICmt4dSBlt2w=,tag:UKhqs3pedmvP/HjGJb0y4Q==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -1,59 +1,61 @@
#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:8qKvOA==,iv:Xci2F8lcBpT7dmhzaDe6sfrtQi+yQD7e2CQsYLAdCnY=,tag:3duziYwr7PoGQILUuY8nBA==,type:comment]
APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvzO4kSiWho2oUo=,tag:qzrr57sTPX8HPyDVwVL4sw==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str]
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:qxQs7dG0RWMA1rs=,iv:5ZUyk02hCPQESr2vFz3mfnUhUF74LbO6YK5+HFBbxUQ=,tag:daQxiWAhzCB2cScjzjYwaA==,type:str]
#ENC[AES256_GCM,data:aWgKm9Y=,iv:8iT6GHSzWhM+fRX9PIY9wAs7lXj/ADS6eZK9BBSEdaQ=,tag:aSLsj52ybnod7Qfmx9BLQA==,type:comment]
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]
#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]
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]
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:EQpvkWFyt8H7,iv:6QiZIDo5Ps39vf9MKkiqSJir7BH9zhoLREJ425y3FIs=,tag:kjO4dczb2E5FKfO6OVaQvw==,type:str]
#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_CLIENT_TOKEN=ENC[AES256_GCM,data:JPmeLZx16WuV,iv:52EczBQM+fvEQuzoY8Aon0RBZiLzf1vrbT9Kx+b/WUE=,tag:+abUTzCgxulamobp13PbWQ==,type:str]
PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:fk2PbtpwoGRB,iv:QOhOd4rKmVjMA1EUQUjSj/y/OM7I435K/s4aqShjQNw=,tag:RIfbUCXAQGmCiE9FODHgpA==,type:str]
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:igRsm8JOO1SP,iv:vQgOZcMHt6YoE+U2d6tT8sILOwsTx3glHVBBatR6Sk8=,tag:1tApDyZmZNiwd3bVm0uZGw==,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]
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]
#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_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]
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:opg8kQY3PKnZ,iv:lPHUBDwHgBulOyt9WWgZhBQae8t2WKYvLHSFQrG3N/w=,tag:qtyIz4fbh40aLp7ZawBJiA==,type:str]
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:6jaEysPtRal7,iv:s5aLx7LdZ3ZLA9oL5vXXDfDDGI7gd5/CukNrMpPLJNk=,tag:Igp3bqW52raBfEeUaUvZ7A==,type:str]
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QfXhwh9L2rhr,iv:OaYlzTiu4onCNu5HfytYTCJa5p2QLShhO5j5Y038IOs=,tag:i13aQ2ICePyCU/Ob+EA7Nw==,type:str]
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:hLneNsFmgQ6+,iv:RNefJ3QbviHPURxcK2xYJU7qWpMfWInCxYQ/4xDIwfw=,tag:FhMiHGrNcsXaSmdG4NXgfQ==,type:str]
#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]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:NkEmV8LOwEiN9Sal,iv:mQHBVT6lNoEEEVbl7a5bNN5qoF/LvTyWXQvvkv/z/B0=,tag:IgA5A1nfF91fOBdYxEN71g==,type:str]
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:4sXQk8zklruC525J279TUUatdDJQ43qweuoPhtpI82Y=,iv:1NT5IsslsZjo/0xU9OGFf717G56FnSkKSZ2L1+U3peU=,tag:bhZ67zlDiq7VaY47LFWOVw==,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]
#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]
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]
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHTwBr/bNnCeqsTrUqAAYM5Vs=,tag:4jBxqgpyomzMLwiC9XpfVQ==,type:str]
BING_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]
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
CENSUS_API_KEY=
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_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-24T21:37:45Z
sops_mac=ENC[AES256_GCM,data:FdIU0UvGEc/P7ETNOxYHqfsGMNCdBVqbxHVIrR1v4hAnTWYHelawJqifQOOArTyNGjfsIRGajct7CLADkGE/qVm6vSQO4m6w+veSGEO39Wvlfz6BrVSYMqWMjGuJsTj/TJGSZDBnyC//Jzf3pTTgXrcjM86aoLbqhT/Qbb0JIiE=,iv:fgP4Ro0Cd6u1n9G07UsMkQNDk3fCQPe5hixA3KXhcAk=,tag:2PEKkltbD5TICzZ3WgvXQA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -1,3 +1,3 @@
creation_rules:
- path_regex: \.env\..+\.sops$
age: age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
age: age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a,age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw

View File

@@ -6,7 +6,128 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Email template system** — all 11 transactional emails migrated from inline f-string HTML in `worker.py` to Jinja2 templates:
- **Standalone renderer** (`email_templates.py`) — `render_email_template()` uses a module-level `jinja2.Environment` with `autoescape=True`, works outside Quart request context (worker process); `tformat` filter mirrors the one in `app.py`
- **`_base.html`** — branded shell (dark header, 3px blue accent, white card body, footer with tagline + copyright); replaces the old `_email_wrap()` helper
- **`_macros.html`** — reusable Jinja2 macros: `email_button`, `heat_badge`, `heat_badge_sm`, `section_heading`, `info_box`
- **11 email templates**: `magic_link`, `quote_verification`, `welcome`, `waitlist_supplier`, `waitlist_general`, `lead_matched`, `lead_forward`, `lead_match_notify`, `weekly_digest`, `business_plan`, `admin_compose`
- **`EMAIL_TEMPLATE_REGISTRY`** — dict mapping slug → `{template, label, description, email_type, sample_data}` with realistic sample data callables for each template
- **Admin email gallery** (`/admin/emails/gallery`) — card grid of all email types; preview page with EN/DE language toggle renders each template in a sandboxed iframe (`srcdoc`); "View in sent log →" cross-link; gallery link added to admin sidebar
- **Compose live preview** — two-column compose layout: form on the left, HTMX-powered preview iframe on the right; `hx-trigger="input delay:500ms"` on the textarea; `POST /admin/emails/compose/preview` endpoint supports plain body or branded wrapper via `wrap` checkbox
- 50 new tests covering all template renders (EN + DE), registry structure, gallery routes (access control, list, preview, lang fallback), and compose preview endpoint
- **JSONL streaming landing format** — extractors now write one JSON object per line (`.jsonl.gz`) instead of a single large blob, eliminating in-memory accumulation and `maximum_object_size` workarounds:
- `playtomic_tenants.py``tenants.jsonl.gz` (one tenant per line; dedup still happens in memory before write)
- `playtomic_availability.py``availability_{date}.jsonl.gz` (one venue per line with `date`/`captured_at_utc` injected; working file IS the final file — eliminates the consolidation step)
- `geonames.py``cities_global.jsonl.gz` (one city per line; eliminates 30 MB blob and its `maximum_object_size` workaround)
- `compress_jsonl_atomic(jsonl_path, dest_path)` utility added to `utils.py` — streams compression in 1 MB chunks, atomic `.tmp` rename, deletes source
- **Regional Overpass splitting for tennis courts** — replaces single global query (150K+ elements, timed out) with 10 regional bbox queries (~10-40K elements each, 150s server / 180s client):
- Regions: europe\_west, europe\_central, europe\_east, north\_america, south\_america, asia\_east, asia\_west, oceania, africa, asia\_north
- Per-region retry (2 attempts, 30s cooldown) + 5s inter-region polite delay
- Crash recovery via `working.jsonl` accumulation — already-written element IDs skipped on restart; completed regions produce 0 new elements on re-query
- Output: `courts.jsonl.gz` (one OSM element per line)
- **`scripts/init_landing_seeds.py`** — creates minimal `.jsonl.gz` and `.json.gz` seed files in `1970/01/` so SQLMesh staging models can run before real extraction data arrives; idempotent
### Changed
- All modified staging SQL models use **UNION ALL transition CTEs** — both JSONL (new) and blob (old) formats are readable simultaneously; old `.json.gz` files in the landing zone continue working until they rotate out naturally:
- `stg_playtomic_venues`, `stg_playtomic_resources`, `stg_playtomic_opening_hours` — JSONL top-level columns (no `UNNEST(tenants)`)
- `stg_playtomic_availability` — JSONL morning files + blob morning files + blob recheck files
- `stg_population_geonames` — JSONL city rows (no `UNNEST(rows)`, no `maximum_object_size`)
- `stg_tennis_courts` — JSONL elements with `COALESCE(lat, center.lat)` for way/relation centre coords; blob UNNEST kept for old files
### Removed
- `_email_wrap()` and `_email_button()` helper functions removed from `worker.py` — replaced by templates
- **Marketplace admin dashboard** (`/admin/marketplace`) — single-screen health view for the two-sided market:
- **Lead funnel** — total / verified-new (ready to unlock) / unlocked / won / conversion rate
- **Credit economy** — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate
- **Supplier engagement** — active paid supplier count, avg lead unlocks per supplier, forward response rate
- **Feature flag toggles** — `lead_unlock` and `supplier_signup` flags togglable inline; sidebar nav entry added
- **Live activity stream** (HTMX partial) — last 50 events across leads, unlocks, and credit ledger in a single feed
- **Lead matching notifications** (`notify_matching_suppliers` worker task) — on quote verification, finds growth/pro suppliers whose `service_area` includes the lead's country and sends an instant alert email; bounded to 20 suppliers per lead
- **Weekly lead digest** (`send_weekly_lead_digest` worker task) — every Monday at 08:00 UTC, sends paid suppliers a summary table of new matching leads from the past 7 days they haven't unlocked yet (max 5 rows per email)
- **One-click CTA token** — lead-forward emails now include a "Mark as contacted" footer link backed by a unique `cta_token`; clicking it sets the forward status to `contacted` and redirects to the supplier dashboard; token stored on `lead_forwards` after send
- **Supplier `lead_respond` endpoint** — HTMX status update for forwarded leads: `sent / viewed / contacted / quoted / won / lost / no_response`
- **Supplier `lead_cta_contacted` endpoint** (`/suppliers/leads/cta/<token>`) — one-click email handler; idempotent (only advances from `sent``contacted`)
- **Migration 0022** — adds `status_updated_at`, `supplier_note`, `cta_token` to `lead_forwards`; unique partial index on `cta_token`
- **Admin leads list improvements** — summary cards (total / new+unverified / hot pipeline credits / forward rate); text search across name, email, company; period filter pills (Today / 7d / 30d / All); `get_leads()` now returns `(rows, total_count)` and supports `search` + `days` params
- **Admin lead detail — HTMX inline actions** — status change returns an updated status badge partial; forward-to-supplier form returns an updated forward history table; no full-page reload
- **Quote form extended** — captures `build_context`, `glass_type`, `lighting_type`, `location_status`, `financing_status`, `services_needed`, `additional_info`; displayed in lead detail view
- **pSEO Engine admin tab** (`/admin/pseo`) — operational visibility for the programmatic SEO system:
- **Content gap detection** — queries DuckDB serving tables vs SQLite articles to find rows with no matching article per language; per-template HTMX-loaded gap list
- **Data freshness signals** — compares `_serving_meta.json` export timestamp vs `MAX(updated_at)` in articles; per-template status: 🟢 Fresh / 🟡 Stale / 🟣 No articles / ⚫ No data
- **Article health checks** (HTMX partial) — hreflang orphans (EN exists, DE missing), missing HTML build files, broken `[scenario:slug]` references in article markdown
- **Generation job monitoring** — live progress bars polling every 2s while jobs run; stops polling on completion; error drilldown via `<details>`; dedicated `/admin/pseo/jobs` list page
- **`_serving_meta.json`** — written by `export_serving.py` after atomic rename; records `exported_at_utc` and per-table row counts; drives freshness signals in pSEO Engine dashboard
- **Progress tracking columns** on `tasks` table (migration 0021): `progress_current`, `progress_total`, `error_log`; `generate_articles()` writes progress every 50 articles and on completion
- 45 new tests covering all health functions + pSEO routes (access control, rendering, gap detection, generate-gaps POST, job status HTMX polling)
- **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`.
- **Crash-safe partial JSONL** — `utils.load_partial_results()` and `flush_partial_batch()`
provide a generic opt-in mechanism for incremental progress flushing during long extractions.
Any extractor processing items one-by-one can flush every N records and resume from a
`.partial.jsonl` sidecar file after a crash.
- **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
completeness) and score band interpretations without exposing weights or
formulas. Full JSON-LD (WebPage + FAQPage + BreadcrumbList), OG tags, and
bilingual content (EN professional, DE Du-form). Added to sitemap and footer.
First "padelnomics Market Score" mention in each article template now links
to the methodology page (hub-and-spoke internal linking).
### Changed
- **`EXTRACT_WORKERS` env var removed** — worker count is now derived from `PROXY_URLS` length
(one worker per proxy). No proxies → single-threaded. No manual tuning needed.
- **Playtomic tenants extractor** — parallel batch page fetching when proxies are configured.
Each page in a batch fires concurrently using its own session + proxy. Expected speedup:
~2.5 min → ~15 s with 10 Webshare datacenter proxies.
- **Playtomic availability extractor** — three performance changes:
1. No per-request `time.sleep()` on success when a proxy is active (throttle only when
running direct). Retry/backoff sleeps for 429 and 5xx responses are unchanged.
2. Worker count auto-detected from proxy count (drops `EXTRACT_WORKERS`).
3. True crash resumption via `.partial.jsonl` sidecar: progress flushed every 50 venues,
resume skips already-fetched venues and merges prior results into the final file.
### Fixed
- **`datetime.utcnow()` deprecation warnings** — replaced all 94 occurrences
across 22 files (source + tests) with `utcnow()` / `utcnow_iso()` helpers
from `core.py`. `utcnow_iso()` produces `YYYY-MM-DD HH:MM:SS` (space
separator) matching SQLite's `datetime('now')` format so lexicographic SQL
comparisons stay correct. `datetime.utcfromtimestamp()` in `seo/_bing.py`
also replaced with `datetime.fromtimestamp(ts, tz=UTC)`. Zero deprecation
warnings remain.
- **Credit ledger ordering** — `get_ledger()` now uses `ORDER BY created_at
DESC, id DESC` to preserve insertion order when multiple credits are added
within the same second.
- **Double language prefix in article URLs** — articles were served at
`/en/en/markets/italy` (double prefix) because `generate_articles()` stored
`url_path` with the lang prefix baked in, but the blueprint is already mounted

View File

@@ -29,4 +29,4 @@ USER appuser
ENV PYTHONUNBUFFERED=1
ENV DATABASE_PATH=/app/data/app.db
EXPOSE 5000
CMD ["hypercorn", "padelnomics.app:app", "--bind", "0.0.0.0:5000", "--workers", "1"]
CMD ["granian", "--interface", "asgi", "--host", "0.0.0.0", "--port", "5000", "--workers", "1", "padelnomics.app:app"]

View File

@@ -1,7 +1,7 @@
# Padelnomics — Project Tracker
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
> Last updated: 2026-02-24.
> Last updated: 2026-02-25.
---
@@ -93,6 +93,9 @@
- [x] `dim_venues` (OSM + Playtomic deduped), `dim_cities` (Eurostat population)
- [x] `city_market_profile` (market score OBT), `planner_defaults` (per-city calculator pre-fill)
- [x] DuckDB analytics reader in app lifecycle
- [x] **JSONL streaming landing format** — extractors write `.jsonl.gz` (one record per line); constant-memory compression via `compress_jsonl_atomic()`; eliminates `maximum_object_size` workarounds; all modified staging models use UNION ALL transition to support both formats
- [x] **Regional Overpass tennis splitting** — 10 regional bbox queries replace the single global 150K-element query that timed out; crash recovery via `working.jsonl` accumulation
- [x] **`init_landing_seeds.py`** — creates minimal seed files for both JSONL and blob formats so SQLMesh can run before real data arrives
### i18n
- [x] Full i18n across entire app (EN + DE)
@@ -107,6 +110,12 @@
- [x] Task queue management (list, retry, delete)
- [x] Lead funnel stats on admin dashboard
- [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks
- [x] **Email template system** — 11 transactional emails as Jinja2 templates (`emails/*.html`); standalone `render_email_template()` renderer works in worker + admin; `_base.html` + `_macros.html` shared shell; `EMAIL_TEMPLATE_REGISTRY` with sample data for gallery previews; `_email_wrap()` / `_email_button()` helpers removed
- [x] **Admin email gallery** (`/admin/emails/gallery`) — card grid of all templates, EN/DE preview in sandboxed iframe, "View in sent log" cross-link; compose page now has HTMX live preview pane
- [x] **pSEO Engine tab** (`/admin/pseo`) — content gap detection, data freshness signals, article health checks (hreflang orphans, missing build files, broken scenario refs), generation job monitoring with live progress bars
- [x] **Marketplace admin dashboard** (`/admin/marketplace`) — lead funnel, credit economy, supplier engagement, live activity stream, inline feature flag toggles
- [x] **Lead matching notifications**`notify_matching_suppliers` task on quote verification + `send_weekly_lead_digest` every Monday; one-click CTA token in forward emails
- [x] **Migration 0022**`status_updated_at`, `supplier_note`, `cta_token` on `lead_forwards`; supplier respond endpoint; inline HTMX lead detail actions; extended quote form fields
### SEO & Legal
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
@@ -118,6 +127,7 @@
- [x] Cookie consent banner (functional/A/B categories, 1-year cookie)
- [x] Virtual office address on imprint
- [x] SEO/GEO admin hub — GSC + Bing + Umami sync, search/funnel/scorecard views, daily background sync
- [x] Market Score methodology page (`/{lang}/market-score`) — Zillow-style explanation of the padelnomics Market Score; EN + DE; JSON-LD (WebPage + FAQPage + BreadcrumbList); hub-and-spoke internal linking from all article templates
### Testing
- [x] Playwright visual/E2E test suite — 77 tests across 3 files (visual, e2e flows, quote wizard); single session-scoped server + browser; mocked emails + waitlist mode; ~59s runtime
@@ -132,10 +142,6 @@
---
## In Progress 🔄
_Move here when you start working on it._
---
## Next Up 📋
@@ -154,6 +160,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 |
@@ -195,6 +208,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
@@ -245,3 +261,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. |

208
docs/ADMIN.md Normal file
View File

@@ -0,0 +1,208 @@
# Admin Panel Guide
The admin panel lives at `/admin/` and is restricted to users whose email is in `ADMIN_EMAILS`.
Dev shortcut: `GET /auth/dev-login?email=<admin-email>` (DEBUG mode only).
Sidebar navigation (left to right in layout):
**Dashboard → Marketplace → Leads → Suppliers → Flags → Feedback → Emails → pSEO → SEO**
CMS sections (Templates, Scenarios, Articles) are linked from the sidebar too.
---
## Dashboard `/admin/`
Quick-glance overview: user count, lead funnel summary, recent tasks, and credit economy totals. Entry point for everything else.
---
## Marketplace `/admin/marketplace`
Single-screen health view for the two-sided market. Useful for daily ops check and spotting stalled leads or low supplier engagement.
**Lead Funnel** (top cards)
| Card | Meaning |
|------|---------|
| Total Leads | All quote-type leads ever submitted |
| Verified New | Verified leads with status `new` — ready to be unlocked by a supplier |
| Unlocked | Distinct leads that have at least one `lead_forward` record |
| Won | Leads with status `closed_won` |
| Conversion Rate | Won ÷ Total |
**Credit Economy**
| Card | Meaning |
|------|---------|
| Issued | Sum of all positive credit ledger entries (purchases + refills) |
| Consumed | Credits spent on lead unlocks (absolute value of `lead_unlock` entries) |
| Outstanding | Current `credit_balance` sum across all paid-tier suppliers |
| 30-day Burn | Credits consumed in the last 30 days |
**Supplier Engagement**
| Card | Meaning |
|------|---------|
| Active | Paid-tier suppliers with `credit_balance > 0` |
| Avg Unlocks | Average lead forwards per active supplier |
| Response Rate | Forwards where `status != 'sent'` ÷ total forwards |
**Feature Flags**`lead_unlock` and `supplier_signup` toggles are inline on this page. Clicking saves immediately and refreshes (no full-page reload). Also accessible at `/admin/flags`.
**Activity Stream** — HTMX partial loaded on page open, showing the last 50 events across three tables: new leads, lead unlocks, credit ledger entries. Three dot colours: blue = lead created, green = unlock, amber = credit event.
---
## Leads `/admin/leads`
**List view** (`/admin/leads`)
Summary cards at top: total leads / new+unverified / hot pipeline credits / forward rate.
Filters (HTMX live — updates table without page reload):
- **Search** — matches `contact_name`, `contact_email`, `contact_company`
- **Status** — new / pending_verification / contacted / forwarded / closed_won / closed_lost
- **Heat** — hot / warm / cool
- **Country** — ISO country code
- **Period** pills — Today / 7d / 30d / All
**Detail view** (`/admin/leads/<id>`)
Full lead record including all extended quote fields (build context, glass type, lighting, location status, financing, services needed, notes).
Inline HTMX actions (no full-page reload):
- **Status change** — dropdown + save button → swaps the status badge in place
- **Forward to supplier** — select supplier + send → appends row to forward history table
Forward history table shows: supplier name, current forward status, credit cost, sent timestamp.
**Create lead** (`/admin/leads/new`) — manual lead entry for phone/offline enquiries.
**Heat scoring** — set automatically on submission: hot = 35 credits (≥6 courts + confirmed budget + short timeline), warm = 20, cool = 8.
---
## Suppliers `/admin/suppliers`
**List view** — search by name/email, filter by tier (free/basic/growth/pro) and country. Table shows tier badge, credit balance, and listing status.
**Detail view** (`/admin/suppliers/<id>`)
- Adjust credit balance (add or subtract, reason logged to `credit_ledger`)
- Change subscription tier
- View all lead forwards for this supplier with forward statuses
- Impersonate supplier (enter their session to debug dashboard issues)
**Create supplier** (`/admin/suppliers/new`) — manual supplier onboarding.
---
## Feature Flags `/admin/flags`
Toggle on/off without redeploy. Current flags:
| Flag | Controls |
|------|---------|
| `markets` | Market score pages visible to public |
| `payments` | Paddle checkout enabled |
| `planner_export` | PDF export tab visible in planner |
| `supplier_signup` | Supplier signup wizard accessible |
| `lead_unlock` | Suppliers can unlock leads (spend credits) |
Flags can also be toggled inline on the Marketplace dashboard for the two most-used flags.
---
## Feedback `/admin/feedback`
All submissions from the on-page feedback widget (thumbs up/down + optional text). Filterable by page and rating. Rate-limited to 1 per IP per page per hour.
---
## Emails
### Sent Log `/admin/emails`
Every outgoing email recorded in `email_sent`. Filter by type (magic_link, lead_forward, weekly_digest, etc.), delivery event (delivered/bounced/opened/clicked), or free-text search on recipient/subject.
Clicking a row opens the **detail view** — shows metadata, Resend delivery event timeline, and a sandboxed HTML preview of the message body fetched live from Resend API.
### Inbox `/admin/emails/inbox`
Inbound emails received via Resend inbound routing. Unread count shown as a badge in the sidebar. Detail view renders the HTML body in a sandboxed iframe with an inline reply form.
### Compose `/admin/emails/compose`
Send one-off transactional or plain emails. Select from-address (leads@, hello@, etc.) and optionally wrap in the branded email shell.
### Audiences `/admin/emails/audiences`
Lists all Resend audiences (waitlist, planner nurture, etc.) with contact counts. Drill into an audience to view contacts and remove individuals.
---
## pSEO Engine `/admin/pseo`
Operational visibility for the programmatic SEO content pipeline. Four sub-tabs:
**Content Gaps** — for each template, shows DuckDB serving rows that have no matching article in the requested language. Use this to prioritise what to generate next.
**Health Checks** — per-article sanity checks:
- hreflang orphans (EN article exists, DE missing)
- missing HTML build files on disk
- broken `[scenario:slug]` references in article markdown
**Freshness** — compares `_serving_meta.json` export timestamp vs `MAX(updated_at)` across articles per template. Status: 🟢 Fresh / 🟡 Stale / 🟣 No articles / ⚫ No data.
**Jobs** — live generation job monitor. Progress bars poll every 2s while jobs run. Error drilldown via `<details>` on failed jobs.
Generation is triggered from the **Templates** section (see below) — pSEO Engine is read-only observability.
---
## SEO Hub `/admin/seo`
Aggregated SEO metrics from Google Search Console, Bing Webmaster Tools, and Umami. Three tabs:
- **Search** — keyword performance (impressions, clicks, CTR, position) synced daily
- **Funnel** — Umami pageview → planner → quote conversion funnel
- **Scorecard** — per-article GSC impressions/clicks overlay on article metadata
**Sync** button triggers an immediate background sync of all configured sources (otherwise syncs daily via scheduler).
Requires `GSC_CLIENT_SECRETS_JSON`, `GSC_PROPERTY_URL`, `BING_API_KEY`, and `UMAMI_*` env vars.
---
## CMS
### Templates `/admin/templates`
Each template = a Jinja2 Markdown template file + a DuckDB data source query. Templates produce articles at scale.
Actions per template:
- **Edit** — modify template body, data query, or metadata
- **Preview** — render a single row through the template without saving
- **Generate** — bulk generate articles for all (or specific) data rows; runs as background task; progress visible in pSEO Engine → Jobs
### Scenarios `/admin/scenarios`
Public scenario cards shown on the landing page (e.g. "6-court indoor club in Munich"). Each has a name, description, financial state blob (pre-fills the planner), and a live PDF preview.
### Articles `/admin/articles`
Generated article records. Filter by template, language, country, published status.
Per-article actions: **Edit** markdown inline, **Publish/Unpublish** (HTMX, no page reload), **Rebuild HTML** (re-runs Markdown → HTML without re-generating content), **Delete**.
**Rebuild All** button at top re-processes every published article's Markdown into HTML — use after template or CSS changes.
---
## Tasks `/admin/tasks`
Worker queue state. Shows pending / running / failed tasks with payload and error log. Actions: **Retry** (re-enqueues), **Delete** (removes from queue).
Failed tasks do not auto-retry — manual retry is intentional so you can inspect the error first.
---
## Users `/admin/users`
List all users (search by email/name). Detail view shows role, subscription state, scenarios, and recent activity. **Impersonate** button logs you in as that user — "Stop impersonating" in the top bar returns you to your admin session.

View File

@@ -169,23 +169,40 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
|------|-----|-------|
| 1 | View teased lead | `GET /<lang>/suppliers/dashboard/leads` — lead shown with blurred contact info |
| 2 | Unlock | `POST /<lang>/suppliers/leads/<id>/unlock` — deducts 1 credit, reveals full lead |
| 3 | Receive email | `send_lead_forward_email` task enqueued — full project brief sent to supplier |
| 3 | Receive email | `send_lead_forward_email` task enqueued — full project brief sent to supplier with one-click CTA link |
| 4 | Entrepreneur notified | `send_lead_matched_notification` task — notifies entrepreneur a supplier was matched |
**Auth required:** Yes — `@_lead_tier_required`
**Credit check:** Server-side check; if 0 credits → redirect to boosts tab
**Matching notification:** On quote verification, `notify_matching_suppliers` task auto-notifies growth/pro suppliers whose `service_area` matches the lead's country (max 20 per lead); `send_weekly_lead_digest` sends a Monday 08:00 UTC summary of new matching leads to all paid suppliers
---
## 12. Admin Flows
## 12. Supplier → Update Lead Response Status
**Entry:** Supplier dashboard leads tab, or one-click CTA link in forward email
| Step | URL | Notes |
|------|-----|-------|
| 1a | Click "Mark as contacted" in email | `GET /suppliers/leads/cta/<cta_token>` — one-click; advances status `sent``contacted`; redirects to `/suppliers/dashboard?tab=leads` |
| 1b | Update via dashboard | `POST /<lang>/suppliers/leads/<token>/respond` — HTMX; sets `status` and optional `supplier_note`; returns 204 |
**Auth required:** CTA link is unauthenticated (token is the credential); dashboard endpoint requires `@_lead_tier_required`
**Valid statuses:** `sent / viewed / contacted / quoted / won / lost / no_response`
**Idempotency:** CTA only advances `sent → contacted`; subsequent clicks are no-ops
---
## 13. Admin Flows
**Entry:** `/admin/` (requires `@role_required("admin")`)
| Area | URL | What you can do |
|------|-----|-----------------|
| Dashboard | `GET /admin/` | Stats overview |
| Marketplace | `GET /admin/marketplace` | Lead funnel, credit economy, supplier engagement, live activity stream, inline feature flag toggles |
| Users | `GET /admin/users`, `/admin/users/<id>` | List, view, impersonate |
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List (search + period filter + summary cards), view detail, HTMX inline status change + forward to supplier |
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
| Feedback | `GET /admin/feedback` | View all submitted feedback |
| Email Sent Log | `GET /admin/emails`, `/admin/emails/<id>` | List all outgoing emails (filter by type/event/search), detail with API-enriched HTML preview |
@@ -197,6 +214,7 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
| Task Queue | `GET /admin/tasks` | View worker tasks, retry/delete failed |
**HTMX partials:** `lead_status_badge.html` (status change), `lead_forward_history.html` (forward history), `marketplace_activity.html` (activity stream)
**Dev shortcut:** `/auth/dev-login?email=<admin-email>` where email is in `config.ADMIN_EMAILS`
---

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

@@ -0,0 +1,735 @@
# Proxy Provider Inventory
Compiled: 2026-02-24
Purpose: Identify proxy providers for distributed web extraction — rotating IPs to avoid per-IP rate limits when scraping Playtomic and similar APIs at scale (14k+ venues/day).
Use case requirements:
- ~14,000 HTTP requests/day to `api.playtomic.io` (availability + tenant endpoints)
- Need IP rotation to stay under per-IP soft bans (observed ~1,000 req/IP/day limit)
- Serial or low-concurrency (18 workers), not high-throughput
- Residential IPs strongly preferred — Playtomic's API likely blocks datacenter ranges
- HTTP/HTTPS proxies; SOCKS5 a bonus
- Pay-per-GB preferred over per-IP (light usage: ~15 GB/month estimated)
- Budget: €20100/month target
**Bandwidth estimate:** A Playtomic availability API response for one venue is roughly 530 KB of JSON. At 14,000 requests/day with ~15 KB average (request + response + headers, pre-compression), that is ~200 MB/day or roughly **6 GB/month**. At the low end (small venues, cached pages) it could be 12 GB/month; at the high end (large venue JSON, retries) up to 10 GB/month. Budget for 510 GB/month when choosing a plan.
---
## Provider Comparison Table
| Provider | Pool Size | Res. Price (GB) | ISP/Static Price | Min Spend | Sticky | City-level | SOCKS5 | Score |
|----------|-----------|-----------------|------------------|-----------|--------|------------|--------|-------|
| Nimbleway | Millions (proprietary) | $8.00$14.00 PAYG | — | $300/mo | 30 min | Yes | Yes | 2 |
| Froxy | 10M+ | $12.00$1.95/GB (tier) | — | $1.99 trial; $60/mo entry | 1060 min | Yes | Yes (SOCKS4/5) | 3 |
| LunaProxy | 200M+ | $0.65$0.77 PAYG | $3/IP/week static | $7 (10 GB bundle) | 90 min | Yes | Yes | ⛔ REMOVED |
| Proxy-seller | 20M+ res; 400+ ISP | $0.70$7.00 | $1$2/IP/mo ISP | ~$1 (1 GB) | 24 hr res; static ISP | Yes | Yes | 3 |
| ProxyScrape | 48M+ | $4.50$4.85/GB | — | $2 (1 GB test) | 120 min | Yes | Yes | 3 |
| Bright Data | 150M+ | $2.80$10.50 PAYG | $2.80/IP/mo | $500/mo sub; PAYG no min | 30 min | Yes | Yes | 3 |
| Oxylabs | 175M+ | $3.75$8.00 PAYG | $1.15$1.60/IP/mo | $99/mo | 30 min | Yes | Yes | 3 |
| Decodo (Smartproxy) | 115M+ | $7$12.50/GB | — | $80/mo sub | 24 hr | Yes | Yes | 3 |
| IPRoyal | 34M+ | $1.75$7.00 | — | No min (PAYG) | 7 days | Yes | Yes | 5 |
| Rayobyte | 40M+ | $3.20$9.00 | Unlimited BW plans | No stated min | 60 min | Yes | No | 3 |
| NetNut | 85M+ | $3.54$15.00 | $17.50+/GB static | $99/mo | Indefinite | Yes | No | 3 |
| SOAX | 191M+ | $3.60$4.00 | — | $90/mo sub; $1.99 trial | 60 min | Yes | Yes | 3 |
| Webshare | — | $2.00$3.00 | $0.30/IP/mo | ~$7/mo | Limited | Yes | Yes | 4 |
| Geonode | 10M+ | $0.50$3.00 | — | No min (PAYG) | 24 hr | Yes | Yes | 4 |
| PacketStream | 4572M+ | $1.00 flat | — | $50 deposit | Supported | Yes | Yes | 4 |
| Proxy-Cheap | 6M+ | $2.99 | — | No min | Supported | Yes | Yes | 3 |
| ProxyEmpire | — | $1.50$3.50 | — | $1.97 trial | Unlimited rollover | Yes | Yes | 4 |
| Infatica | 20M+ | $4.00 PAYG | — | $4 trial (1 GB) | Supported | Yes | No | 3 |
| Shifter | 31M+ | Port-based $299+/mo | — | $299/mo | — | Yes | No | 1 |
| Storm Proxies | 20M+ | Port-based $19+/mo | — | $19/mo | 5 min | USA/EU only | No | 2 |
| DataImpulse | 90M+ | $1.00 (never expires) | — | No min | 30 min | Yes | Yes | 5 |
| Evomi | 510M+ | $0.99 PAYG / $0.49 sub ($49.99/mo) | — | Money-back guarantee (<1 GB) | Supported | Yes | Yes | 4 |
| 922Proxy | 200M+ claimed | $0.77 | $0.16/IP/day | No min | Supported | Yes | Yes | 3 |
**Score (15):** fit for Playtomic extraction — rotating residential, pay-per-GB, no large minimum, city/country targeting, low total cost at 510 GB/month.
---
## 1. Residential Rotating Proxies
### 1.1 Bright Data
| Field | Value |
|-------|-------|
| URL | https://brightdata.com |
| Types | Rotating residential, ISP, datacenter, mobile |
| Pool Size | 150M+ residential IPs |
| Countries | 195+ countries; country + city + ZIP + ASN + carrier targeting |
| Pricing (residential PAYG) | $10.50/GB; promo ~$5.25/GB when available |
| Pricing (Starter subscription) | ~$2.80$3.50/GB for 141 GB ($499/month) |
| Sticky Sessions | Up to 30 minutes |
| Auth Method | Username:password; API key; browser extension |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | Starter plan: $500/month; PAYG: no minimum |
| Trial | 7-day free trial for registered companies; $5 credit on account creation |
| ToS scraping | Commercial scraping explicitly supported; most permissive ToS in market |
Bright Data is the industry benchmark — 150M+ IPs, 195+ countries, the deepest targeting options (ZIP-level, ASN, carrier), and a platform that includes scraping browsers, CAPTCHA solvers, and managed datasets beyond raw proxies. Their IP quality is the highest in the market; IPs are very unlikely to be pre-flagged by Playtomic.
The economics are the problem for small-volume use. The Starter plan requires $500/month — 50100x the actual data cost at 510 GB/month. The PAYG rate of $10.50/GB (or $5.25/GB at promo) is usable but the promo is time-limited. The $5 trial credit on account creation allows a no-risk integration test.
Choose Bright Data if: (a) extraction scales to 100+ GB/month and justifies the Starter plan, (b) Playtomic begins aggressively blocking mid-tier residential IPs and only Bright Data's IP quality works, or (c) you need the managed scraping infrastructure (Web Unlocker API) rather than raw proxies.
---
### 1.2 Oxylabs
| Field | Value |
|-------|-------|
| URL | https://oxylabs.io |
| Types | Rotating residential, ISP (static residential), datacenter, mobile |
| Pool Size | 175M+ residential IPs |
| Countries | 195+ countries; country + state + city targeting |
| Pricing (residential PAYG) | $8.00/GB |
| Pricing (Micro plan) | $99/month (13 GB = $7.62/GB) |
| Pricing (Starter) | $150/month (40 GB = $3.75/GB) |
| Pricing (ISP proxies) | $1.15$1.60/IP/month with unlimited bandwidth |
| Sticky Sessions | Up to 30 minutes (residential); indefinite (ISP) |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | $99/month (Micro) or PAYG at $8.00/GB |
| Trial | Free trial for qualified businesses |
| ToS scraping | Full commercial scraping support; dedicated scraping APIs available |
Oxylabs has the largest claimed pool at 175M+ IPs and is consistently strong on reliability. The ISP proxy tier is a notable option: 10 static ISP IPs at $1.60/IP = $16/month with unlimited bandwidth. If Playtomic's rate limits reset daily and you distribute 14,000 requests across 10 IPs (1,400 req/IP/day), this may exceed the soft limit. Expanding to 20 IPs at $32/month brings each IP to 700 req/day — safely under the ~1,000/IP threshold.
The $99/month minimum for the residential rotating plan makes it expensive for small volumes. Like Bright Data, Oxylabs is better justified at scale. The ISP tier is the most interesting option at this budget level — worth a direct test.
---
### 1.3 Decodo (formerly Smartproxy)
| Field | Value |
|-------|-------|
| URL | https://decodo.com |
| Types | Rotating residential, ISP, datacenter, mobile |
| Pool Size | 115M+ ethically sourced residential IPs |
| Countries | 195+ locations; country + state + city + ZIP + ASN |
| Pricing (PAYG) | $12.50/GB (1 GB) |
| Pricing (Micro) | $80/month (8 GB = $10.00/GB) |
| Pricing (Starter) | $225/month (25 GB = $9.00/GB) |
| Pricing (Enterprise) | As low as $1.50/GB at 1,000 GB/month |
| Sticky Sessions | Up to 24 hours (11,440 minutes configurable) |
| Auth Method | Username:password; whitelist IP |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | $80/month (Micro) or PAYG at $12.50/GB |
| Trial | 3-day free trial |
| ToS scraping | Explicitly supported; scraping use case documented |
| Performance | 99.86% success rate, 0.63s avg response time (Proxyway 2025 — top tier) |
Decodo (rebranded from Smartproxy in 2025) is the top performer in Proxyway's 2025 benchmark: 99.86% success rate and 0.63-second response time, winning Proxyway's "Best Value Provider" award for the fourth consecutive year. The 24-hour sticky sessions are the longest among major providers and the 115M+ pool with ZIP+ASN targeting is excellent.
The difficulty is pricing at small volumes. $12.50/GB PAYG for 6 GB = $75/month. The Micro plan at $80/month forces purchasing 8 GB regardless — at 6 GB actual usage, 25% is wasted each month. Decodo is the right choice if performance is the primary requirement and/or volume scales to 25+ GB/month where the Starter plan's $9.00/GB becomes competitive.
---
### 1.4 IPRoyal
| Field | Value |
|-------|-------|
| URL | https://iproyal.com |
| Types | Rotating residential, static residential (Pawns), datacenter, mobile/4G, ISP |
| Pool Size | 34M+ residential IPs |
| Countries | 195+ countries; country + state/region + city targeting |
| Pricing (rotating residential PAYG) | $7.00/GB (1 GB) → $4.90/GB (50 GB) |
| Pricing (sticky residential) | From $1.75/GB; up to 7-day sessions |
| Sticky Sessions | Up to 7 days per IP |
| Auth Method | Username:password; API key |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | No minimum; PAYG only |
| Trial | No free trial; small PAYG purchases from $1.75 |
| ToS scraping | Web scraping and data collection explicitly permitted |
| Performance | 99.56% success rate, 1.06s avg response time (Proxyway 2025) |
IPRoyal stands out for the combination of no-minimum PAYG billing, non-expiring traffic, and industry-unique 7-day sticky sessions. For the Playtomic extraction pipeline, the sticky residential tier at $1.75/GB is exceptional value — lower than almost any other provider's rotating residential rate, with sessions that hold an IP for up to a full week.
The 34M IP pool is smaller than Bright Data or Oxylabs but very adequate for 14,000 requests/day. The 99.56% success rate and 1.06s response time are strong for a mid-tier provider. SOCKS5 support and city/country targeting complete a well-rounded offering.
At $1.75/GB sticky residential for 6 GB/month, total cost is approximately $10.50/month — well within budget, with no monthly commitment. Caveat: verify with IPRoyal support whether the $1.75/GB sticky tier draws from the same 34M rotating pool or a separate static IP inventory, as IP quality may differ.
---
### 1.5 Rayobyte
| Field | Value |
|-------|-------|
| URL | https://rayobyte.com |
| Types | Rotating residential, ISP (static), datacenter (dedicated + semi-dedicated), mobile |
| Pool Size | 40M+ residential IPs across ~180 countries |
| Countries | ~180 countries; country + state + city + ASN targeting |
| Pricing (residential rotating) | ~$9.00/GB for small plans; ~$3.20/GB at 1,000 GB/month |
| Pricing (datacenter dedicated) | From $2.50/IP/month (US); from $1.00/IP/month semi-dedicated |
| Sticky Sessions | Soft sticky (IP held while peer is online) or hard sticky (160 min fixed) |
| Auth Method | Username:password; dashboard configuration |
| Protocols | HTTP, HTTPS |
| Min Purchase | No stated minimum; PAYG available |
| Trial | Trial proxies available via portal |
| ToS scraping | Commercial scraping supported |
Rayobyte (formerly Blazing SEO) built its reputation on datacenter proxies; their residential offering is solid but not price-competitive for small volumes. At ~$9.00/GB it is more expensive than IPRoyal ($7.00/GB), DataImpulse ($1.00/GB), or Evomi ($0.99/GB PAYG) for equivalent low-volume usage.
The soft/hard sticky session distinction is technically useful — hard sticky guarantees the same IP for up to 60 minutes regardless of peer status; soft sticky holds only while the peer device is online. For venue-by-venue crawling, hard sticky at 510 minutes is sufficient. The lack of SOCKS5 for residential is a minor inconvenience. Rayobyte becomes more compelling at 200+ GB/month where the pricing curve ($3.20/GB) is competitive. Not recommended as primary for 510 GB/month.
---
### 1.6 NetNut
| Field | Value |
|-------|-------|
| URL | https://netnut.io |
| Types | Rotating residential, static residential (ISP), datacenter, mobile |
| Pool Size | 85M+ rotating residential; 1M+ static residential |
| Countries | 195+ countries; city targeting |
| Pricing (rotating residential) | Starter: $99/month (28 GB = $3.54/GB); enterprise: $1.59/GB |
| Pricing (static residential) | $17.50+/GB (primarily for long-term sessions) |
| Sticky Sessions | Indefinite (static residential); ~30 minutes (rotating) |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS (no SOCKS5) |
| Min Purchase | $99/month (28 GB) |
| Trial | Free trial for enterprise accounts |
| ToS scraping | Commercial use permitted |
NetNut's differentiated product is direct ISP connectivity for static residential proxies — lower latency and more predictable performance than peer-sourced networks. The rotating residential pool of 85M+ is competitive. However, the $99/month minimum (28 GB) is oversized for 510 GB/month consumption, and the static residential tier at $17.50/GB is very expensive for bandwidth-intensive work.
NetNut updated pricing in March 2025 to align more closely with Oxylabs and Bright Data, making it less of a budget option than it historically was. The lack of SOCKS5 is a limitation. Best suited to operations running 28+ GB/month where the Starter plan breaks even.
---
### 1.7 SOAX
| Field | Value |
|-------|-------|
| URL | https://soax.com |
| Types | Rotating residential, mobile, ISP, datacenter |
| Pool Size | 191M+ IPs (residential + mobile combined) |
| Countries | 195+ locations; country + city targeting |
| Pricing (residential Starter sub) | $90/month (25 GB = $3.60/GB) |
| Pricing (residential PAYG) | $4.00/GB |
| Pricing (3-day trial) | $1.99 |
| Pricing (Enterprise) | As low as $0.32/GB at scale |
| Sticky Sessions | Up to 60 minutes |
| Auth Method | Username:password; dashboard-based session management |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | $90/month (Starter) or PAYG at $4.00/GB |
| Trial | $1.99 for 3-day trial |
| ToS scraping | Fully supported; web scraping is a primary advertised use case |
| Performance | 99.95% success rate, 0.55s avg response time (Proxyway 2025 — near best in class) |
SOAX achieves near-best-in-market performance metrics: 99.95% success rate and 0.55-second response time in Proxyway's 2025 benchmarks. The 191M+ combined pool, 60-minute sticky sessions, and SOCKS5 support make it technically strong. The $1.99 three-day trial is the cheapest paid entry point for validation.
At PAYG: $4.00/GB × 6 GB/month = $24/month — within the lower budget range. The Starter subscription at $90/month is only justified at 20+ GB/month usage. SOAX is a good option if performance reliability is paramount and you do not want to accept the uncertainty of smaller providers like Evomi or DataImpulse.
---
### 1.8 Webshare
| Field | Value |
|-------|-------|
| URL | https://www.webshare.io |
| Types | Rotating residential, static residential (ISP), datacenter, shared proxies |
| Pool Size | Not publicly stated for residential |
| Countries | 195+ countries; city targeting supported |
| Pricing (residential rotating) | 10 GB/mo: $3.00/GB; 60 GB: $2.33/GB; 100 GB: $2.00/GB |
| Pricing (static residential / ISP) | $0.30/IP/month (unlimited bandwidth) |
| Pricing (free tier) | 10 datacenter proxies + 1 GB/month — permanent |
| Sticky Sessions | Limited; primarily a rotating service |
| Auth Method | Username:password or whitelist IP |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | Very low; plans start ~$7/month for residential |
| Trial | Free tier permanent (10 proxies + 1 GB/month datacenter) |
| ToS scraping | Permitted for commercial use |
Webshare offers two compelling products for this use case. First, the permanent free tier enables no-cost integration testing. Second, the static residential (ISP) proxies at $0.30/IP/month represent the cheapest stable-IP option on the market: 30 ISP IPs at $9/month provides unlimited bandwidth across a persistent pool of 30 residential-quality IPs.
If Playtomic's soft ban is per-IP-per-day (resetting at midnight), a pool of 30 ISP IPs at 14,000 requests/day means ~467 requests per IP per day — safely below the ~1,000/IP observed limit. This setup at $9/month is potentially the cheapest viable production configuration, contingent on ISP IPs not being pre-blocked.
The rotating residential plan at $3.00/GB (10 GB tier) is mid-market and straightforward. Webshare has a developer-friendly API and clean dashboard.
---
### 1.9 Geonode
| Field | Value |
|-------|-------|
| URL | https://geonode.com |
| Types | Rotating residential, static residential (ISP), datacenter |
| Pool Size | 10M+ residential IPs |
| Countries | 190+ countries; country + city targeting |
| Pricing (PAYG) | $3.00/GB |
| Pricing (Starter plan) | From $1.00/GB (subscription) |
| Pricing (Business plan) | From $0.50/GB |
| Sticky Sessions | Configurable, up to 24 hours |
| Auth Method | Username:password; whitelist IP |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | No minimum for PAYG |
| Trial | No formal trial; $2,250 proxy credit on first purchase (promotional) |
| ToS scraping | Explicitly permitted for commercial use |
| Performance | Trustpilot 4.7/5; latency anomalies reported on Reddit |
Geonode advertises $0.50/GB on the Business plan and up to 24-hour sticky sessions — both market-leading for a mid-tier provider. The PAYG rate of $3.00/GB with no minimum results in approximately $18/month for 6 GB.
The 10M IP pool is the smallest among recommended providers, meaning IP reuse frequency is higher. For 14,000 requests/day this is still fine — at 10M IPs cycling uniformly, each IP would appear roughly once every 700 days at that volume. In practice pools are not uniform, so some IPs will be reused sooner.
The reported Reddit latency anomalies (up to 800 seconds for some requests) are concerning for a latency-sensitive extraction loop. Test against a sample before committing; the $3.00/GB PAYG is not cheap enough to absorb high retry overhead.
---
### 1.10 PacketStream
| Field | Value |
|-------|-------|
| URL | https://packetstream.io |
| Types | Rotating residential, static residential |
| Pool Size | 4572M+ residential IPs (P2P network) |
| Countries | 102195 countries; country + city targeting |
| Pricing | $1.00/GB flat |
| Sticky Sessions | Supported |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | $50 deposit (credit persists indefinitely) |
| Trial | No formal trial |
| ToS scraping | General commercial use permitted |
PacketStream's $1.00/GB flat rate with no tiers or subscriptions is extremely simple and competitive. The $50 minimum deposit is the main friction point but credit persists indefinitely. SOCKS5 and city targeting are included at no extra charge.
The P2P model (volunteer peers share their bandwidth) means IP quality and availability vary by geography. For Europe (Spain, Germany, France — primary Playtomic markets), the peer density should be adequate. The latency variance inherent in P2P networks adds noise to extraction timing. For a background extraction job running overnight, this is not a significant issue. At $1.00/GB for 610 GB/month, cost is $610/month — comparable to DataImpulse.
---
### 1.11 Proxy-Cheap
| Field | Value |
|-------|-------|
| URL | https://www.proxy-cheap.com |
| Types | Rotating residential, ISP, datacenter, mobile |
| Pool Size | 6M+ residential IPs |
| Countries | 180+ countries; city targeting |
| Pricing (residential) | $2.99/GB |
| Sticky Sessions | Supported |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | No stated minimum |
| Trial | No formal trial |
| ToS scraping | Permitted |
At $2.99/GB with no minimum and SOCKS5 support: 6 GB/month = $18/month. The 6M IP pool is small. More expensive than DataImpulse ($1.00/GB), PacketStream ($1.00/GB), or Evomi ($0.99/GB PAYG) without a clear advantage. The service is better reviewed for ISP proxies than rotating residential. Viable if preferred on other criteria but not the first choice.
---
### 1.12 ProxyEmpire
| Field | Value |
|-------|-------|
| URL | https://proxyempire.io |
| Types | Rotating residential, static residential (ISP), mobile/4G |
| Pool Size | Not publicly stated |
| Countries | 195+ countries; country + city + ASN targeting |
| Pricing (rotating residential PAYG) | $3.50/GB (1 GB) → $1.50/GB (1,000 GB/month subscription) |
| Sticky Sessions | Supported; unused GB never expires (unlimited rollover) |
| Auth Method | Username:password; HTTP and SOCKS5 |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | $1.97 trial package |
| Trial | $1.97 introductory package |
| ToS scraping | Permitted; ethical use policy |
| Performance | 99%+ success rate (self-reported; limited independent benchmarks) |
ProxyEmpire's $1.97 trial is the cheapest entry point among all providers — adequate for a live test against the Playtomic API without financial commitment. The unlimited rollover policy (unused GB never expires across months) is genuinely useful for bursty extraction patterns. At $3.50/GB PAYG for 6 GB/month: $21/month — comparable to Geonode at $3.00/GB. ASN-level targeting is a differentiator for precise geo-targeting.
---
### 1.13 Infatica
| Field | Value |
|-------|-------|
| URL | https://infatica.io |
| Types | Rotating residential, mobile, datacenter |
| Pool Size | 20M+ residential IPs |
| Countries | 195+ countries; city targeting |
| Pricing (PAYG) | $4.00/GB |
| Pricing (Light plan) | $96/month (25 GB = $3.84/GB) |
| Pricing (trial) | $4 for 1 GB / 7 days |
| Sticky Sessions | Supported |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS (no SOCKS5) |
| Min Purchase | $4 (trial); $96/month for subscription |
| Trial | $4 trial (1 GB, 7 days) |
| ToS scraping | Permitted |
| Performance | 94.30% success rate (Proxyway 2025) |
Infatica's 94.30% success rate in Proxyway testing is lower than IPRoyal (99.56%), DataImpulse (99.66%), SOAX (99.95%), and Decodo (99.86%). For a scraper where 6% of requests fail, that is approximately 840 failed venue lookups per day — requiring a robust retry system. At $4.00/GB PAYG it is also not cheap. The $4 trial allows quick validation. Not recommended as primary for this use case.
---
## 2. Budget Residential Providers (Strong Value)
### 2.1 DataImpulse
| Field | Value |
|-------|-------|
| URL | https://dataimpulse.com |
| Types | Rotating residential, mobile |
| Pool Size | 90M+ residential IPs |
| Countries | 195+ countries; country + city targeting |
| Pricing | $1.00/GB (never expires; no monthly minimum) |
| Sticky Sessions | Up to 30 minutes |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | No minimum |
| Trial | No formal trial; small purchases from $1 |
| ToS scraping | Permitted |
| Performance | 99.66% success rate (Proxyway 2025) |
DataImpulse at $1.00/GB with non-expiring credit and a 90M+ pool is the standout value pick for this use case. The 99.66% benchmarked success rate (Proxyway 2025) is excellent for a budget provider — better than Infatica, close to IPRoyal. No monthly subscription, no credit expiry. At 610 GB/month: $610/month total.
Country + city targeting and 30-minute sticky sessions cover all requirements. SOCKS5 support is a bonus. The main unknown is IP quality against Playtomic specifically (residential IPs from smaller providers are sometimes flagged in geographic clusters). Test first with a $510 credit load.
---
### 2.2 Evomi
| Field | Value |
|-------|-------|
| URL | https://evomi.com |
| Types | Rotating residential, mobile, datacenter |
| Pool Size | 510M+ residential IPs |
| Countries | 150+ countries; city targeting |
| Pricing (PAYG) | $0.99/GB — no expiry, no commitment |
| Pricing (Core subscription) | $0.49/GB — $49.99/month for 100 GB |
| Sticky Sessions | Supported |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | No stated minimum for PAYG |
| Trial | Money-back guarantee: full refund if <1 GB or <10% of plan used; credit card required |
| ToS scraping | Permitted |
Evomi's advertised $0.49/GB requires a $49.99/month Core subscription (100 GB). At 610 GB/month that is terrible value — you pay for 90+ GB you will not use. The effective price for this use case is the PAYG rate of **$0.99/GB**, which is essentially identical to DataImpulse ($1.00/GB). The "free trial" is a money-back guarantee rather than a true no-cost trial: you pay, test, and request a refund if you have used less than 1 GB or 10% of the plan. A credit card is required.
At $0.99/GB PAYG for 610 GB/month: $610/month — the same cost as DataImpulse. The decision between them is therefore purely on reliability data, where DataImpulse has a published Proxyway independent benchmark (99.66% success rate) and Evomi does not. The 510M pool is also notably smaller. The money-back guarantee still enables low-risk validation before committing further spend.
---
### 2.3 922Proxy
| Field | Value |
|-------|-------|
| URL | https://www.922proxy.com |
| Types | Rotating residential, static residential, mobile |
| Pool Size | 200M+ claimed |
| Countries | 190+ countries; country + city + ZIP targeting |
| Pricing (rotating residential) | $0.77/GB |
| Pricing (static) | $0.16/IP/day |
| Sticky Sessions | Supported |
| Auth Method | Username:password; client application |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | No stated minimum |
| Trial | No formal trial |
| ToS scraping | Permitted; operated from Asia-Pacific |
922Proxy advertises 200M+ IPs at $0.77/GB — cheaper than DataImpulse ($1.00/GB) and Evomi PAYG ($0.99/GB). The large claimed pool is promising. However, 922Proxy is an Asia-Pacific operation with less visibility in Western proxy benchmarks. IP geographic distribution may skew toward Asia-Pacific rather than Europe, which matters for Playtomic's Spain/Germany focus. Worth testing if DataImpulse is unavailable or saturated, but verify European IP availability first.
---
## 3. ISP / Static Residential Strategy
If Playtomic's per-IP rate limit resets daily (most common configuration), a static pool of ISP proxies round-robined across workers is potentially the cheapest and most reliable solution — avoiding the latency variance of P2P residential networks.
| Provider | ISP Pricing | Bandwidth | 30-IP Pool Cost | Notes |
|----------|-------------|-----------|-----------------|-------|
| Webshare | $0.30/IP/month | Unlimited | $9/month | Cheapest option; static residential-quality IPs |
| Oxylabs | $1.15$1.60/IP/month | Unlimited | $3548/month | Enterprise-grade; lower blocking risk |
| Bright Data | From $2.80/IP/month | Unlimited | $84/month | Best IP quality; high cost |
At 14,000 requests/day distributed across 30 IPs: ~467 req/IP/day — below the ~1,000/IP observed soft limit. Replace blocked IPs as they accumulate. Webshare allows adding/replacing IPs ad hoc.
**If ISP IP ranges are pre-blocked by Playtomic** (common for some ISP providers), fall back to rotating residential. The ISP test is worth running first before committing to a rotating plan.
---
## 4. Port-Based / Unlimited Bandwidth Providers (Not Recommended)
### 4.1 Shifter (formerly Microleaves)
| Field | Value |
|-------|-------|
| URL | https://shifter.io |
| Pool Size | 31M+ residential IPs |
| Pricing | $299/month for 25 ports (unlimited bandwidth) |
| Min Purchase | $299/month |
Designed for 1 TB+/month operations. The $299/month minimum is 3060x more than the actual cost of 6 GB/month at DataImpulse. Not appropriate for this use case.
---
### 4.2 Storm Proxies
| Field | Value |
|-------|-------|
| URL | https://stormproxies.com |
| Pool Size | 20M+ residential IPs |
| Countries | USA and EU only (no country-level selection) |
| Pricing | 1 port: $19/month; 5 ports: $50/month |
| Sticky Sessions | 5-minute rotation interval (fixed) |
| Protocols | HTTP, HTTPS |
Storm Proxies at $19/month for unlimited bandwidth is tempting on paper. However, the geographic restriction to USA/EU with no country-level selection means IPs may not be Spanish/European residential as needed. The fixed 5-minute rotation interval cannot be adjusted. Not flexible enough for this use case.
---
## 5. Mobile / 4G Proxies (Reserve Option)
| Provider | Mobile Pricing | Performance |
|----------|---------------|-------------|
| SOAX | $7$15/GB | 0.57s, 99.94% success (Proxyway 2025 — best mobile) |
| Decodo | $7$12/GB | Excellent |
| Oxylabs | $8$15/GB | Enterprise-grade |
| Bright Data | $8$15/GB | Largest mobile pool |
Mobile proxies use 4G/LTE carrier IPs — the hardest type to block without affecting real users. Costs are 510x residential. Not recommended until Playtomic demonstrates active blocking of residential IPs from established providers.
---
## 6. Recommended Setup for Playtomic Extraction
### Phase 1 — Validation
Test three products simultaneously against a sample of 500 venues each:
1. **Evomi** — use the money-back guarantee (credit card required; refund if <1 GB used); test $0.99/GB PAYG against ~500 venues
2. **DataImpulse** — add $10 credit; test $1.00/GB tier with 90M+ pool against the same venues
3. **Webshare static residential** — sign up for 10 ISP IPs ($3/month); test whether static IPs work
Compare: success rate, 429 rate, response time, connection errors across all three.
**Decision tree after Phase 1:**
- Evomi >95% success → Evomi as primary ($610/month at $0.99/GB PAYG)
- Evomi <95%, DataImpulse >98% → DataImpulse primary ($610/month)
- Webshare static passes (>98% success) → Webshare static at $9/month flat (simplest setup)
- All fail → escalate to IPRoyal sticky ($1.75/GB) or SOAX PAYG ($4.00/GB)
### Phase 2 — Production Configuration
**Primary recommended:** DataImpulse at $1.00/GB
- Expected monthly cost: $610/month for 610 GB
- Non-expiring credit (no waste on unused GB)
- 99.66% benchmarked success rate
- 30-minute sticky sessions for multi-step venue crawls
- 90M+ IP pool with country + city targeting
**Fallback:** IPRoyal sticky residential at $1.75/GB
- Pre-load $20 of credit
- Activate when DataImpulse returns sustained errors or 429s
- 7-day sticky sessions for maximum session stability
**Integration pattern (Python):**
```python
import hashlib
def get_proxy_url(venue_id: str, provider: str = "dataimpulse") -> str:
"""Return a sticky proxy URL with a deterministic session ID per venue."""
# Hash venue_id to a session bucket (0999)
session_id = int(hashlib.md5(venue_id.encode()).hexdigest(), 16) % 1000
if provider == "dataimpulse":
host = "gate.dataimpulse.com"
port = 14433
user = f"username-country-ES-session-{session_id}"
password = "your_password"
elif provider == "iproyal":
host = "geo.iproyal.com"
port = 12321
user = f"username_country-ES_session-{session_id}"
password = "your_password"
return f"http://{user}:{password}@{host}:{port}"
```
This distributes 14,000 venues across 1,000 session buckets, so each session handles ~14 venues/day. With 30-minute sticky sessions (DataImpulse) or 7-day sticky (IPRoyal), each bucket retains its IP for all requests to its assigned venues. Per-IP daily load: ~14 requests × (number of API calls per venue). Very safe.
**Set `PROXY_URLS` in `.env.prod.sops`:**
```
PROXY_URLS=http://user:pass@gate.dataimpulse.com:14433,http://user:pass@gate.dataimpulse.com:14433
EXTRACT_WORKERS=4
```
Use the same endpoint multiple times to create N workers — the sticky session logic differentiates them via session IDs in the username parameter. Or use multiple distinct proxy URLs if the provider assigns different IPs per credential pair.
### Budget Summary
| Scenario | Provider | Est. Monthly Cost | Notes |
|----------|----------|-------------------|-------|
| Comparable to DataImpulse | Evomi | $610/month | $0.99/GB PAYG ($0.49/GB needs $49.99/mo 100 GB plan — not viable at this volume); money-back guarantee |
| **Best value** | **DataImpulse** | **$610/month** | **$1.00/GB; strong benchmarks; no expiry** |
| Static ISP pool | Webshare | $9/month flat | 30 IPs × $0.30; unlimited BW; test ISP blocking first |
| PAYG reliable | IPRoyal sticky | $1018/month | $1.75/GB; 7-day sessions; well-established |
| PAYG mid-tier | PacketStream | $610/month | $1.00/GB; $50 deposit; P2P quality variance |
| PAYG mid-tier | Geonode PAYG | $1830/month | $3.00/GB; 24-hr sticky; test latency first |
| High-reliability | SOAX PAYG | $2440/month | $4.00/GB; 99.95% success; justified if others fail |
| Enterprise-grade | Decodo Micro | $80/month | 99.86% success; overkill at current scale |
| Enterprise-grade | Bright Data PAYG | $31+/month | ~$5.25/GB at promo; reverts to $10.50/GB |
For the stated budget of €20100/month, DataImpulse at $610/month leaves substantial headroom. The money saved versus Decodo or Bright Data can fund a secondary provider for redundancy, or be reserved to escalate to mobile proxies if Playtomic deploys more aggressive anti-bot measures.
---
## 7. Additional Providers
### 7.1 Nimbleway
| Field | Value |
|-------|-------|
| URL | https://www.nimbleway.com |
| Types | Rotating residential, ISP/unlocker, datacenter, mobile |
| Pool Size | Millions of IPs (proprietary; smaller than Oxylabs/NetNut) |
| Countries | Nearly every country; country, state, city targeting |
| Pricing (PAYG) | $8.00/GB |
| Pricing ($300/mo plan) | $14.00/GB (21 GB included) |
| Pricing ($4,000/mo plan) | $7.00/GB |
| Sticky Sessions | 130 minutes (10 min inactivity timeout) |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS5 (recently added) |
| Min Purchase | $300/month; free trial requires contacting sales |
| Trial | Sales-gated; no self-serve trial |
| ToS scraping | Explicitly built for web data collection; GDPR/CCPA-compliant; KYC process |
| Performance | ~0.25s response time (claimed); 99.9% uptime; AI-assisted IP routing |
Nimbleway is a premium-tier provider aimed at enterprise data engineering teams — not casual or low-volume scrapers. Its proprietary residential network is quality-over-quantity: the pool is smaller than Oxylabs or Bright Data, but Nimble compensates with intelligent IP optimisation, built-in anti-detection routing, and an AI-layered product stack (Nimble API, Nimble Browser, Nimble IP). The 0.25s response time claim is genuinely fast and geo-targeting granularity is excellent.
The fatal problem for this use case is the pricing model. The $300/month minimum gives roughly 21 GB at $14/GB — 23x the bandwidth needed for ~610 GB/month. The PAYG $8/GB rate is more reasonable but the trial is sales-gated. Nimbleway targets teams spending $300$4,000/month and makes no accommodation for low-volume users.
Unless Playtomic becomes extremely difficult to scrape and requires elite anti-bot bypass, Nimbleway is overengineered and overpriced for 14,000 requests/day. The $300/month floor alone exceeds the entire stated budget ceiling. Keep on a shortlist if usage scales dramatically or if other providers start failing systematically.
---
### 7.2 Froxy
| Field | Value |
|-------|-------|
| URL | https://froxy.com |
| Types | Rotating residential, mobile (4G/LTE), SOCKS5 proxies |
| Pool Size | 10M+ IPs worldwide |
| Countries | 200+ countries; city-level targeting |
| Pricing (entry plan) | ~$12.00/GB (5 GB for $60/month) |
| Pricing (scale) | ~$1.95/GB at $2,000/month |
| Sticky Sessions | 1060 minutes (IP reassigned on inactivity) |
| Auth Method | Username:password |
| Protocols | HTTP, HTTPS, SOCKS4, SOCKS5 |
| Min Purchase | $1.99 trial (3 days / 100 MB); entry plan ~$60/month (5 GB) |
| Trial | $1.99 / 100 MB / 3 days |
| ToS scraping | Permissive for data collection; no anti-scraping clauses in public docs |
| Performance | ~70% success rate (independent tests); 340800+ ms latency; Froxy claims 99% |
Froxy is a mid-tier European-facing provider with a clean dashboard, SOCKS4/5 support, and a genuine low-cost entry point via the $1.99 / 100 MB trial — the most honest validation option in this comparison. Port-based rotation with configurable intervals (903,600 seconds) gives fine-grained control over IP churn rate. The protocol breadth works with any Python HTTP library.
The core problems are pricing and performance. At low volumes, Froxy is expensive per GB: 5 GB for $60 = $12/GB. Independent testing puts the real-world success rate at ~70% and latency at 340800+ ms — measurably worse than premium providers and several budget alternatives. The 10M IP pool is on the smaller side with notable IP recycling reported.
Froxy is borderline-viable if you accept higher latency and occasional failures that the extractor's retry logic absorbs. Use the $1.99 trial first; if success rates on the actual Playtomic endpoint are acceptable, the $60/month entry plan fits within budget — but you are paying a premium per GB. Treat it as "test first, commit if it works" rather than a confident primary choice.
---
### 7.3 LunaProxy — ⛔ DO NOT USE
> **Disqualified January 2026.** LunaProxy was revealed to be a front brand operated by IPIDEA, a malicious residential proxy network disrupted by Google Threat Intelligence Group (GTIG) and Cloudflare. The "residential IPs" were sourced from malware-infected devices enrolled without user knowledge via rogue SDKs distributed to developers. In January 2026 Google obtained court orders and partnered with Cloudflare to take down IPIDEA's command-and-control infrastructure, cutting the network by ~40%. Over 550 tracked threat groups had used IPIDEA exit nodes in a single week.
>
> Using LunaProxy routes your traffic through compromised devices belonging to unknowing victims. Beyond the ethical problem, the network is actively degraded and unreliable. Do not use.
>
> Reference: [Google Cloud Blog — Disrupting the World's Largest Residential Proxy Network](https://cloud.google.com/blog/topics/threat-intelligence/disrupting-largest-residential-proxy-network)
---
### 7.4 Proxy-seller
| Field | Value |
|-------|-------|
| URL | https://proxy-seller.com |
| Types | ISP (static residential), rotating residential, datacenter, mobile |
| Pool Size | 20M+ rotating residential IPs; 400+ ISP networks / 800+ subnets |
| Countries | 220+ countries/regions; city and ISP targeting; ISP covers 22+ countries |
| Pricing (rotating residential bulk) | $0.70/GB; PAYG $7/GB |
| Pricing (ISP static) | $1$2/IP/month (volume discounts up to 57%) |
| Sticky Sessions | Residential: up to 24 hours; ISP: static (permanent) |
| Auth Method | Username:password; IP whitelisting |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | ~$1 (1 GB residential); ISP minimum varies |
| Trial | No formal free trial; ~$1 entry |
| ToS scraping | Positioned for scraping and privacy use; Trustpilot 4.7/5 (500+ reviews) |
| Performance | ~9192% success rate (SERP tests); 1 Gbps channel; 99% uptime; DNS leak inconsistency in one independent test |
Proxy-seller has two genuinely distinct product lines. For rotating residential proxies, the $0.70/GB pricing at volume is competitive and approaches LunaProxy territory, though the PAYG $7/GB rate is high without upfront commitment. The 24-hour sticky session on residential is exceptional — the longest in this comparison for a rotating product. For the ISP line, $12/IP/month for dedicated static residential IPs is competitive and useful for account-based access or distributing requests across a fixed pool of IPs.
The independent security testing revealed a DNS leak issue where anti-fraud databases classified connections inconsistently (residential vs. corporate vs. proxy). This is material concern for high-security targets but unlikely to matter for Playtomic's public API. The 92% SERP success rate is solid though it drops on well-defended targets. City-level targeting across 220+ GEOs is a genuine differentiator if appearing geographically local to Spain is important.
Proxy-seller is a credible option particularly if you value long sticky sessions, ISP/city targeting granularity, or want a single vendor for both rotating residential and ISP static proxies. The DNS inconsistency is worth monitoring but unlikely to cause failures against a mobile API target. The ~$1 minimum makes it trivially easy to test.
---
### 7.5 ProxyScrape
| Field | Value |
|-------|-------|
| URL | https://proxyscrape.com |
| Types | Rotating residential, shared datacenter (premium), dedicated datacenter; also maintains a well-known free public proxy list |
| Pool Size | 48M+ residential IPs (some sources cite 55M+) |
| Countries | 195+ countries; country, state, city targeting |
| Pricing (5 GB plan) | $4.85/GB ($24.25/month) |
| Pricing (20 GB plan) | $4.50/GB ($90/month) |
| Pricing (1 GB test) | ~$2 |
| Sticky Sessions | Up to 120 minutes |
| Auth Method | Username:password; IP whitelisting |
| Protocols | HTTP, HTTPS, SOCKS5 |
| Min Purchase | $2 (1 GB test); 3-day / up to 1 GB refund policy |
| Trial | 3-day refund policy functions as a trial |
| ToS scraping | Explicitly positioned for legitimate web scraping; emphasises ethical IP sourcing |
| Performance | ~0.8s response time; 99%+ uptime claimed; some reviews report IPs labelled residential that behave as ISP/datacenter |
ProxyScrape is best known as the source of large free public proxy lists, and that legacy creates both an opportunity and a perception problem. The paid residential product is a genuinely separate tier: 48M+ IPs, 195+ countries, city-level targeting, 120-minute sticky sessions (the joint-longest in this comparison), and SOCKS5 support. The 3-day refund policy on up to 1 GB effectively functions as a trial — pay $2, test against Playtomic, request a refund if it fails. Data does not expire monthly, which suits variable scraping workloads.
The concerns are meaningful. Independent reviews report that some IPs advertised as residential resolve to ISP or datacenter ranges when tested by anti-fraud tools, suggesting imprecise pool labelling. The 0.8s average response time is acceptable but slower than Nimbleway (0.25s); for 14,000 requests/day at roughly one request every six seconds, latency is not a bottleneck anyway. Trustpilot feedback is polarised with recurring mentions of support delays and filter reliability issues.
For the Playtomic workload, ProxyScrape is functionally adequate — pool is large enough, city targeting works, sticky sessions are long, and the $2 test-run is the easiest entry point in this comparison. Monthly cost for 610 GB would be $27$49 on the 5 GB plan, within budget but worse value per dollar than LunaProxy. The main risk is pool quality inconsistency — if Playtomic checks IP type and blocks datacenter ranges, mislabelled proxies would silently inflate failure rates. Treat it as a solid backup or test-first option.
---
## Sources
- [Bright Data Residential Pricing](https://brightdata.com/pricing/proxy-network/residential-proxies)
- [Oxylabs Residential Pricing](https://oxylabs.io/pricing/residential-proxy-pool)
- [Oxylabs ISP Proxies Pricing](https://oxylabs.io/pricing/isp-proxies)
- [Decodo Residential Pricing](https://decodo.com/proxies/residential-proxies/pricing)
- [IPRoyal Pricing](https://iproyal.com/pricing/residential-proxies/)
- [Rayobyte Pricing](https://rayobyte.com/pricing/)
- [NetNut Residential Proxies](https://netnut.io/residential-proxies/)
- [SOAX Pricing](https://soax.com/pricing)
- [Webshare Pricing](https://www.webshare.io/pricing)
- [Webshare Static Residential](https://www.webshare.io/static-residential-proxy)
- [Geonode Pricing](https://geonode.com/pricing/residential-proxies)
- [PacketStream Pricing](https://packetstream.io/pricing/)
- [Proxy-Cheap Rotating Residential](https://www.proxy-cheap.com/services/rotating-residential-proxies)
- [ProxyEmpire Pricing](https://proxyempire.io/pricing-table/)
- [Infatica Pricing](https://infatica.io/pricing/)
- [DataImpulse Residential](https://dataimpulse.com/residential-proxies/)
- [Evomi Residential](https://evomi.com/product/residential-proxies)
- [922Proxy Residential](https://www.922proxy.com/residential-proxies)
- [Shifter Pricing](https://shifter.io/pricing)
- [Storm Proxies Residential](https://stormproxies.com/residential_proxy.html)
- [Proxyway Best Residential Proxies 2026](https://proxyway.com/best/residential-proxies)
- [Proxyway Market Research 2025](https://proxyway.com/research/proxy-market-research-2025)
- [AIM Multiple Proxy Pricing Comparison](https://research.aimultiple.com/proxy-pricing/)
- [Nimbleway Residential Proxies](https://www.nimbleway.com/nimble-ip/residential-proxies)
- [Nimbleway Review — Proxyway](https://proxyway.com/reviews/nimbleway-review)
- [Nimble Pricing](https://www.nimbleway.com/pricing)
- [Froxy Residential Proxies Pricing](https://froxy.com/en/residential-proxies/pricing)
- [Froxy Review — StupidProxy](https://www.stupidproxy.com/froxy/)
- [Froxy Review — BestProxyFinder](https://bestproxyfinder.com/providers/froxy-review/)
- [LunaProxy Residential Pricing](https://www.lunaproxy.com/pricing/residential-proxies/)
- [LunaProxy Review — Proxyway](https://proxyway.com/reviews/lunaproxy-proxies)
- [Proxy-seller Residential Proxies](https://proxy-seller.com/residential-proxies/)
- [Proxy-seller ISP Proxies](https://proxy-seller.com/isp/)
- [Proxy-seller Review — Proxyway](https://proxyway.com/reviews/proxy-seller-proxies)
- [ProxyScrape Residential Proxies](https://proxyscrape.com/products/residential-proxies)
- [ProxyScrape Pricing](https://proxyscrape.com/pricing)
- [ProxyScrape Review — DiCloak](https://dicloak.com/blog-detail/proxyscrape-2025-review-complete-analysis-of-features-performance-pricing-and-security)

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

@@ -41,12 +41,17 @@ def setup_logging(name: str) -> logging.Logger:
def run_extractor(
extractor_name: str,
func,
proxy_url: str | None = None,
) -> None:
"""Boilerplate wrapper: open state DB, start run, call func, end run.
func signature: func(landing_dir, year_month, conn, session) -> dict
The dict must contain: files_written, files_skipped, bytes_written.
Optional: cursor_value.
proxy_url: if set, configure the session proxy before calling func.
Extractors that manage their own proxy logic (e.g. playtomic_availability)
ignore the shared session and are unaffected.
"""
LANDING_DIR.mkdir(parents=True, exist_ok=True)
conn = open_state_db(LANDING_DIR)
@@ -58,6 +63,8 @@ def run_extractor(
try:
with niquests.Session() as session:
session.headers["User-Agent"] = USER_AGENT
if proxy_url:
session.proxies = {"http": proxy_url, "https": proxy_url}
result = func(LANDING_DIR, year_month, conn, session)
assert isinstance(result, dict), f"extractor must return a dict, got {type(result)}"

View File

@@ -1,9 +1,20 @@
"""Run all extractors sequentially.
"""Run all extractors with dependency-aware parallel execution.
Entry point for the combined `uv run extract` command.
Each extractor gets its own state tracking row in .state.sqlite.
Extractors are declared as a dict mapping name → (func, [dependencies]).
A graphlib.TopologicalSorter schedules them: tasks with no unmet dependencies
run immediately in parallel; each completion may unlock new tasks.
Current dependency graph:
- All 8 non-availability extractors have no dependencies (run in parallel)
- playtomic_availability depends on playtomic_tenants (starts as soon as
tenants finishes, even if other extractors are still running)
"""
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from graphlib import TopologicalSorter
from ._shared import run_extractor, setup_logging
from .census_usa import EXTRACTOR_NAME as CENSUS_USA_NAME
from .census_usa import extract as extract_census_usa
@@ -17,6 +28,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
@@ -24,30 +37,68 @@ from .playtomic_tenants import extract as extract_tenants
logger = setup_logging("padelnomics.extract")
EXTRACTORS = [
(OVERPASS_NAME, extract_overpass),
(EUROSTAT_NAME, extract_eurostat),
(EUROSTAT_CITY_LABELS_NAME, extract_eurostat_city_labels),
(CENSUS_USA_NAME, extract_census_usa),
(ONS_UK_NAME, extract_ons_uk),
(GEONAMES_NAME, extract_geonames),
(TENANTS_NAME, extract_tenants),
(AVAILABILITY_NAME, extract_availability),
]
# Declarative: name → (func, [dependency names])
# Add new extractors here; the scheduler handles ordering and parallelism.
EXTRACTORS: dict[str, tuple] = {
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, []),
ONS_UK_NAME: (extract_ons_uk, []),
GEONAMES_NAME: (extract_geonames, []),
TENANTS_NAME: (extract_tenants, []),
AVAILABILITY_NAME: (extract_availability, [TENANTS_NAME]),
}
def _run_safe(name: str) -> bool:
"""Run one extractor, return True on success."""
func, _ = EXTRACTORS[name]
try:
run_extractor(name, func)
return True
except Exception:
logger.exception("Extractor %s failed", name)
return False
def main() -> None:
"""Run all extractors. Each gets its own state row."""
"""Run all extractors respecting declared dependencies, maximally parallel."""
logger.info("Running %d extractors", len(EXTRACTORS))
for i, (name, func) in enumerate(EXTRACTORS, 1):
logger.info("[%d/%d] %s", i, len(EXTRACTORS), name)
try:
run_extractor(name, func)
except Exception:
logger.exception("Extractor %s failed — continuing with next", name)
graph = {name: set(deps) for name, (_, deps) in EXTRACTORS.items()}
ts = TopologicalSorter(graph)
ts.prepare()
logger.info("All extractors complete")
failed: list[str] = []
with ThreadPoolExecutor(max_workers=len(EXTRACTORS)) as pool:
futures: dict = {}
# Submit all initially ready tasks (no dependencies)
for name in ts.get_ready():
futures[pool.submit(_run_safe, name)] = name
# Process completions and submit newly-unblocked tasks
while futures:
done_set, _ = wait(futures, return_when=FIRST_COMPLETED)
for f in done_set:
name = futures.pop(f)
ok = f.result()
if ok:
logger.info("done: %s", name)
else:
failed.append(name)
logger.warning("FAILED: %s", name)
ts.done(name)
for ready in ts.get_ready():
futures[pool.submit(_run_safe, ready)] = ready
if failed:
logger.warning("Completed with %d failure(s): %s", len(failed), ", ".join(failed))
else:
logger.info("All %d extractors complete", len(EXTRACTORS))
if __name__ == "__main__":

View File

@@ -180,6 +180,8 @@ def extract(
session: niquests.Session,
) -> dict:
"""Fetch all Eurostat datasets. Returns run metrics."""
assert landing_dir.is_dir(), f"landing_dir must exist: {landing_dir}"
assert "/" in year_month and len(year_month) == 7, f"year_month must be YYYY/MM: {year_month!r}"
year, month = year_month.split("/")
files_written = 0
files_skipped = 0

View File

@@ -1,20 +1,23 @@
"""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,
"ref_year": 2024}], "count": N}
Landing: {LANDING_DIR}/geonames/{year}/{month}/cities_global.jsonl.gz
Output: one JSON object per line, e.g.:
{"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}
"""
import gzip
import io
import json
import os
@@ -25,30 +28,39 @@ from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import get_last_cursor, landing_path, write_gzip_atomic
from .utils import compress_jsonl_atomic, get_last_cursor, landing_path
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,15 +125,18 @@ 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")
year, month = year_month.split("/")
dest_dir = landing_path(landing_dir, "geonames", year, month)
dest = dest_dir / "cities_global.json.gz"
dest = dest_dir / "cities_global.jsonl.gz"
if not dest.exists():
write_gzip_atomic(dest, b'{"rows": [], "count": 0}')
tmp = dest.with_suffix(".gz.tmp")
with gzip.open(tmp, "wt") as f:
f.write('{"geoname_id":null}\n') # filtered by WHERE geoname_id IS NOT NULL
tmp.rename(dest)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
@@ -120,30 +146,33 @@ 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"
payload = json.dumps({"rows": rows, "count": len(rows)}).encode()
bytes_written = write_gzip_atomic(dest, payload)
dest = dest_dir / "cities_global.jsonl.gz"
working_path = dest.with_suffix(".working.jsonl")
with open(working_path, "w") as f:
for row in rows:
f.write(json.dumps(row, separators=(",", ":")) + "\n")
bytes_written = compress_jsonl_atomic(working_path, dest)
logger.info("written %s bytes compressed", f"{bytes_written:,}")
return {

View File

@@ -0,0 +1,175 @@
"""Overpass API extractor — global tennis court locations from OpenStreetMap.
Queries the Overpass API for all nodes/ways/relations tagged sport=tennis,
split across 10 geographic regions to avoid timeout on the ~150K+ global result.
Regional strategy:
- Each region is a bounding box covering a continent or sub-continent
- Each region is queried independently (POST with [bbox:...])
- Overlapping bboxes are deduped on OSM element id
- One region per POST (~10-40K elements each, well within Overpass limits)
- Crash recovery: working JSONL accumulates completed regions; on restart
already-written IDs are skipped, completed regions produce 0 new elements
Landing: {LANDING_DIR}/overpass_tennis/{year}/{month}/courts.jsonl.gz
"""
import json
import sqlite3
import time
from pathlib import Path
import niquests
from ._shared import run_extractor, setup_logging
from .utils import compress_jsonl_atomic, landing_path, load_partial_results
logger = setup_logging("padelnomics.extract.overpass_tennis")
EXTRACTOR_NAME = "overpass_tennis"
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Each region is [south, west, north, east] — Overpass bbox format
REGIONS = [
{"name": "europe_west", "bbox": "35.0,-11.0,61.0,8.0"}, # FR ES GB PT IE BE NL
{"name": "europe_central", "bbox": "42.0,8.0,55.5,24.0"}, # DE IT AT CH CZ PL HU
{"name": "europe_east", "bbox": "35.0,24.0,72.0,60.0"}, # Nordics Baltics GR TR RO
{"name": "north_america", "bbox": "15.0,-170.0,72.0,-50.0"}, # US CA MX
{"name": "south_america", "bbox": "-56.0,-82.0,15.0,-34.0"}, # BR AR CL
{"name": "asia_east", "bbox": "18.0,73.0,54.0,150.0"}, # JP KR CN
{"name": "asia_west", "bbox": "-11.0,24.0,42.0,73.0"}, # Middle East India
{"name": "oceania", "bbox": "-50.0,110.0,5.0,180.0"}, # AU NZ
{"name": "africa", "bbox": "-35.0,-18.0,37.0,52.0"}, # ZA EG MA
{"name": "asia_north", "bbox": "42.0,60.0,82.0,180.0"}, # RU-east KZ
]
MAX_RETRIES_PER_REGION = 2
RETRY_DELAY_SECONDS = 30 # Overpass cooldown between retries
REGION_TIMEOUT_SECONDS = 180 # Client-side per-region timeout (server uses 150s)
INTER_REGION_DELAY_SECONDS = 5 # Polite delay between regions
def _region_query(bbox: str) -> str:
"""Build an Overpass QL query for tennis courts within a bounding box."""
return (
f"[out:json][timeout:150][bbox:{bbox}];\n"
"(\n"
" node[\"sport\"=\"tennis\"];\n"
" way[\"sport\"=\"tennis\"];\n"
" rel[\"sport\"=\"tennis\"];\n"
");\n"
"out center;"
)
def _query_region(session: niquests.Session, region: dict) -> list[dict]:
"""POST one regional Overpass query. Returns list of OSM elements."""
query = _region_query(region["bbox"])
resp = session.post(
OVERPASS_URL,
data={"data": query},
timeout=REGION_TIMEOUT_SECONDS,
)
resp.raise_for_status()
return resp.json().get("elements", [])
def extract(
landing_dir: Path,
year_month: str,
conn: sqlite3.Connection,
session: niquests.Session,
) -> dict:
"""Query Overpass for global tennis courts using regional bbox splitting.
Splits the global query into REGIONS to avoid Overpass timeout.
Writes one OSM element per line to courts.jsonl.gz.
Crash-safe: working.jsonl accumulates results; on restart already-written
element IDs are skipped so completed regions produce 0 new elements.
"""
assert landing_dir.is_dir(), f"landing_dir must exist: {landing_dir}"
assert "/" in year_month and len(year_month) == 7, f"year_month must be YYYY/MM: {year_month!r}"
year, month = year_month.split("/")
dest_dir = landing_path(landing_dir, "overpass_tennis", year, month)
dest = dest_dir / "courts.jsonl.gz"
old_blob = dest_dir / "courts.json.gz"
if dest.exists() or old_blob.exists():
logger.info("Already have courts for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
# Crash recovery: load already-written elements from the working file
working_path = dest_dir / "courts.working.jsonl"
prior_records, already_seen_ids = load_partial_results(working_path, id_key="id")
if already_seen_ids:
logger.info("Resuming: %d elements already in working file", len(already_seen_ids))
total_new = 0
regions_succeeded: list[str] = []
regions_failed: list[str] = []
working_file = open(working_path, "a") # noqa: SIM115
try:
for i, region in enumerate(REGIONS):
for attempt in range(MAX_RETRIES_PER_REGION + 1):
try:
elements = _query_region(session, region)
new_elements = [e for e in elements if str(e.get("id", "")) not in already_seen_ids]
for elem in new_elements:
working_file.write(json.dumps(elem, separators=(",", ":")) + "\n")
already_seen_ids.add(str(elem["id"]))
working_file.flush()
total_new += len(new_elements)
regions_succeeded.append(region["name"])
logger.info(
"Region %s: %d elements (%d new, %d total)",
region["name"], len(elements), len(new_elements), len(already_seen_ids),
)
break
except niquests.exceptions.RequestException as exc:
if attempt < MAX_RETRIES_PER_REGION:
logger.warning(
"Region %s attempt %d failed: %s — retrying in %ds",
region["name"], attempt + 1, exc, RETRY_DELAY_SECONDS,
)
time.sleep(RETRY_DELAY_SECONDS)
else:
regions_failed.append(region["name"])
logger.error(
"Region %s failed after %d attempts: %s",
region["name"], MAX_RETRIES_PER_REGION + 1, exc,
)
if i < len(REGIONS) - 1:
time.sleep(INTER_REGION_DELAY_SECONDS)
finally:
working_file.close()
total_elements = len(prior_records) + total_new
if total_elements == 0:
raise RuntimeError(f"All regions failed, no elements written: {regions_failed}")
if regions_failed:
logger.warning("Completed with %d failed regions: %s", len(regions_failed), regions_failed)
bytes_written = compress_jsonl_atomic(working_path, dest)
logger.info(
"%d total elements (%d regions, %d failed) -> %s (%s bytes)",
total_elements, len(regions_succeeded), len(regions_failed), 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

@@ -5,13 +5,18 @@ unauthenticated /v1/availability endpoint for each venue's next-day slots.
This is the highest-value source: daily snapshots enable occupancy rate
estimation, pricing benchmarking, and demand signal detection.
Parallel mode: set EXTRACT_WORKERS=N and PROXY_URLS=... to fetch N venues
concurrently (one proxy per worker). Without proxies, runs single-threaded.
Parallel mode: worker count is derived from PROXY_URLS (one worker per proxy).
Without proxies, runs single-threaded with per-request throttling.
Crash resumption: progress is flushed to a .partial.jsonl sidecar file every
PARTIAL_FLUSH_SIZE records. On restart the already-fetched venues are skipped
and prior results are merged into the final file. At most PARTIAL_FLUSH_SIZE
records (a few seconds of work with 10 workers) are lost on crash.
Recheck mode: re-queries venues with slots starting within the next 90 minutes.
Writes a separate recheck file for more accurate occupancy measurement.
Landing: {LANDING_DIR}/playtomic/{year}/{month}/availability_{date}.json.gz
Landing: {LANDING_DIR}/playtomic/{year}/{month}/availability_{date}.jsonl.gz
Recheck: {LANDING_DIR}/playtomic/{year}/{month}/availability_{date}_recheck_{HH}.json.gz
"""
@@ -28,8 +33,14 @@ from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, USER_AGENT, run_extractor, setup_logging
from .proxy import load_proxy_urls, make_round_robin_cycler
from .utils import get_last_cursor, landing_path, write_gzip_atomic
from .proxy import load_fallback_proxy_urls, load_proxy_urls, make_tiered_cycler
from .utils import (
compress_jsonl_atomic,
flush_partial_batch,
landing_path,
load_partial_results,
write_gzip_atomic,
)
logger = setup_logging("padelnomics.extract.playtomic_availability")
@@ -40,8 +51,16 @@ AVAILABILITY_URL = "https://api.playtomic.io/v1/availability"
THROTTLE_SECONDS = 1
MAX_VENUES_PER_RUN = 20_000
MAX_RETRIES_PER_VENUE = 2
MAX_WORKERS = int(os.environ.get("EXTRACT_WORKERS", "1"))
RECHECK_WINDOW_MINUTES = int(os.environ.get("RECHECK_WINDOW_MINUTES", "90"))
RECHECK_WINDOW_MINUTES = int(os.environ.get("RECHECK_WINDOW_MINUTES", "30"))
CIRCUIT_BREAKER_THRESHOLD = int(os.environ.get("CIRCUIT_BREAKER_THRESHOLD") or "10")
# Parallel mode submits futures in batches so the circuit breaker can stop
# new submissions after it opens. Already-inflight futures in the current
# batch still complete.
PARALLEL_BATCH_SIZE = 100
# Flush partial results to disk every N records — lose at most this many on crash.
PARTIAL_FLUSH_SIZE = 50
# Thread-local storage for per-worker sessions
_thread_local = threading.local()
@@ -52,24 +71,41 @@ _thread_local = threading.local()
# ---------------------------------------------------------------------------
def _load_tenant_ids(landing_dir: Path) -> list[str]:
"""Read tenant IDs from the most recent tenants.json.gz file."""
"""Read tenant IDs from the most recent tenants file (JSONL or blob format)."""
assert landing_dir.is_dir(), f"landing_dir must exist: {landing_dir}"
playtomic_dir = landing_dir / "playtomic"
if not playtomic_dir.exists():
return []
# Prefer JSONL (new format), fall back to blob (old format)
tenant_files = sorted(playtomic_dir.glob("*/*/tenants.jsonl.gz"), reverse=True)
if not tenant_files:
tenant_files = sorted(playtomic_dir.glob("*/*/tenants.json.gz"), reverse=True)
if not tenant_files:
return []
latest = tenant_files[0]
logger.info("Loading tenant IDs from %s", latest)
with gzip.open(latest, "rb") as f:
data = json.loads(f.read())
tenants = data.get("tenants", [])
ids = []
for t in tenants:
with gzip.open(latest, "rt") as f:
if latest.name.endswith(".jsonl.gz"):
# JSONL: one tenant object per line
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
tid = record.get("tenant_id") or record.get("id")
if tid:
ids.append(tid)
except json.JSONDecodeError:
break # truncated last line
else:
# Blob: {"tenants": [...]}
data = json.loads(f.read())
for t in data.get("tenants", []):
tid = t.get("tenant_id") or t.get("id")
if tid:
ids.append(tid)
@@ -78,22 +114,6 @@ def _load_tenant_ids(landing_dir: Path) -> list[str]:
return ids
def _parse_resume_cursor(cursor: str | None, target_date: str) -> int:
"""Parse cursor_value to find resume index. Returns 0 if no valid cursor."""
if not cursor:
return 0
parts = cursor.split(":", 1)
if len(parts) != 2:
return 0
cursor_date, cursor_index = parts
if cursor_date != target_date:
return 0
try:
return int(cursor_index)
except ValueError:
return 0
# ---------------------------------------------------------------------------
# Per-venue fetch (used by both serial and parallel modes)
# ---------------------------------------------------------------------------
@@ -143,6 +163,7 @@ def _fetch_venue_availability(
continue
resp.raise_for_status()
if not proxy_url:
time.sleep(THROTTLE_SECONDS)
return {"tenant_id": tenant_id, "slots": resp.json()}
@@ -169,10 +190,19 @@ def _fetch_venues_parallel(
start_min_str: str,
start_max_str: str,
worker_count: int,
proxy_cycler,
cycler: dict,
fallback_urls: list[str],
on_result=None,
) -> tuple[list[dict], int]:
"""Fetch availability for multiple venues in parallel.
Submits futures in batches of PARALLEL_BATCH_SIZE. After each batch
completes, checks the circuit breaker: if it opened and there is no
fallback configured, stops submitting further batches.
on_result: optional callable(result: dict) invoked inside the lock for
each successful result — used for incremental partial-file flushing.
Returns (venues_data, venues_errored).
"""
venues_data: list[dict] = []
@@ -181,20 +211,34 @@ def _fetch_venues_parallel(
lock = threading.Lock()
def _worker(tenant_id: str) -> dict | None:
proxy_url = proxy_cycler()
proxy_url = cycler["next_proxy"]()
return _fetch_venue_availability(tenant_id, start_min_str, start_max_str, proxy_url)
with ThreadPoolExecutor(max_workers=worker_count) as pool:
futures = {pool.submit(_worker, tid): tid for tid in tenant_ids}
for batch_start in range(0, len(tenant_ids), PARALLEL_BATCH_SIZE):
# Stop submitting new work if circuit is open with no fallback
if cycler["is_fallback_active"]() and not fallback_urls:
logger.error(
"Circuit open with no fallback — stopping after %d/%d venues",
completed_count, len(tenant_ids),
)
break
for future in as_completed(futures):
batch = tenant_ids[batch_start:batch_start + PARALLEL_BATCH_SIZE]
batch_futures = {pool.submit(_worker, tid): tid for tid in batch}
for future in as_completed(batch_futures):
result = future.result()
with lock:
completed_count += 1
if result is not None:
venues_data.append(result)
cycler["record_success"]()
if on_result is not None:
on_result(result)
else:
venues_errored += 1
cycler["record_failure"]()
if completed_count % 500 == 0:
logger.info(
@@ -220,6 +264,8 @@ def extract(
session: niquests.Session,
) -> dict:
"""Fetch next-day availability for all known Playtomic venues."""
assert landing_dir.is_dir(), f"landing_dir must exist: {landing_dir}"
assert "/" in year_month and len(year_month) == 7, f"year_month must be YYYY/MM: {year_month!r}"
tenant_ids = _load_tenant_ids(landing_dir)
if not tenant_ids:
logger.warning("No tenant IDs found — run extract-playtomic-tenants first")
@@ -233,48 +279,74 @@ def extract(
year, month = year_month.split("/")
dest_dir = landing_path(landing_dir, "playtomic", year, month)
dest = dest_dir / f"availability_{target_date}.json.gz"
if dest.exists():
logger.info("Already have %s — skipping", dest)
dest = dest_dir / f"availability_{target_date}.jsonl.gz"
old_blob = dest_dir / f"availability_{target_date}.json.gz"
if dest.exists() or old_blob.exists():
logger.info("Already have availability for %s — skipping", target_date)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
# Resume from cursor if crashed mid-run
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
resume_index = _parse_resume_cursor(last_cursor, target_date)
if resume_index > 0:
logger.info("Resuming from index %d (cursor: %s)", resume_index, last_cursor)
# Crash resumption: load already-fetched venues from working file
partial_path = dest_dir / f"availability_{target_date}.working.jsonl"
prior_results, already_done = load_partial_results(partial_path, id_key="tenant_id")
if already_done:
logger.info("Resuming: %d venues already fetched from partial file", len(already_done))
venues_to_process = tenant_ids[:MAX_VENUES_PER_RUN]
if resume_index > 0:
venues_to_process = venues_to_process[resume_index:]
all_venues_to_process = tenant_ids[:MAX_VENUES_PER_RUN]
venues_to_process = [tid for tid in all_venues_to_process if tid not in already_done]
# Determine parallelism
# Set up tiered proxy cycler with circuit breaker
proxy_urls = load_proxy_urls()
worker_count = min(MAX_WORKERS, len(proxy_urls)) if proxy_urls else 1
proxy_cycler = make_round_robin_cycler(proxy_urls)
fallback_urls = load_fallback_proxy_urls()
worker_count = len(proxy_urls) if proxy_urls else 1
cycler = make_tiered_cycler(proxy_urls, fallback_urls, CIRCUIT_BREAKER_THRESHOLD)
start_min_str = start_min.strftime("%Y-%m-%dT%H:%M:%S")
start_max_str = start_max.strftime("%Y-%m-%dT%H:%M:%S")
# Timestamp stamped into every JSONL line — computed once before the fetch loop.
captured_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
# Working file for incremental crash-safe progress (IS the final file).
partial_file = open(partial_path, "a") # noqa: SIM115
partial_lock = threading.Lock()
pending_batch: list[dict] = []
def _on_result(result: dict) -> None:
# Called inside _fetch_venues_parallel's lock — no additional locking needed.
# In serial mode, called single-threaded — also safe without extra locking.
# Inject date + captured_at so every JSONL line is self-contained.
result["date"] = target_date
result["captured_at_utc"] = captured_at
pending_batch.append(result)
if len(pending_batch) >= PARTIAL_FLUSH_SIZE:
flush_partial_batch(partial_file, partial_lock, pending_batch)
pending_batch.clear()
new_venues_data: list[dict] = []
venues_errored = 0
if worker_count > 1:
logger.info("Parallel mode: %d workers, %d proxies", worker_count, len(proxy_urls))
venues_data, venues_errored = _fetch_venues_parallel(
venues_to_process, start_min_str, start_max_str, worker_count, proxy_cycler,
new_venues_data, venues_errored = _fetch_venues_parallel(
venues_to_process, start_min_str, start_max_str, worker_count, cycler, fallback_urls,
on_result=_on_result,
)
else:
# Serial mode — same as before but uses shared fetch function
logger.info("Serial mode: 1 worker, %d venues", len(venues_to_process))
venues_data = []
venues_errored = 0
for i, tenant_id in enumerate(venues_to_process):
result = _fetch_venue_availability(
tenant_id, start_min_str, start_max_str, proxy_cycler(),
tenant_id, start_min_str, start_max_str, cycler["next_proxy"](),
)
if result is not None:
venues_data.append(result)
new_venues_data.append(result)
cycler["record_success"]()
_on_result(result)
else:
venues_errored += 1
circuit_opened = cycler["record_failure"]()
if circuit_opened and not fallback_urls:
logger.error("Circuit open with no fallback — writing partial results")
break
if (i + 1) % 100 == 0:
logger.info(
@@ -282,27 +354,26 @@ def extract(
i + 1, len(venues_to_process), venues_errored,
)
# Write consolidated file
captured_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
payload = json.dumps({
"date": target_date,
"captured_at_utc": captured_at,
"venue_count": len(venues_data),
"venues_errored": venues_errored,
"venues": venues_data,
}).encode()
# Final flush of any remaining partial batch
if pending_batch:
flush_partial_batch(partial_file, partial_lock, pending_batch)
pending_batch.clear()
partial_file.close()
# Working file IS the output — compress atomically (deletes source).
total_venues = len(prior_results) + len(new_venues_data)
bytes_written = compress_jsonl_atomic(partial_path, dest)
bytes_written = write_gzip_atomic(dest, payload)
logger.info(
"%d venues scraped (%d errors) -> %s (%s bytes)",
len(venues_data), venues_errored, dest, f"{bytes_written:,}",
total_venues, venues_errored, dest, f"{bytes_written:,}",
)
return {
"files_written": 1,
"files_skipped": 0,
"bytes_written": bytes_written,
"cursor_value": f"{target_date}:{len(tenant_ids[:MAX_VENUES_PER_RUN])}",
"cursor_value": f"{target_date}:{len(all_venues_to_process)}",
}
@@ -310,14 +381,36 @@ def extract(
# Recheck mode — re-query venues with upcoming slots for accurate occupancy
# ---------------------------------------------------------------------------
def _read_availability_jsonl(path: Path) -> dict:
"""Read a JSONL availability file into the blob dict format recheck expects."""
venues = []
date_val = captured_at = None
with gzip.open(path, "rt") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
break # truncated last line on crash
if date_val is None:
date_val = record.get("date")
captured_at = record.get("captured_at_utc")
venues.append(record)
return {"date": date_val, "captured_at_utc": captured_at, "venues": venues}
def _load_morning_availability(landing_dir: Path, target_date: str) -> dict | None:
"""Load today's morning availability file. Returns parsed JSON or None."""
"""Load today's morning availability file (JSONL or blob). Returns dict or None."""
playtomic_dir = landing_dir / "playtomic"
# Search across year/month dirs for the target date
# Try JSONL first (new format), fall back to blob (old format)
matches = list(playtomic_dir.glob(f"*/*/availability_{target_date}.jsonl.gz"))
if matches:
return _read_availability_jsonl(matches[0])
matches = list(playtomic_dir.glob(f"*/*/availability_{target_date}.json.gz"))
if not matches:
return None
with gzip.open(matches[0], "rb") as f:
return json.loads(f.read())
@@ -357,6 +450,8 @@ def extract_recheck(
session: niquests.Session,
) -> dict:
"""Re-query venues with slots starting soon for accurate occupancy data."""
assert landing_dir.is_dir(), f"landing_dir must exist: {landing_dir}"
assert "/" in year_month and len(year_month) == 7, f"year_month must be YYYY/MM: {year_month!r}"
now = datetime.now(UTC)
target_date = now.strftime("%Y-%m-%d")
@@ -390,24 +485,30 @@ def extract_recheck(
start_min_str = window_start.strftime("%Y-%m-%dT%H:%M:%S")
start_max_str = window_end.strftime("%Y-%m-%dT%H:%M:%S")
# Determine parallelism
# Set up tiered proxy cycler with circuit breaker
proxy_urls = load_proxy_urls()
worker_count = min(MAX_WORKERS, len(proxy_urls)) if proxy_urls else 1
proxy_cycler = make_round_robin_cycler(proxy_urls)
fallback_urls = load_fallback_proxy_urls()
worker_count = len(proxy_urls) if proxy_urls else 1
cycler = make_tiered_cycler(proxy_urls, fallback_urls, CIRCUIT_BREAKER_THRESHOLD)
if worker_count > 1 and len(venues_to_recheck) > 10:
venues_data, venues_errored = _fetch_venues_parallel(
venues_to_recheck, start_min_str, start_max_str, worker_count, proxy_cycler,
venues_to_recheck, start_min_str, start_max_str, worker_count, cycler, fallback_urls,
)
else:
venues_data = []
venues_errored = 0
for tid in venues_to_recheck:
result = _fetch_venue_availability(tid, start_min_str, start_max_str, proxy_cycler())
result = _fetch_venue_availability(tid, start_min_str, start_max_str, cycler["next_proxy"]())
if result is not None:
venues_data.append(result)
cycler["record_success"]()
else:
venues_errored += 1
circuit_opened = cycler["record_failure"]()
if circuit_opened and not fallback_urls:
logger.error("Circuit open with no fallback — writing partial recheck results")
break
# Write recheck file
recheck_hour = now.hour

View File

@@ -1,8 +1,8 @@
"""Playtomic tenants extractor — venue listings via unauthenticated API.
Paginates through the global tenant list (sorted by UUID) using the `page`
parameter. Deduplicates on tenant_id and writes a single consolidated JSON
to the landing zone.
parameter. Deduplicates on tenant_id and writes a gzipped JSONL file to the
landing zone (one tenant object per line).
API notes (discovered 2026-02):
- bbox params (min_latitude etc.) are silently ignored by the API
@@ -10,20 +10,28 @@ API notes (discovered 2026-02):
- `size=100` is the maximum effective page size
- ~14K venues globally as of Feb 2026
Rate: 1 req / 2 s (see docs/data-sources-inventory.md §1.2).
Parallel mode: when PROXY_URLS is set, fires batch_size = len(proxy_urls)
pages concurrently. Each page gets its own fresh session + proxy. Pages beyond
the last one return empty lists (safe — just triggers the done condition).
Without proxies, falls back to single-threaded with THROTTLE_SECONDS between
pages.
Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.json.gz
Rate: 1 req / 2 s per IP (see docs/data-sources-inventory.md §1.2).
Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.jsonl.gz
"""
import json
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import landing_path, write_gzip_atomic
from ._shared import HTTP_TIMEOUT_SECONDS, USER_AGENT, run_extractor, setup_logging
from .proxy import load_proxy_urls, make_round_robin_cycler
from .utils import compress_jsonl_atomic, landing_path
logger = setup_logging("padelnomics.extract.playtomic_tenants")
@@ -35,6 +43,30 @@ PAGE_SIZE = 100
MAX_PAGES = 500 # safety bound — ~50K venues max, well above current ~14K
def _fetch_one_page(proxy_url: str | None, page: int) -> tuple[int, list[dict]]:
"""Fetch a single page using a fresh session with the given proxy.
Returns (page, tenants_list). Raises on HTTP error.
"""
s = niquests.Session()
s.headers["User-Agent"] = USER_AGENT
if proxy_url:
s.proxies = {"http": proxy_url, "https": proxy_url}
params = {"sport_ids": "PADEL", "size": PAGE_SIZE, "page": page}
resp = s.get(PLAYTOMIC_TENANTS_URL, params=params, timeout=HTTP_TIMEOUT_SECONDS)
resp.raise_for_status()
tenants = resp.json()
assert isinstance(tenants, list), f"Expected list from Playtomic API, got {type(tenants)}"
return (page, tenants)
def _fetch_pages_parallel(pages: list[int], next_proxy) -> list[tuple[int, list[dict]]]:
"""Fetch multiple pages concurrently. Returns [(page_num, tenants_list), ...]."""
with ThreadPoolExecutor(max_workers=len(pages)) as pool:
futures = [pool.submit(_fetch_one_page, next_proxy(), p) for p in pages]
return [f.result() for f in as_completed(futures)]
def extract(
landing_dir: Path,
year_month: str,
@@ -44,28 +76,47 @@ def extract(
"""Fetch all Playtomic venues via global pagination. Returns run metrics."""
year, month = year_month.split("/")
dest_dir = landing_path(landing_dir, "playtomic", year, month)
dest = dest_dir / "tenants.json.gz"
dest = dest_dir / "tenants.jsonl.gz"
proxy_urls = load_proxy_urls()
next_proxy = make_round_robin_cycler(proxy_urls) if proxy_urls else None
batch_size = len(proxy_urls) if proxy_urls else 1
if next_proxy:
logger.info("Parallel mode: %d pages per batch (%d proxies)", batch_size, len(proxy_urls))
else:
logger.info("Serial mode: 1 page at a time (no proxies)")
all_tenants: list[dict] = []
seen_ids: set[str] = set()
page = 0
done = False
for page in range(MAX_PAGES):
params = {
"sport_ids": "PADEL",
"size": PAGE_SIZE,
"page": page,
}
logger.info("GET page=%d (total so far: %d)", page, len(all_tenants))
while not done and page < MAX_PAGES:
batch_end = min(page + batch_size, MAX_PAGES)
pages_to_fetch = list(range(page, batch_end))
if next_proxy and len(pages_to_fetch) > 1:
logger.info(
"Fetching pages %d-%d in parallel (%d workers, total so far: %d)",
page, batch_end - 1, len(pages_to_fetch), len(all_tenants),
)
results = _fetch_pages_parallel(pages_to_fetch, next_proxy)
else:
# Serial: reuse the shared session, throttle between pages
page_num = pages_to_fetch[0]
logger.info("GET page=%d (total so far: %d)", page_num, len(all_tenants))
params = {"sport_ids": "PADEL", "size": PAGE_SIZE, "page": page_num}
resp = session.get(PLAYTOMIC_TENANTS_URL, params=params, timeout=HTTP_TIMEOUT_SECONDS)
resp.raise_for_status()
tenants = resp.json()
assert isinstance(tenants, list), (
f"Expected list from Playtomic API, got {type(tenants)}"
)
results = [(page_num, tenants)]
# Process pages in order so the done-detection on < PAGE_SIZE is deterministic
for p, tenants in sorted(results):
new_count = 0
for tenant in tenants:
tid = tenant.get("tenant_id") or tenant.get("id")
@@ -75,17 +126,24 @@ def extract(
new_count += 1
logger.info(
"page=%d got=%d new=%d total=%d", page, len(tenants), new_count, len(all_tenants)
"page=%d got=%d new=%d total=%d", p, len(tenants), new_count, len(all_tenants),
)
# Last page — fewer than PAGE_SIZE results means we've exhausted the list
if len(tenants) < PAGE_SIZE:
done = True
break
page = batch_end
if not next_proxy:
time.sleep(THROTTLE_SECONDS)
payload = json.dumps({"tenants": all_tenants, "count": len(all_tenants)}).encode()
bytes_written = write_gzip_atomic(dest, payload)
# Write each tenant as a JSONL line, then compress atomically
working_path = dest.with_suffix(".working.jsonl")
with open(working_path, "w") as f:
for tenant in all_tenants:
f.write(json.dumps(tenant, separators=(",", ":")) + "\n")
bytes_written = compress_jsonl_atomic(working_path, dest)
logger.info("%d unique venues -> %s", len(all_tenants), dest)
return {

View File

@@ -3,15 +3,19 @@
Proxies are configured via the PROXY_URLS environment variable (comma-separated).
When unset, all functions return None/no-op — extractors fall back to direct requests.
Two routing modes:
round-robin — distribute requests evenly across proxies (default)
sticky — same key always maps to same proxy (for session-tracked sites)
Tiered proxy with circuit breaker:
Primary tier (PROXY_URLS) is used by default — typically cheap datacenter proxies.
Fallback tier (PROXY_URLS_FALLBACK) activates once consecutive failures >= threshold.
Once the circuit opens it stays open for the duration of the run (no auto-recovery).
"""
import itertools
import logging
import os
import threading
logger = logging.getLogger(__name__)
def load_proxy_urls() -> list[str]:
"""Read PROXY_URLS env var (comma-separated). Returns [] if unset.
@@ -23,6 +27,17 @@ def load_proxy_urls() -> list[str]:
return urls
def load_fallback_proxy_urls() -> list[str]:
"""Read PROXY_URLS_FALLBACK env var (comma-separated). Returns [] if unset.
Used as the residential/reliable fallback tier when the primary tier fails.
Format: http://user:pass@host:port or socks5://host:port
"""
raw = os.environ.get("PROXY_URLS_FALLBACK", "")
urls = [u.strip() for u in raw.split(",") if u.strip()]
return urls
def make_round_robin_cycler(proxy_urls: list[str]):
"""Thread-safe round-robin proxy cycler.
@@ -42,16 +57,83 @@ def make_round_robin_cycler(proxy_urls: list[str]):
return next_proxy
def make_sticky_selector(proxy_urls: list[str]):
"""Consistent-hash proxy selector — same key always maps to same proxy.
def make_tiered_cycler(
primary_urls: list[str],
fallback_urls: list[str],
threshold: int,
) -> dict:
"""Thread-safe tiered proxy cycler with circuit breaker.
Use when the target site tracks sessions by IP (e.g. Cloudflare).
Returns a callable: select_proxy(key: str) -> str | None
Uses primary_urls until consecutive failures >= threshold, then switches
permanently to fallback_urls for the rest of the run. No auto-recovery —
once the circuit opens it stays open to avoid flapping.
Returns a dict of callables:
next_proxy() -> str | None — returns URL from the active tier
record_success() — resets consecutive failure counter
record_failure() -> bool — increments counter; True if circuit just opened
is_fallback_active() -> bool — whether fallback tier is currently active
If primary_urls is empty: always returns from fallback_urls (no circuit breaker needed).
If both are empty: next_proxy() always returns None.
"""
if not proxy_urls:
return lambda key: None
assert threshold > 0, f"threshold must be positive, got {threshold}"
def select_proxy(key: str) -> str:
return proxy_urls[hash(key) % len(proxy_urls)]
lock = threading.Lock()
state = {
"consecutive_failures": 0,
"fallback_active": False,
}
primary_cycle = itertools.cycle(primary_urls) if primary_urls else None
fallback_cycle = itertools.cycle(fallback_urls) if fallback_urls else None
# No primary proxies — skip circuit breaker, use fallback directly
if not primary_urls:
state["fallback_active"] = True
def next_proxy() -> str | None:
with lock:
if state["fallback_active"]:
return next(fallback_cycle) if fallback_cycle else None
return next(primary_cycle) if primary_cycle else None
def record_success() -> None:
with lock:
state["consecutive_failures"] = 0
def record_failure() -> bool:
"""Increment failure counter. Returns True if circuit just opened."""
with lock:
if state["fallback_active"]:
# Already on fallback — don't trip the circuit again
return False
state["consecutive_failures"] += 1
if state["consecutive_failures"] >= threshold:
state["fallback_active"] = True
if fallback_urls:
logger.warning(
"Circuit open after %d consecutive failures — "
"switching to fallback residential proxies",
state["consecutive_failures"],
)
else:
logger.error(
"Circuit open after %d consecutive failures — "
"no fallback configured, aborting run",
state["consecutive_failures"],
)
return True
return False
def is_fallback_active() -> bool:
with lock:
return state["fallback_active"]
return {
"next_proxy": next_proxy,
"record_success": record_success,
"record_failure": record_failure,
"is_fallback_active": is_fallback_active,
}
return select_proxy

View File

@@ -7,7 +7,9 @@ if you add multiple data sources, extract them to a shared workspace package.
import gzip
import hashlib
import json
import sqlite3
import threading
from pathlib import Path
# ---------------------------------------------------------------------------
@@ -117,6 +119,50 @@ def content_hash(data: bytes, prefix_bytes: int = 8) -> str:
return hashlib.sha256(data).hexdigest()[:prefix_bytes]
def load_partial_results(partial_path: Path, id_key: str) -> tuple[list[dict], set[str]]:
"""Load already-completed records from a partial JSONL file (crash recovery).
Returns (records, seen_ids). If the file doesn't exist, returns ([], set()).
Gracefully handles a truncated last line from a mid-write crash.
"""
records: list[dict] = []
seen_ids: set[str] = set()
if not partial_path.exists():
return records, seen_ids
with open(partial_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
records.append(record)
rid = record.get(id_key)
if rid:
seen_ids.add(rid)
except json.JSONDecodeError:
break # truncated last line from crash — skip it
return records, seen_ids
def flush_partial_batch(
partial_file,
lock: threading.Lock,
batch: list[dict],
) -> None:
"""Thread-safe batch write of JSON records to the partial JSONL file.
Writes all records in one lock acquisition with a single flush.
Call with batches of ~50 records for good I/O throughput vs crash safety tradeoff.
On crash, at most one batch worth of records is lost.
"""
assert batch, "batch must not be empty"
with lock:
for record in batch:
partial_file.write(json.dumps(record, separators=(",", ":")) + "\n")
partial_file.flush()
def write_gzip_atomic(path: Path, data: bytes) -> int:
"""Gzip compress data and write to path atomically via .tmp sibling.
@@ -128,3 +174,23 @@ def write_gzip_atomic(path: Path, data: bytes) -> int:
tmp.write_bytes(compressed)
tmp.rename(path)
return len(compressed)
def compress_jsonl_atomic(jsonl_path: Path, dest_path: Path) -> int:
"""Compress a JSONL working file to .jsonl.gz atomically, then delete the source.
Streams compression in 1MB chunks (constant memory regardless of file size).
Atomic via .tmp rename — readers never see a partial .jsonl.gz.
Deletes the uncompressed working file after successful compression.
Returns compressed bytes written.
"""
assert jsonl_path.exists(), f"source must exist: {jsonl_path}"
assert jsonl_path.stat().st_size > 0, f"source must not be empty: {jsonl_path}"
tmp = dest_path.with_suffix(dest_path.suffix + ".tmp")
with open(jsonl_path, "rb") as f_in, gzip.open(tmp, "wb") as f_out:
while chunk := f_in.read(1_048_576): # 1 MB chunks
f_out.write(chunk)
bytes_written = tmp.stat().st_size
tmp.rename(dest_path)
jsonl_path.unlink()
return bytes_written

View File

@@ -29,5 +29,5 @@ depends_on = ["playtomic_tenants"]
[playtomic_recheck]
module = "padelnomics_extract.playtomic_availability"
entry = "main_recheck"
schedule = "0 6-23 * * *"
schedule = "0,30 6-23 * * *"
depends_on = ["playtomic_availability"]

View File

@@ -0,0 +1,101 @@
"""Create minimal landing zone seed files so SQLMesh models can run before real data arrives.
Each seed contains one null/empty record that is filtered out by the staging model's
WHERE clause. Seeds live in the 1970/01 epoch so they're never confused with real data.
Usage:
uv run python scripts/init_landing_seeds.py [--landing-dir data/landing]
Idempotent: skips existing files.
"""
import argparse
import gzip
import json
from pathlib import Path
def create_seed(dest: Path, content: bytes) -> None:
"""Write content to a gzip file atomically. Skips if the file already exists."""
if dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".tmp")
with gzip.open(tmp, "wb") as f:
f.write(content)
tmp.rename(dest)
print(f" created: {dest}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--landing-dir", default="data/landing", type=Path)
args = parser.parse_args()
base: Path = args.landing_dir
seeds = {
# --- Playtomic tenants ---
# JSONL: one null tenant (filtered by WHERE tenant_id IS NOT NULL)
"playtomic/1970/01/tenants.jsonl.gz":
b'{"tenant_id":null}\n',
# Blob: empty tenants array
"playtomic/1970/01/tenants.json.gz":
json.dumps({"tenants": [], "count": 0}).encode(),
# --- Playtomic availability (morning) ---
# JSONL: one null venue (filtered by WHERE tenant_id IS NOT NULL)
"playtomic/1970/01/availability_1970-01-01.jsonl.gz":
b'{"tenant_id":null,"date":"1970-01-01","captured_at_utc":"1970-01-01T00:00:00Z","slots":null}\n',
# Blob: empty venues array
"playtomic/1970/01/availability_1970-01-01.json.gz":
json.dumps({"date": "1970-01-01", "captured_at_utc": "1970-01-01T00:00:00Z",
"venue_count": 0, "venues": []}).encode(),
# --- Playtomic recheck (blob only, small format) ---
"playtomic/1970/01/availability_1970-01-01_recheck_00.json.gz":
json.dumps({"date": "1970-01-01", "captured_at_utc": "1970-01-01T00:00:00Z",
"recheck_hour": 0, "venues": []}).encode(),
# --- GeoNames ---
# JSONL: one null city (filtered by WHERE geoname_id IS NOT NULL)
"geonames/1970/01/cities_global.jsonl.gz":
b'{"geoname_id":null}\n',
# Blob: empty rows array
"geonames/1970/01/cities_global.json.gz":
json.dumps({"rows": [], "count": 0}).encode(),
# --- Overpass tennis ---
# JSONL: one null element (filtered by WHERE type IS NOT NULL)
"overpass_tennis/1970/01/courts.jsonl.gz":
b'{"type":null,"id":null}\n',
# Blob: empty elements array
"overpass_tennis/1970/01/courts.json.gz":
json.dumps({"version": 0.6, "elements": []}).encode(),
# --- Overpass padel (unchanged format) ---
"overpass/1970/01/courts.json.gz":
json.dumps({"version": 0.6, "elements": []}).encode(),
# --- Eurostat ---
"eurostat/1970/01/urb_cpop1.json.gz":
json.dumps({"rows": [], "count": 0}).encode(),
"eurostat/1970/01/ilc_di03.json.gz":
json.dumps({"rows": [], "count": 0}).encode(),
"eurostat_city_labels/1970/01/cities_codelist.json.gz":
json.dumps({"rows": [], "count": 0}).encode(),
# --- National statistics ---
"ons_uk/1970/01/lad_population.json.gz":
json.dumps({"rows": [], "count": 0}).encode(),
"census_usa/1970/01/acs5_places.json.gz":
json.dumps({"rows": [], "count": 0}).encode(),
}
print(f"Initialising landing seeds in: {base}")
for rel_path, content in seeds.items():
create_seed(base / rel_path, content)
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -24,8 +24,12 @@ Usage:
uv run python -m padelnomics.export_serving
"""
import json
import logging
import os
import re
from datetime import UTC, datetime
from pathlib import Path
import duckdb
@@ -44,6 +48,8 @@ def export_serving() -> None:
# (rename across filesystems is not atomic on Linux).
tmp_path = os.path.join(os.path.dirname(os.path.abspath(serving_path)), "_export.duckdb")
table_counts: dict[str, int] = {}
src = duckdb.connect(pipeline_path, read_only=True)
try:
# SQLMesh creates serving views that reference "local".sqlmesh__serving.*
@@ -60,7 +66,6 @@ def export_serving() -> None:
for view_name, view_sql in view_rows:
# Pattern: ... FROM "local".sqlmesh__serving.serving__name__hash;
# Strip the "local". prefix to get schema.table
import re
match = re.search(r'FROM\s+"local"\.(sqlmesh__serving\.\S+)', view_sql)
assert match, f"Cannot parse view definition for {view_name}: {view_sql[:200]}"
physical_tables.append((view_name, match.group(1)))
@@ -81,6 +86,7 @@ def export_serving() -> None:
dst.execute(f"CREATE OR REPLACE TABLE serving.{logical_name} AS SELECT * FROM _src")
dst.unregister("_src")
row_count = dst.sql(f"SELECT count(*) FROM serving.{logical_name}").fetchone()[0]
table_counts[logical_name] = row_count
logger.info(f" serving.{logical_name}: {row_count:,} rows")
finally:
dst.close()
@@ -91,6 +97,16 @@ def export_serving() -> None:
os.rename(tmp_path, serving_path)
logger.info(f"Serving DB atomically updated: {serving_path}")
# Write freshness metadata so the pSEO dashboard can show data age without
# querying file mtimes (which are unreliable after rclone syncs).
meta_path = Path(serving_path).parent / "_serving_meta.json"
meta = {
"exported_at_utc": datetime.now(tz=UTC).isoformat(),
"tables": {name: {"row_count": count} for name, count in table_counts.items()},
}
meta_path.write_text(json.dumps(meta))
logger.info("Wrote serving metadata: %s", meta_path)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")

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,182 @@
-- 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,
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

@@ -3,12 +3,17 @@
-- "Available" = the slot was NOT booked at capture time. Missing slots = booked.
--
-- Reads BOTH morning snapshots and recheck files:
-- Morning: availability_{date}.json.gz → snapshot_type = 'morning'
-- Morning (new): availability_{date}.jsonl.gz → snapshot_type = 'morning'
-- Morning (old): availability_{date}.json.gz → snapshot_type = 'morning'
-- Recheck: availability_{date}_recheck_{HH}.json.gz → snapshot_type = 'recheck'
--
-- Only 60-min duration slots are kept (canonical hourly rate + occupancy unit).
-- Price parsed from strings like "14.56 EUR" or "48 GBP".
--
-- Supports two morning landing formats (UNION ALL during migration):
-- New: availability_{date}.jsonl.gz — one venue per line, columns: tenant_id, slots, date, captured_at_utc
-- Old: availability_{date}.json.gz — {"date":..., "venues": [...]} blob (UNNEST required)
--
-- Requires: at least one availability file in the landing zone.
-- A seed file (data/landing/playtomic/1970/01/availability_1970-01-01.json.gz)
-- with empty venues[] ensures this model runs before real data arrives.
@@ -20,12 +25,41 @@ MODEL (
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
);
-- Morning snapshots (filename does NOT contain '_recheck_')
WITH morning_files AS (
WITH
-- New format: one venue per JSONL line — no outer UNNEST needed
morning_jsonl AS (
SELECT
*,
date AS snapshot_date,
captured_at_utc,
'morning' AS snapshot_type,
NULL::INTEGER AS recheck_hour
NULL::INTEGER AS recheck_hour,
tenant_id,
slots AS slots_json
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
format = 'newline_delimited',
columns = {
date: 'VARCHAR',
captured_at_utc: 'VARCHAR',
tenant_id: 'VARCHAR',
slots: 'JSON'
},
filename = true
)
WHERE filename NOT LIKE '%_recheck_%'
AND tenant_id IS NOT NULL
),
-- Old format: {"date":..., "venues": [...]} blob — kept for transition
morning_blob AS (
SELECT
af.date AS snapshot_date,
af.captured_at_utc,
'morning' AS snapshot_type,
NULL::INTEGER AS recheck_hour,
venue_json ->> 'tenant_id' AS tenant_id,
venue_json -> 'slots' AS slots_json
FROM (
SELECT date, captured_at_utc, venues
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*.json.gz',
format = 'auto',
@@ -40,17 +74,22 @@ WITH morning_files AS (
WHERE filename NOT LIKE '%_recheck_%'
AND venues IS NOT NULL
AND json_array_length(venues) > 0
) af,
LATERAL UNNEST(af.venues) AS t(venue_json)
),
-- Recheck snapshots (filename contains '_recheck_')
-- Use TRY_CAST on a regex-extracted hour to get the recheck_hour.
-- If no recheck files exist yet, this CTE produces zero rows (safe).
recheck_files AS (
-- Recheck snapshots (blob format only — small files, no JSONL conversion needed)
recheck_blob AS (
SELECT
*,
rf.date AS snapshot_date,
rf.captured_at_utc,
'recheck' AS snapshot_type,
TRY_CAST(
regexp_extract(filename, '_recheck_(\d+)', 1) AS INTEGER
) AS recheck_hour
regexp_extract(rf.filename, '_recheck_(\d+)', 1) AS INTEGER
) AS recheck_hour,
venue_json ->> 'tenant_id' AS tenant_id,
venue_json -> 'slots' AS slots_json
FROM (
SELECT date, captured_at_utc, venues, filename
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.json.gz',
format = 'auto',
@@ -64,33 +103,27 @@ recheck_files AS (
)
WHERE venues IS NOT NULL
AND json_array_length(venues) > 0
) rf,
LATERAL UNNEST(rf.venues) AS t(venue_json)
),
all_files AS (
SELECT date, captured_at_utc, venues, snapshot_type, recheck_hour FROM morning_files
all_venues AS (
SELECT * FROM morning_jsonl
UNION ALL
SELECT date, captured_at_utc, venues, snapshot_type, recheck_hour FROM recheck_files
),
raw_venues AS (
SELECT
af.date AS snapshot_date,
af.captured_at_utc,
af.snapshot_type,
af.recheck_hour,
venue_json
FROM all_files af,
LATERAL UNNEST(af.venues) AS t(venue_json)
SELECT * FROM morning_blob
UNION ALL
SELECT * FROM recheck_blob
),
raw_resources AS (
SELECT
rv.snapshot_date,
rv.captured_at_utc,
rv.snapshot_type,
rv.recheck_hour,
rv.venue_json ->> 'tenant_id' AS tenant_id,
av.snapshot_date,
av.captured_at_utc,
av.snapshot_type,
av.recheck_hour,
av.tenant_id,
resource_json
FROM raw_venues rv,
FROM all_venues av,
LATERAL UNNEST(
from_json(rv.venue_json -> 'slots', '["JSON"]')
from_json(av.slots_json, '["JSON"]')
) AS t(resource_json)
),
raw_slots AS (

View File

@@ -5,8 +5,11 @@
-- DuckDB auto-infers opening_hours as STRUCT, so we access each day by literal
-- key (no dynamic access) and UNION ALL to unpivot.
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
-- Each tenant has opening_hours: {MONDAY: {opening_time, closing_time}, ...}
-- Supports two landing formats (UNION ALL during migration):
-- New: tenants.jsonl.gz — one tenant per line, opening_hours is a top-level JSON column
-- Old: tenants.json.gz — {"tenants": [...]} blob (UNNEST required)
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.{jsonl,json}.gz
MODEL (
name staging.stg_playtomic_opening_hours,
@@ -15,7 +18,22 @@ MODEL (
grain (tenant_id, day_of_week)
);
WITH venues AS (
WITH
-- New format: one tenant per JSONL line
jsonl_venues AS (
SELECT
tenant_id,
opening_hours AS oh
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/tenants.jsonl.gz',
format = 'newline_delimited',
columns = {tenant_id: 'VARCHAR', opening_hours: 'JSON'}
)
WHERE tenant_id IS NOT NULL
AND opening_hours IS NOT NULL
),
-- Old format: blob
blob_venues AS (
SELECT
tenant ->> 'tenant_id' AS tenant_id,
tenant -> 'opening_hours' AS oh
@@ -30,6 +48,11 @@ WITH venues AS (
WHERE (tenant ->> 'tenant_id') IS NOT NULL
AND (tenant -> 'opening_hours') IS NOT NULL
),
venues AS (
SELECT * FROM jsonl_venues
UNION ALL
SELECT * FROM blob_venues
),
-- Unpivot by UNION ALL — 7 literal key accesses
unpivoted AS (
SELECT tenant_id, 'MONDAY' AS day_of_week, 1 AS day_number,

View File

@@ -1,9 +1,12 @@
-- Individual court (resource) records from Playtomic venues.
-- Reads resources array from the landing zone JSON directly (double UNNEST:
-- tenants → resources) to extract court type, size, surface, and booking config.
-- Reads resources array from the landing zone to extract court type, size,
-- surface, and booking config.
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
-- Each tenant has a resources[] array of court objects.
-- Supports two landing formats (UNION ALL during migration):
-- New: tenants.jsonl.gz — one tenant per line, resources is a top-level JSON column
-- Old: tenants.json.gz — {"tenants": [...]} blob (double UNNEST: tenants → resources)
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.{jsonl,json}.gz
MODEL (
name staging.stg_playtomic_resources,
@@ -12,22 +15,42 @@ MODEL (
grain (tenant_id, resource_id)
);
WITH raw AS (
WITH
-- New format: one tenant per JSONL line — single UNNEST for resources
jsonl_unnested AS (
SELECT
tenant_id,
UPPER(address ->> 'country_code') AS country_code,
UNNEST(from_json(resources, '["JSON"]')) AS resource_json
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/tenants.jsonl.gz',
format = 'newline_delimited',
columns = {tenant_id: 'VARCHAR', address: 'JSON', resources: 'JSON'}
)
WHERE tenant_id IS NOT NULL
AND resources IS NOT NULL
),
-- Old format: blob — double UNNEST (tenants → resources)
blob_unnested AS (
SELECT
tenant ->> 'tenant_id' AS tenant_id,
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
UNNEST(from_json(tenant -> 'resources', '["JSON"]')) AS resource_json
FROM (
SELECT UNNEST(tenants) AS tenant
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
format = 'auto',
maximum_object_size = 134217728
)
),
unnested AS (
SELECT
tenant ->> 'tenant_id' AS tenant_id,
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
UNNEST(from_json(tenant -> 'resources', '["JSON"]')) AS resource_json
FROM raw
)
WHERE (tenant ->> 'tenant_id') IS NOT NULL
AND (tenant -> 'resources') IS NOT NULL
),
unnested AS (
SELECT * FROM jsonl_unnested
UNION ALL
SELECT * FROM blob_unnested
)
SELECT
tenant_id,

View File

@@ -1,10 +1,13 @@
-- Playtomic padel venue records — full metadata extraction.
-- Reads landing zone JSON, unnests tenant array, extracts all venue metadata
-- Reads landing zone tenants files, extracts all venue metadata
-- including address, opening hours, court resources, VAT rate, and facilities.
-- Deduplicates on tenant_id (keeps most recent extraction).
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
-- Format: {"tenants": [{tenant_id, tenant_name, address, resources, opening_hours, ...}]}
-- Supports two landing formats (UNION ALL during migration):
-- New: tenants.jsonl.gz — one tenant JSON object per line (no UNNEST needed)
-- Old: tenants.json.gz — {"tenants": [{...}]} blob (UNNEST required)
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.{jsonl,json}.gz
MODEL (
name staging.stg_playtomic_venues,
@@ -13,9 +16,52 @@ MODEL (
grain tenant_id
);
WITH parsed AS (
WITH
-- New format: one tenant per JSONL line — no UNNEST, access columns directly
jsonl_parsed AS (
SELECT
tenant_id,
tenant_name,
slug,
tenant_type,
tenant_status,
playtomic_status,
booking_type,
address ->> 'street' AS street,
address ->> 'city' AS city,
address ->> 'postal_code' AS postal_code,
UPPER(address ->> 'country_code') AS country_code,
address ->> 'timezone' AS timezone,
address ->> 'administrative_area' AS administrative_area,
TRY_CAST(address -> 'coordinate' ->> 'lat' AS DOUBLE) AS lat,
TRY_CAST(address -> 'coordinate' ->> 'lon' AS DOUBLE) AS lon,
TRY_CAST(vat_rate AS DOUBLE) AS vat_rate,
default_currency,
TRY_CAST(booking_settings ->> 'booking_ahead_limit' AS INTEGER) AS booking_ahead_limit_minutes,
opening_hours AS opening_hours_json,
resources AS resources_json,
created_at,
CAST(is_playtomic_partner AS VARCHAR) AS is_playtomic_partner_raw,
filename AS source_file,
CURRENT_DATE AS extracted_date
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/tenants.jsonl.gz',
format = 'newline_delimited',
filename = true,
columns = {
tenant_id: 'VARCHAR', tenant_name: 'VARCHAR', slug: 'VARCHAR',
tenant_type: 'VARCHAR', tenant_status: 'VARCHAR', playtomic_status: 'VARCHAR',
booking_type: 'VARCHAR', address: 'JSON', vat_rate: 'DOUBLE',
default_currency: 'VARCHAR', booking_settings: 'JSON',
opening_hours: 'JSON', resources: 'JSON',
created_at: 'VARCHAR', is_playtomic_partner: 'VARCHAR'
}
)
WHERE tenant_id IS NOT NULL
),
-- Old format: {"tenants": [...]} blob — keep for transition until old files rotate out
blob_parsed AS (
SELECT
-- Identity
tenant ->> 'tenant_id' AS tenant_id,
tenant ->> 'tenant_name' AS tenant_name,
tenant ->> 'slug' AS slug,
@@ -23,8 +69,6 @@ WITH parsed AS (
tenant ->> 'tenant_status' AS tenant_status,
tenant ->> 'playtomic_status' AS playtomic_status,
tenant ->> 'booking_type' AS booking_type,
-- Address
tenant -> 'address' ->> 'street' AS street,
tenant -> 'address' ->> 'city' AS city,
tenant -> 'address' ->> 'postal_code' AS postal_code,
@@ -33,22 +77,13 @@ WITH parsed AS (
tenant -> 'address' ->> 'administrative_area' AS administrative_area,
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lat' AS DOUBLE) AS lat,
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lon' AS DOUBLE) AS lon,
-- Commercial
TRY_CAST(tenant ->> 'vat_rate' AS DOUBLE) AS vat_rate,
tenant ->> 'default_currency' AS default_currency,
-- Booking settings (venue-level)
TRY_CAST(tenant -> 'booking_settings' ->> 'booking_ahead_limit' AS INTEGER) AS booking_ahead_limit_minutes,
-- Opening hours and resources stored as JSON for downstream models
tenant -> 'opening_hours' AS opening_hours_json,
tenant -> 'resources' AS resources_json,
-- Metadata
tenant ->> 'created_at' AS created_at,
tenant ->> 'is_playtomic_partner' AS is_playtomic_partner_raw,
filename AS source_file,
CURRENT_DATE AS extracted_date
FROM (
@@ -62,6 +97,11 @@ WITH parsed AS (
)
WHERE (tenant ->> 'tenant_id') IS NOT NULL
),
parsed AS (
SELECT * FROM jsonl_parsed
UNION ALL
SELECT * FROM blob_parsed
),
deduped AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY source_file DESC) AS rn

View File

@@ -1,8 +1,13 @@
-- 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
-- Supports two landing formats (UNION ALL during migration):
-- New: cities_global.jsonl.gz — one city per line, columns directly accessible
-- Old: cities_global.json.gz — {"rows": [...]} blob (UNNEST required)
--
-- Source: data/landing/geonames/{year}/{month}/cities_global.{jsonl,json}.gz
MODEL (
name staging.stg_population_geonames,
@@ -11,11 +16,41 @@ MODEL (
grain geoname_id
);
WITH parsed AS (
WITH
-- New format: one city per JSONL line
jsonl_rows AS (
SELECT
TRY_CAST(geoname_id AS INTEGER) AS geoname_id,
city_name,
country_code,
TRY_CAST(lat AS DOUBLE) AS lat,
TRY_CAST(lon AS DOUBLE) AS lon,
admin1_code,
admin2_code,
TRY_CAST(population AS BIGINT) AS population,
TRY_CAST(ref_year AS INTEGER) AS ref_year,
CURRENT_DATE AS extracted_date
FROM read_json(
@LANDING_DIR || '/geonames/*/*/cities_global.jsonl.gz',
format = 'newline_delimited',
columns = {
geoname_id: 'INTEGER', city_name: 'VARCHAR', country_code: 'VARCHAR',
lat: 'DOUBLE', lon: 'DOUBLE', admin1_code: 'VARCHAR', admin2_code: 'VARCHAR',
population: 'BIGINT', ref_year: 'INTEGER'
}
)
WHERE geoname_id IS NOT NULL
),
-- Old format: {"rows": [...]} blob — kept for transition
blob_rows AS (
SELECT
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
@@ -23,20 +58,32 @@ WITH parsed AS (
SELECT UNNEST(rows) AS row
FROM read_json(
@LANDING_DIR || '/geonames/*/*/cities_global.json.gz',
auto_detect = true
auto_detect = true,
maximum_object_size = 40000000
)
)
WHERE (row ->> 'geoname_id') IS NOT NULL
),
all_rows AS (
SELECT * FROM jsonl_rows
UNION ALL
SELECT * FROM blob_rows
)
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
FROM parsed
FROM all_rows
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,113 @@
-- 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.
--
-- Supports two landing formats (UNION ALL during migration):
-- New: courts.jsonl.gz — one OSM element per line; nodes have lat/lon directly,
-- ways/relations have center.lat/center.lon (Overpass out center)
-- Old: courts.json.gz — {"elements": [...]} blob (UNNEST required)
--
-- Source: data/landing/overpass_tennis/{year}/{month}/courts.{jsonl,json}.gz
MODEL (
name staging.stg_tennis_courts,
kind FULL,
cron '@daily',
grain osm_id
);
WITH
-- New format: one OSM element per JSONL line
jsonl_elements AS (
SELECT
type AS osm_type,
TRY_CAST(id AS BIGINT) AS osm_id,
-- Nodes: lat/lon direct. Ways/relations: center object (Overpass out center).
COALESCE(
TRY_CAST(lat AS DOUBLE),
TRY_CAST(center ->> 'lat' AS DOUBLE)
) AS lat,
COALESCE(
TRY_CAST(lon AS DOUBLE),
TRY_CAST(center ->> 'lon' AS DOUBLE)
) AS lon,
tags ->> 'name' AS name,
tags ->> 'addr:country' AS country_code,
tags ->> 'addr:city' AS city_tag,
filename AS source_file,
CURRENT_DATE AS extracted_date
FROM read_json(
@LANDING_DIR || '/overpass_tennis/*/*/courts.jsonl.gz',
format = 'newline_delimited',
columns = {
type: 'VARCHAR', id: 'BIGINT', lat: 'DOUBLE', lon: 'DOUBLE',
center: 'JSON', tags: 'JSON'
},
filename = true
)
WHERE type IS NOT NULL
),
-- Old format: {"elements": [...]} blob — kept for transition
blob_elements 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
),
parsed AS (
SELECT * FROM jsonl_elements
UNION ALL
SELECT * FROM blob_elements
),
deduped AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY osm_id ORDER BY extracted_date DESC) AS rn
FROM parsed
WHERE 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

175
uv.lock generated
View File

@@ -641,6 +641,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[[package]]
name = "granian"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/e5/c3a745a2c60cba6e67c5607fe6e18883fd2b7800fd7215511c526fab3872/granian-2.7.1.tar.gz", hash = "sha256:cc79292b24895db9441d32c3a9f11a4e19805d566bc77f9deb7ef18daac62e16", size = 128508, upload-time = "2026-02-08T20:02:31.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/fd/44b8027007de2558d09ff7ee688229ad5d4f368bb166589a2547926057e4/granian-2.7.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bbcdea802c5a594d204b807de6829a7d4b723c397087857ca4d3a3cf2ac1d16e", size = 6447686, upload-time = "2026-02-08T20:00:41.829Z" },
{ url = "https://files.pythonhosted.org/packages/e8/b6/db0b26c9226490fb42d51fa70fd08e8daf5ad9747d60d2dc143dd2517b3d/granian-2.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b1abc6dfe5d5fb1f2e863200ee9edf749ed82ff9c1361c21483b214a91654879", size = 6154446, upload-time = "2026-02-08T20:00:44.1Z" },
{ url = "https://files.pythonhosted.org/packages/2b/1b/44d8acdfda1a1af2c4fa8ba215912bd78318b59f195c5b7831dab69a7719/granian-2.7.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:edf7cbab2c54a3dd10c0f8a737b133cc605b6309acdfe3aa060bc954d7ae13c5", size = 7144519, upload-time = "2026-02-08T20:00:45.504Z" },
{ url = "https://files.pythonhosted.org/packages/be/ac/6e142e3a26c3fe90d7e6592256ed4940e696f4430933d597e4014b5ee441/granian-2.7.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5240510754712cc802ad5a71507f10efdb83a043dbccd351662897f58916a76a", size = 6353689, upload-time = "2026-02-08T20:00:46.766Z" },
{ url = "https://files.pythonhosted.org/packages/37/49/1836d259060ceae6cf1dc7d0c424864786ac028c93aaeed07f6ea9dfcafc/granian-2.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2c445c13fa6fc7235f95c28f2d203369d0c516aba15ba24faad08ca0a095bd0", size = 6906248, upload-time = "2026-02-08T20:00:48.15Z" },
{ url = "https://files.pythonhosted.org/packages/5c/84/0d18018b05652991c8502da2cbab6b9b8c234926870d0458d2d7c5124a65/granian-2.7.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71776d7319906cfc78f723cc38f927ffaf58bcb9b1707fe5d88c3662827aa1f7", size = 6974742, upload-time = "2026-02-08T20:00:49.636Z" },
{ url = "https://files.pythonhosted.org/packages/19/83/f9c3685681aa4b41feb73def9ef63800b6f639629e9b083a0c279583fb92/granian-2.7.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab6da78f0fcecf9a9177db2d716e50214b540cb1ea77dafc88e35184ca901266", size = 7030837, upload-time = "2026-02-08T20:00:51.464Z" },
{ url = "https://files.pythonhosted.org/packages/30/62/c445c0c96552f11dee49d002d4af32adbeca19b7e8064a1d106952810345/granian-2.7.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:025218f8ccc5907bc8277b0df9a60927a5862ee607606cfc970cc404d5346af6", size = 7313823, upload-time = "2026-02-08T20:00:53.787Z" },
{ url = "https://files.pythonhosted.org/packages/c2/bc/c9d1dce0b2d11bf76aadd06608d3b01a2b697c030c5ea01474d15e36e2af/granian-2.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:98ab412772f2c66260a3535da4101ccc6dd20de30e74a87b32fd7abc729cc14f", size = 7014570, upload-time = "2026-02-08T20:00:55.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/51/2abe731a4ec42038a0ea24695bd6fd79d4b340797115bd1af40c21cfd1a3/granian-2.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:ba65410b56d951d9aa2e8b0b0f7796431052c43eca2bb8a526a743d2f8aa539f", size = 4058148, upload-time = "2026-02-08T20:00:56.678Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/4a29e3b654ad38b0a7b1fb477a20a1d03b36a40060d15bd98f43654aac3a/granian-2.7.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d603c53a8d7e6243a5c4b9749116143f4a6184033777451ba376b038905ac57f", size = 6390662, upload-time = "2026-02-08T20:00:57.999Z" },
{ url = "https://files.pythonhosted.org/packages/ab/38/bf86291a04d1d4fd7b469b0134224cdb0cafa4e7cc8de5744f79d045ff5c/granian-2.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:df3e8f617dc3e21e3a4e543678993e855fb1d008f1207c646d27efd45e45161b", size = 6126936, upload-time = "2026-02-08T20:00:59.352Z" },
{ url = "https://files.pythonhosted.org/packages/fe/69/09eea196a4f9883dad20d4acd645be35242c0004ba4a698f73f9e0fe8291/granian-2.7.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b1007f1b58e4ace682d424789dd34b63526a482ba3efc01ca18098b65420d6d", size = 7120523, upload-time = "2026-02-08T20:01:00.731Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/db6b3504a41e222a1d94417995f73fa17a27dc2fc664c29295dfc34bd64b/granian-2.7.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76b1751c5d5dcc93803e37baf68396dba22d809001037faec4b2df8fdc52af7e", size = 6420419, upload-time = "2026-02-08T20:01:02.189Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ef/5d6712ad81e85841d4fd5436f5cbfcdb3ac3ddeb9e75953fd6b323bfff64/granian-2.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a02a150c6a1ba8a7123634a22c0352a116ea2211e634479e9f64409db72d4489", size = 6895176, upload-time = "2026-02-08T20:01:03.748Z" },
{ url = "https://files.pythonhosted.org/packages/90/f5/bd0fafc93f01f345ad1ecc70fbb459e452c777fe8b4958020399332b7f03/granian-2.7.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:24f09f5dbb9105498e521733e5993135fb276e346ce8f04cead2f4113ca51bba", size = 7002315, upload-time = "2026-02-08T20:01:05.071Z" },
{ url = "https://files.pythonhosted.org/packages/af/ff/b17d357d4f1eff19ff45257ea924bb571d4cf2caefccdc8aca8c0b1a3c7a/granian-2.7.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:cc435c5d1881554bf7eb4e2fe8d2ad7e5052a0bacc7195c477bfc97544c7bf46", size = 7018969, upload-time = "2026-02-08T20:01:06.564Z" },
{ url = "https://files.pythonhosted.org/packages/d4/68/e0e24673e943fbb2540a7cd68dd3ea10a4cd9db6f538de9cec26b1c54133/granian-2.7.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:68a136b5d7ade34f3ee5ee743b2bdd55d6c1f0249c6bfdc8e038c6d0846de61e", size = 7274801, upload-time = "2026-02-08T20:01:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/42/ee/cda1e8eb3e7025d82b6594814fc2f95ce252f638691240e4bc523924e204/granian-2.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93100bd3185e653c482c2996e11a7ece58ea28e355ef335bb0a30e4851c3ae8c", size = 7032826, upload-time = "2026-02-08T20:01:09.538Z" },
{ url = "https://files.pythonhosted.org/packages/ca/48/2c89fa53f5cdbc8495f55d587f3fa24f9ff984a8c572dd8930aa991e4301/granian-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:6cab79a863ccf6f18aa8b5e9261865d87c28574cd85174e8bb1bab873220077d", size = 4076284, upload-time = "2026-02-08T20:01:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/53/ce/e8ae26e248daaa8e782c0e6bce1350759da262f8aa637b8a0036c5455376/granian-2.7.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b4f0c807fefedfa58d07c2751cc40471765387d331e70ea7ebd2a2ff5d492ca0", size = 6384691, upload-time = "2026-02-08T20:01:12.389Z" },
{ url = "https://files.pythonhosted.org/packages/c2/5f/32f933dac26835ad2f8bc9b4f5762be8f8340318a9bbeca75b32fa6f6195/granian-2.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:78ce501ec337b7db52ba1773c0acf0abd72b3fac71b6b747fe4ae6f38cca0a6b", size = 6128567, upload-time = "2026-02-08T20:01:14.64Z" },
{ url = "https://files.pythonhosted.org/packages/b4/04/432b73f713ebb102e1585f5abec9cb2284d76f4d16df73c24f2e4dcc9cbd/granian-2.7.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2079c9c29b65404283ef61ced11905c8491e4bc68a4e3b56c684fe2dab8cf8c2", size = 7129893, upload-time = "2026-02-08T20:01:16.526Z" },
{ url = "https://files.pythonhosted.org/packages/63/5e/fdd4e42c800804cc277f12a3eba51747d100739b8beb0c1a909837670d86/granian-2.7.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a68bf02c93c2137c68e2acd1dc68e871f49ce2e61b042fec9a145104daf3d5b", size = 6428486, upload-time = "2026-02-08T20:01:18.024Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/7a5632e1a206e11ac3470f9ef79b2aadce67d1dfc5cdf75a5fd9795ae0fa/granian-2.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de3f44b244600103a3ad6358937a42370b8cc518b7754c740620be681272e0bd", size = 6888218, upload-time = "2026-02-08T20:01:20.393Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ef/379b77fc6f8909ffc4d9397135b122d93446f303f52e428aca1120d79b08/granian-2.7.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7681e76c61af0dd1e135139f5fa9561ec16fdbac19d0a9fbf4617079b822bf21", size = 7007452, upload-time = "2026-02-08T20:01:21.864Z" },
{ url = "https://files.pythonhosted.org/packages/4e/49/6849f1f784186f41551ceba040e4402d7daa7a9c5c89e0b4c0fb7df5d73e/granian-2.7.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:bc56766803ec0f958f4f2e3be9f4cb2385f9d6970e34ade6ff5c0ba751a3ce9c", size = 7024506, upload-time = "2026-02-08T20:01:23.24Z" },
{ url = "https://files.pythonhosted.org/packages/52/85/dcbc5b860697e1ebf9fa4206d3fba931a2ea2547fb8d2638ad392f4d5a90/granian-2.7.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a20eaf1b756981caa8c0d6c19c5467e03386aadb07f854b88243218c9db9513b", size = 7289505, upload-time = "2026-02-08T20:01:24.59Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0f/3ddd893a4582943ab21c59853b7a6adae837130445ad64964cd73ea77ce4/granian-2.7.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:af842b07f14d7433774627c16fb0fbcdc9e60587d2d684636d2eba446c343297", size = 7022894, upload-time = "2026-02-08T20:01:25.973Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/52173568f8da3a2d50f48eabe1cc19d857586e0878009477ed0c196ebebd/granian-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:861d69fc3504c891f152585c2109d1eaf791c35392b13ed22c72fb199dc50dfa", size = 4093077, upload-time = "2026-02-08T20:01:27.735Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/3e0ea25a85a05618363ac9f90eb4e504ccc00e48c64f30cd37ef7046097f/granian-2.7.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:33ed73fe753fcae51a647555614fc67013558a654d323115ab0fbf60aca6c47a", size = 6354066, upload-time = "2026-02-08T20:01:29.268Z" },
{ url = "https://files.pythonhosted.org/packages/46/8d/a8965de519507ba5dfa13af4760b3c1b334e46bf3283eab55f171693de0a/granian-2.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de9367e2dca2923bf12b52f004ab975ed0de45c8dedddd87993ed9fffabfb0ce", size = 6049800, upload-time = "2026-02-08T20:01:30.989Z" },
{ url = "https://files.pythonhosted.org/packages/21/f6/ff76aab55b5a7bdbd20f4f73486fcb5a09440f4fd56bd3dc6266e65dee9a/granian-2.7.1-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:38088f6bd4780b280aae8abf15c2205bdf9066def927f8c9690c13a966519286", size = 6219241, upload-time = "2026-02-08T20:01:32.311Z" },
{ url = "https://files.pythonhosted.org/packages/c8/3a/7aaf34391df169d54bcc3bfc32919b58de9b8a9e28e66b4f3276b910ef68/granian-2.7.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ebbc04483ada6e1a8a89f055de0b4cad2f90b3cbc94a1ae08fc2b140d905f4b", size = 7114695, upload-time = "2026-02-08T20:01:33.748Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d3/540a9f816884abf4da62d2e411455968a1ee8e4685243d3dd7fee1cf375f/granian-2.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2acc29a3eb9b1b9708355abd5438c216caff4ba4536bc77e46b19e44fb1b37ea", size = 6775127, upload-time = "2026-02-08T20:01:35.925Z" },
{ url = "https://files.pythonhosted.org/packages/82/62/d133c36fdab4552db665d6bb2d53ac4834e41a97d8d0244f1aacc03e188f/granian-2.7.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:bec493af655645e58e6d89c7e37eb7751e9bf827506286e765d79a5c4ff10a3f", size = 6847644, upload-time = "2026-02-08T20:01:37.282Z" },
{ url = "https://files.pythonhosted.org/packages/21/4a/619d699acd3cd37de048ab606a85021f5edf42bd54c7f081d20dccd48041/granian-2.7.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:dc51944736d5683b255b7cd33581daf8bc44ae1dab31240e1969eca13d1e75cf", size = 7011427, upload-time = "2026-02-08T20:01:38.858Z" },
{ url = "https://files.pythonhosted.org/packages/91/25/389eea98109e4b85e443fae384b30ff67167f27f4df6fb43d26cd151d0dc/granian-2.7.1-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:f787bbcb06ca605ff4161a04078591b2269b628165214ab913084e7fdb5ab9d8", size = 7261453, upload-time = "2026-02-08T20:01:40.355Z" },
{ url = "https://files.pythonhosted.org/packages/ac/00/75180d71994b87c0b56385c1b60c93b73b8822ed8edba2c63f72b0f836b6/granian-2.7.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:52163a3b609489bcb614e45811e2a66a6780b1459bbbc29504de13c23a115112", size = 7039030, upload-time = "2026-02-08T20:01:41.758Z" },
{ url = "https://files.pythonhosted.org/packages/d3/11/a913af3c65debb5e5d577d3cb5ac988313c05c19fca789e167375ee432df/granian-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b2cd2118353db7f06fee0aefdada9e109434e030ac2fdc8f691b669787680d2e", size = 4066745, upload-time = "2026-02-08T20:01:43.161Z" },
{ url = "https://files.pythonhosted.org/packages/41/c1/cc5c0abc5c573a8832c584f52c98f7882119fe81d52a49285800e25d993f/granian-2.7.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a677972bb9050ec15896452f2c299b56f15e01212c1185d9373b92348fd88930", size = 6397999, upload-time = "2026-02-08T20:01:44.515Z" },
{ url = "https://files.pythonhosted.org/packages/e8/77/5248e8cf1c25f080959c0a4e4a8039107b0b2bf67a9fc8904cfe57614a24/granian-2.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ee4b404425a135274ab69513fdc1883ce954beef22113058e6e2a25d89926e68", size = 6108572, upload-time = "2026-02-08T20:01:45.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/a0/fa0b961d7c9b1c2f046a58b85ffe1e7bc5d3a7fcc8c947bdd6fd397a312c/granian-2.7.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c4eaf3b0c1602a2ef75a8e418bb6d2867994e7ac246ea6833f7b812289d038c", size = 7101910, upload-time = "2026-02-08T20:01:47.773Z" },
{ url = "https://files.pythonhosted.org/packages/ca/70/edd388b12ebecde4edbbf4d62cd78ed6e5ae0f6b834e88de2fe06e6f948e/granian-2.7.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d593d80f568b2025a227a9b0bf664db94c9423069b27c120e288a2350507a4d8", size = 6399861, upload-time = "2026-02-08T20:01:49.594Z" },
{ url = "https://files.pythonhosted.org/packages/74/18/6e8962f1be1a578841e9c68bb8f3a416c30880003c3180a1e6b852ad1717/granian-2.7.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab94be605aaf90968fd04fb527f1b2790f6815dd0e9690586adb4a9be1f25010", size = 6951789, upload-time = "2026-02-08T20:01:51.115Z" },
{ url = "https://files.pythonhosted.org/packages/eb/47/9f07664d847653115b196f70594016de8fd7629e5aa1645d6d20f771cf14/granian-2.7.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:392f6cc3eb7a5039a815a823c3f468161b4eb179d061450c0ec843cef0eb1b54", size = 6983541, upload-time = "2026-02-08T20:01:52.693Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c6/08b9203a4f897a31810bb18344b5ecaf26eb34135916c257c14ec762eb51/granian-2.7.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff063c417ee16fadca3c534e2059a6cf47e1df2607f1c6012be4ea6486b814f5", size = 7032652, upload-time = "2026-02-08T20:01:54.336Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/c7a5c595313432a5373e6014980a77d8f028f24f31b68406af97ace94fe6/granian-2.7.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:1a59ed88c40896db96a973e49a5ba2a2f84d7569c1da8cf11c685d11bffc2ef1", size = 7254611, upload-time = "2026-02-08T20:01:55.74Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/fe283eeb7a2f525472bd6ef2b0c6b7fb95d4369902b75d8e7e252628e62e/granian-2.7.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:773ff347d4541634e8c50b82b532eefa68c0043cda100bd44712b88565a5495b", size = 7110307, upload-time = "2026-02-08T20:01:57.117Z" },
{ url = "https://files.pythonhosted.org/packages/61/ea/b6901c64cac1fc3b455acdba279d80454fe963eca314ebfaf4e2eec9933c/granian-2.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:089f8a0d6d6a215f6773aa9dfdb56ec349d28840203517e7a7933485b1a1f404", size = 4122834, upload-time = "2026-02-08T20:01:58.682Z" },
{ url = "https://files.pythonhosted.org/packages/5c/41/bd76745d2fd2e2735390037324cb2d2b2f934473d77fb27f176494f5b2f3/granian-2.7.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e25c7dedd9325e11bda1d9692f25314791d24ae39b8206fb858f18a57087f2ee", size = 6376497, upload-time = "2026-02-08T20:02:00.117Z" },
{ url = "https://files.pythonhosted.org/packages/40/ea/bdb388e3e24308e92c370674d225e819eee6740dd440d6450860039b934a/granian-2.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:387c6032d46191deaf18819f15988e98d0f5c85eef09efb28c4c4b7b8b0dc2d2", size = 6092395, upload-time = "2026-02-08T20:02:01.75Z" },
{ url = "https://files.pythonhosted.org/packages/31/9c/438da7d5c66ed2c9df1c5946485e464fd52a420217212e0c9b5bb90f8e93/granian-2.7.1-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d1b5b47a34ab0f47f8bd447894412b4d9bdcb2011fbb9d1b8f7890c8442d233", size = 6226387, upload-time = "2026-02-08T20:02:03.185Z" },
{ url = "https://files.pythonhosted.org/packages/91/1a/f317272d59618a846a0c7ea019ab0352d947e8afdae40faea580b98600c7/granian-2.7.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7f65cd1d46c8ee454b0f29743340bebc170c1da2af83bd759fb02d69c24c7e9", size = 7123367, upload-time = "2026-02-08T20:02:04.721Z" },
{ url = "https://files.pythonhosted.org/packages/d0/63/0c0c0005798c808082ae72b6bc3ccc1282d1b078375b060c5477aabbe407/granian-2.7.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d12b93e6467fc079b38e104154d5e5625a5e7c6a1776a59039c1e5fb57e0fe3", size = 6709311, upload-time = "2026-02-08T20:02:06.266Z" },
{ url = "https://files.pythonhosted.org/packages/e6/27/73655570644b3e727b22e3cf4239eebe90c18d1d3c868fc3d71e4d50dd46/granian-2.7.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ff4aba223bfeca0c6bc8f64ef03d87d04aff36515b2fd91108e5c9f55e67a5ee", size = 6802243, upload-time = "2026-02-08T20:02:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/23/00/2b9655d05f14bee4cd4080f3a18f0f0f4e7014158d7323a1cb0d31ed61cb/granian-2.7.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2cc036b6f7db04ba6750aa86dff17c7930b7f295e4bfc5f35e9231d9f42e8094", size = 6978785, upload-time = "2026-02-08T20:02:09.269Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/deff2560260ddc9a99315ecb345c93485b0b102708838e7c42837c7a6535/granian-2.7.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2834f178ddbe25f077b28eba3b0e3e3814b17a0fc61fe44c17c270eef37ff54", size = 7303589, upload-time = "2026-02-08T20:02:10.81Z" },
{ url = "https://files.pythonhosted.org/packages/d1/52/7fefaf4f1317883e7a5f25a92bca43f914b47d4762ad8f38f48e7e85b2a8/granian-2.7.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:874d4eedc527f9c59dd192e263be8047b86759e71ac9552283d010bcea93993b", size = 6984251, upload-time = "2026-02-08T20:02:12.753Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c1/d6aa049cdbe15b9ffe7964b01cc50efc8ccc067c3a50da7bc5ced1eaf6a4/granian-2.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:d787d9bf1744c275fa60775629e910305aa6395a88a32eea25b0008652ed9fe9", size = 4051984, upload-time = "2026-02-08T20:02:14.325Z" },
{ url = "https://files.pythonhosted.org/packages/d7/72/36d03ed914f70c79583542a60cedfeb7bc2ab992ee75ca5725612c1191a4/granian-2.7.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:97ebda3ae49c181b25b603d32ace5a8d83880c9c52550d3b66a4bf09f3c1b809", size = 6411236, upload-time = "2026-02-08T20:02:16.136Z" },
{ url = "https://files.pythonhosted.org/packages/f7/79/6d734663ea31a1935ae0d835ba12883cdfe63376593918de84ddf1aa26c2/granian-2.7.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8eeb97b4cc403956cdd782da83d30eddbfb90415e850520b6627d207cf06d8db", size = 6120207, upload-time = "2026-02-08T20:02:18.502Z" },
{ url = "https://files.pythonhosted.org/packages/86/40/c6bf30ae2f9feb305b454a2a2118e40bec9dac94cc5c23a9d68f2d054f14/granian-2.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8272952e6c094cdb24e42c9123eb780e789fe28e3f49a80cfce3df1b080ae2d", size = 6926893, upload-time = "2026-02-08T20:02:20.951Z" },
{ url = "https://files.pythonhosted.org/packages/76/00/b2567a14dd68ae1fee1085d60f9ddaa6e93b155c86893804ed2303228f37/granian-2.7.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:32a4414b3ac17eef25d3bc33e2ed4f85150ebed3ef40028d4192bd0a842358c0", size = 7031580, upload-time = "2026-02-08T20:02:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/91/54/4c4aff8f153c3340d0aa26afbeb3db03bc9d7d914905c47705328c2514a8/granian-2.7.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:99b24f1241d142bdcb33c5744e7503b358fbcd899c44e4f48464b2bcaca2bd0f", size = 7097067, upload-time = "2026-02-08T20:02:25.489Z" },
{ url = "https://files.pythonhosted.org/packages/47/82/c1fce66ebeb3d681d4405eee78b9159b230558f8bc99e44456541c03fe7b/granian-2.7.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:f4b715653cc9765c1aea629802c862e030482ff847f5d4c03d5f401830ff617c", size = 7336016, upload-time = "2026-02-08T20:02:26.969Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/6bd215ec3567bcc36defb7cb30a3c03f73f2f56f8a8a34148a24008f94b6/granian-2.7.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:47ef955d06c1cdff1aeb3d4d0ada415359a034295d0f162d7c0a0f98d76d4d6c", size = 7004178, upload-time = "2026-02-08T20:02:28.753Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/c2480b4b4123e22b41bf82fc49e7a3b28cd2274dfa445959a1805f9a603d/granian-2.7.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a9bb46143d77161065cfe1d662f9a758bb17c7e3a2fde178f0a5aaac3fb3a65b", size = 4081455, upload-time = "2026-02-08T20:02:30.271Z" },
]
[package.optional-dependencies]
reload = [
{ name = "watchfiles" },
]
[[package]]
name = "greenlet"
version = "3.3.1"
@@ -1297,8 +1381,8 @@ dependencies = [
{ name = "duckdb" },
{ name = "google-api-python-client" },
{ name = "google-auth" },
{ name = "granian", extra = ["reload"] },
{ name = "httpx" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "mistune" },
@@ -1318,8 +1402,8 @@ requires-dist = [
{ name = "duckdb", specifier = ">=1.0.0" },
{ name = "google-api-python-client", specifier = ">=2.100.0" },
{ name = "google-auth", specifier = ">=2.23.0" },
{ name = "granian", extras = ["reload"], specifier = ">=1.6.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "hypercorn", specifier = ">=0.17.0" },
{ name = "itsdangerous", specifier = ">=2.1.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "mistune", specifier = ">=3.0.0" },
@@ -2643,6 +2727,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/d9/e81c8de18b3edd22e1884ed6b8cfc2ce260addb110fd519781ea54274e38/wassima-2.0.5-py3-none-any.whl", hash = "sha256:e60b567b26b87c83ff310a191d9c584113f13c0bcea0564f92e7630b17da319b", size = 138778, upload-time = "2026-02-07T16:52:32.844Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
]
[[package]]
name = "wcwidth"
version = "0.6.0"

View File

@@ -10,7 +10,7 @@ dependencies = [
"python-dotenv>=1.0.0",
"itsdangerous>=2.1.0",
"jinja2>=3.1.0",
"hypercorn>=0.17.0",
"granian[reload]>=1.6.0",
"paddle-python-sdk>=1.13.0",
"mistune>=3.0.0",
"resend>=2.22.0",

View File

@@ -165,8 +165,8 @@ echo ""
echo "Press Ctrl-C to stop all processes."
echo ""
run_with_label "$COLOR_APP" "app " uv run python -m padelnomics.app
run_with_label "$COLOR_WORKER" "worker" uv run python -m padelnomics.worker
run_with_label "$COLOR_APP" "app " uv run granian --interface asgi --host 127.0.0.1 --port 5000 --reload --reload-paths web/src padelnomics.app:app
run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker
run_with_label "$COLOR_CSS" "css " make css-watch
wait

View File

@@ -0,0 +1,209 @@
"""
pSEO Engine admin blueprint.
Operational visibility for the programmatic SEO system:
/admin/pseo/ → dashboard (template stats, freshness, recent jobs)
/admin/pseo/health → HTMX partial: health issues
/admin/pseo/gaps/<slug> → HTMX partial: content gaps for one template
/admin/pseo/gaps/<slug>/generate → POST: enqueue gap-fill job
/admin/pseo/jobs → recent generation jobs
/admin/pseo/jobs/<id>/status → HTMX polled: progress bar for one job
Registered as a standalone blueprint so admin/routes.py (already ~2,100 lines)
stays focused on its own domain.
"""
from datetime import date
from pathlib import Path
from quart import Blueprint, flash, redirect, render_template, url_for
from ..auth.routes import role_required
from ..content import discover_templates, load_template
from ..content.health import (
get_all_health_issues,
get_content_gaps,
get_template_freshness,
get_template_stats,
)
from ..core import csrf_protect, fetch_all, fetch_one
bp = Blueprint(
"pseo",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/admin/pseo",
)
@bp.before_request
async def _inject_sidebar_data():
"""Load unread inbox count for the admin sidebar badge."""
from quart import g
try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
except Exception:
g.admin_unread_count = 0
@bp.context_processor
def _admin_context():
"""Expose admin-specific variables to all pSEO templates."""
from quart import g
return {"unread_count": getattr(g, "admin_unread_count", 0)}
# ── Dashboard ────────────────────────────────────────────────────────────────
@bp.route("/")
@role_required("admin")
async def pseo_dashboard():
"""pSEO Engine dashboard: template stats, freshness, recent jobs."""
templates = discover_templates()
freshness = await get_template_freshness(templates)
freshness_by_slug = {f["slug"]: f for f in freshness}
template_rows = []
for t in templates:
stats = await get_template_stats(t["slug"])
template_rows.append({
"template": t,
"stats": stats,
"freshness": freshness_by_slug.get(t["slug"], {}),
})
total_articles = sum(r["stats"]["total"] for r in template_rows)
total_published = sum(r["stats"]["published"] for r in template_rows)
stale_count = sum(1 for f in freshness if f["status"] == "stale")
# Recent generation jobs — enough for the dashboard summary.
jobs = await fetch_all(
"SELECT id, task_name, status, progress_current, progress_total,"
" error, error_log, created_at, completed_at"
" FROM tasks WHERE task_name = 'generate_articles'"
" ORDER BY created_at DESC LIMIT 5",
)
return await render_template(
"admin/pseo_dashboard.html",
template_rows=template_rows,
total_articles=total_articles,
total_published=total_published,
total_templates=len(templates),
stale_count=stale_count,
jobs=jobs,
admin_page="pseo",
)
# ── Health checks (HTMX partial) ─────────────────────────────────────────────
@bp.route("/health")
@role_required("admin")
async def pseo_health():
"""HTMX partial: all health issue lists."""
templates = discover_templates()
health = await get_all_health_issues(templates)
return await render_template("admin/pseo_health.html", health=health)
# ── Content gaps (HTMX partial + generate action) ────────────────────────────
@bp.route("/gaps/<slug>")
@role_required("admin")
async def pseo_gaps_template(slug: str):
"""HTMX partial: content gaps for a specific template."""
try:
config = load_template(slug)
except (AssertionError, FileNotFoundError):
return "Template not found", 404
gaps = await get_content_gaps(
template_slug=slug,
data_table=config["data_table"],
natural_key=config["natural_key"],
languages=config["languages"],
)
return await render_template(
"admin/pseo_gaps.html",
template=config,
gaps=gaps,
)
@bp.route("/gaps/<slug>/generate", methods=["POST"])
@role_required("admin")
@csrf_protect
async def pseo_generate_gaps(slug: str):
"""Enqueue a generation job limited to filling gaps for this template."""
from ..worker import enqueue
try:
config = load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("pseo.pseo_dashboard"))
gaps = await get_content_gaps(
template_slug=slug,
data_table=config["data_table"],
natural_key=config["natural_key"],
languages=config["languages"],
)
if not gaps:
await flash(f"No gaps found for '{config['name']}' — nothing to generate.", "info")
return redirect(url_for("pseo.pseo_dashboard"))
await enqueue("generate_articles", {
"template_slug": slug,
"start_date": date.today().isoformat(),
"articles_per_day": 500,
"limit": 500,
})
await flash(
f"Queued generation for {len(gaps)} missing articles in '{config['name']}'.",
"success",
)
return redirect(url_for("pseo.pseo_dashboard"))
# ── Generation job monitoring ─────────────────────────────────────────────────
@bp.route("/jobs")
@role_required("admin")
async def pseo_jobs():
"""Full list of recent article generation jobs."""
jobs = await fetch_all(
"SELECT id, task_name, status, progress_current, progress_total,"
" error, error_log, created_at, completed_at"
" FROM tasks WHERE task_name = 'generate_articles'"
" ORDER BY created_at DESC LIMIT 20",
)
return await render_template(
"admin/pseo_jobs.html",
jobs=jobs,
admin_page="pseo",
)
@bp.route("/jobs/<int:job_id>/status")
@role_required("admin")
async def pseo_job_status(job_id: int):
"""HTMX polled endpoint: progress bar for a running generation job."""
job = await fetch_one(
"SELECT id, status, progress_current, progress_total, error, error_log,"
" created_at, completed_at"
" FROM tasks WHERE id = ?",
(job_id,),
)
if not job:
return "Job not found", 404
return await render_template("admin/pseo_job_status.html", job=job)

View File

@@ -2,7 +2,8 @@
Admin domain: role-based admin panel for managing users, tasks, etc.
"""
import json
from datetime import date, datetime, timedelta
import logging
from datetime import date, timedelta
from pathlib import Path
import mistune
@@ -29,7 +30,12 @@ from ..core import (
fetch_one,
send_email,
slugify,
utcnow,
utcnow_iso,
)
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
logger = logging.getLogger(__name__)
# Blueprint with its own template folder
bp = Blueprint(
@@ -48,6 +54,7 @@ async def _inject_admin_sidebar_data():
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
except Exception:
logger.exception("Failed to load admin sidebar unread count")
g.admin_unread_count = 0
@@ -64,9 +71,9 @@ def _admin_context():
async def get_dashboard_stats() -> dict:
"""Get admin dashboard statistics."""
now = datetime.utcnow()
now = utcnow()
today = now.date().isoformat()
week_ago = (now - timedelta(days=7)).isoformat()
week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL")
users_today = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
@@ -211,7 +218,7 @@ async def retry_task(task_id: int) -> bool:
SET status = 'pending', run_at = ?, error = NULL
WHERE id = ? AND status = 'failed'
""",
(datetime.utcnow().isoformat(), task_id)
(utcnow_iso(), task_id)
)
return result > 0
@@ -261,6 +268,23 @@ async def users():
)
@bp.route("/users/results")
@role_required("admin")
async def user_results():
"""HTMX partial for user list (live search)."""
search = request.args.get("search", "").strip()
page = int(request.args.get("page", 1))
per_page = 50
offset = (page - 1) * per_page
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
return await render_template(
"admin/partials/user_results.html",
users=user_list,
search=search,
page=page,
)
@bp.route("/users/<int:user_id>")
@role_required("admin")
async def user_detail(user_id: int):
@@ -357,9 +381,10 @@ HEAT_OPTIONS = ["hot", "warm", "cool"]
async def get_leads(
status: str = None, heat: str = None, country: str = None,
search: str = None, days: int = None,
page: int = 1, per_page: int = 50,
) -> list[dict]:
"""Get leads with optional filters."""
) -> tuple[list[dict], int]:
"""Get leads with optional filters. Returns (leads, total_count)."""
wheres = ["lead_type = 'quote'"]
params: list = []
@@ -372,16 +397,27 @@ async def get_leads(
if country:
wheres.append("country = ?")
params.append(country)
if search:
term = f"%{search}%"
wheres.append("(contact_name LIKE ? OR contact_email LIKE ? OR contact_company LIKE ?)")
params.extend([term, term, term])
if days:
wheres.append("created_at >= datetime('now', ?)")
params.append(f"-{days} days")
where = " AND ".join(wheres)
offset = (page - 1) * per_page
params.extend([per_page, offset])
count_row = await fetch_one(
f"SELECT COUNT(*) as cnt FROM lead_requests WHERE {where}", tuple(params)
)
total = count_row["cnt"] if count_row else 0
return await fetch_all(
offset = (page - 1) * per_page
rows = await fetch_all(
f"""SELECT * FROM lead_requests WHERE {where}
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
tuple(params),
tuple(params) + (per_page, offset),
)
return rows, total
async def get_lead_detail(lead_id: int) -> dict | None:
@@ -403,11 +439,32 @@ async def get_lead_detail(lead_id: int) -> dict | None:
async def get_lead_stats() -> dict:
"""Get lead conversion funnel counts."""
"""Get lead conversion funnel counts + summary card metrics."""
rows = await fetch_all(
"SELECT status, COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' GROUP BY status"
)
return {r["status"]: r["cnt"] for r in rows}
by_status = {r["status"]: r["cnt"] for r in rows}
# Summary card aggregates
agg = await fetch_one(
"""SELECT
COUNT(*) as total,
SUM(CASE WHEN status IN ('new', 'pending_verification') THEN 1 ELSE 0 END) as new_unverified,
SUM(CASE WHEN heat_score = 'hot' AND status = 'new' THEN credit_cost ELSE 0 END) as hot_pipeline,
SUM(CASE WHEN status = 'forwarded' THEN 1 ELSE 0 END) as forwarded
FROM lead_requests WHERE lead_type = 'quote'"""
)
total = agg["total"] or 0
forwarded = agg["forwarded"] or 0
forward_rate = round((forwarded / total) * 100) if total > 0 else 0
return {
**by_status,
"_total": total,
"_new_unverified": agg["new_unverified"] or 0,
"_hot_pipeline": agg["hot_pipeline"] or 0,
"_forward_rate": forward_rate,
}
@bp.route("/leads")
@@ -417,10 +474,15 @@ async def leads():
status = request.args.get("status", "")
heat = request.args.get("heat", "")
country = request.args.get("country", "")
search = request.args.get("search", "")
days_str = request.args.get("days", "")
days = int(days_str) if days_str.isdigit() else None
page = max(1, int(request.args.get("page", "1") or "1"))
per_page = 50
lead_list = await get_leads(
status=status or None, heat=heat or None, country=country or None, page=page,
lead_list, total = await get_leads(
status=status or None, heat=heat or None, country=country or None,
search=search or None, days=days, page=page, per_page=per_page,
)
lead_stats = await get_lead_stats()
@@ -438,7 +500,11 @@ async def leads():
current_status=status,
current_heat=heat,
current_country=country,
current_search=search,
current_days=days_str,
page=page,
per_page=per_page,
total=total,
)
@@ -449,12 +515,28 @@ async def lead_results():
status = request.args.get("status", "")
heat = request.args.get("heat", "")
country = request.args.get("country", "")
search = request.args.get("search", "")
days_str = request.args.get("days", "")
days = int(days_str) if days_str.isdigit() else None
page = max(1, int(request.args.get("page", "1") or "1"))
per_page = 50
lead_list = await get_leads(
status=status or None, heat=heat or None, country=country or None, page=page,
lead_list, total = await get_leads(
status=status or None, heat=heat or None, country=country or None,
search=search or None, days=days, page=page, per_page=per_page,
)
return await render_template(
"admin/partials/lead_results.html",
leads=lead_list,
page=page,
per_page=per_page,
total=total,
current_status=status,
current_heat=heat,
current_country=country,
current_search=search,
current_days=days_str,
)
return await render_template("admin/partials/lead_results.html", leads=lead_list)
@bp.route("/leads/<int:lead_id>")
@@ -505,11 +587,18 @@ async def lead_new():
contact_name = form.get("contact_name", "").strip()
contact_email = form.get("contact_email", "").strip()
facility_type = form.get("facility_type", "indoor")
build_context = form.get("build_context", "")
glass_type = form.get("glass_type", "")
lighting_type = form.get("lighting_type", "")
court_count = int(form.get("court_count", 6) or 6)
country = form.get("country", "")
city = form.get("city", "").strip()
location_status = form.get("location_status", "")
timeline = form.get("timeline", "")
budget_estimate = int(form.get("budget_estimate", 0) or 0)
financing_status = form.get("financing_status", "")
services_needed = form.get("services_needed", "").strip()
additional_info = form.get("additional_info", "").strip()
stakeholder_type = form.get("stakeholder_type", "")
heat_score = form.get("heat_score", "warm")
status = form.get("status", "new")
@@ -522,19 +611,23 @@ async def lead_new():
from ..credits import HEAT_CREDIT_COSTS
credit_cost = HEAT_CREDIT_COSTS.get(heat_score, 8)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
verified_at = now if status != "pending_verification" else None
lead_id = await execute(
"""INSERT INTO lead_requests
(lead_type, facility_type, court_count, country, location, timeline,
budget_estimate, stakeholder_type, heat_score, status,
(lead_type, facility_type, build_context, glass_type, lighting_type,
court_count, country, location, location_status, timeline,
budget_estimate, financing_status, services_needed, additional_info,
stakeholder_type, heat_score, status,
contact_name, contact_email, contact_phone, contact_company,
credit_cost, verified_at, created_at)
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
facility_type, court_count, country, city, timeline,
budget_estimate, stakeholder_type, heat_score, status,
facility_type, build_context, glass_type, lighting_type,
court_count, country, city, location_status, timeline,
budget_estimate, financing_status, services_needed, additional_info,
stakeholder_type, heat_score, status,
contact_name, contact_email,
form.get("contact_phone", ""), form.get("contact_company", ""),
credit_cost, verified_at, now,
@@ -567,7 +660,7 @@ async def lead_forward(lead_id: int):
await flash("Already forwarded to this supplier.", "warning")
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
VALUES (?, ?, 0, 'sent', ?)""",
@@ -589,6 +682,175 @@ async def lead_forward(lead_id: int):
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
@bp.route("/leads/<int:lead_id>/status-htmx", methods=["POST"])
@role_required("admin")
@csrf_protect
async def lead_status_htmx(lead_id: int):
"""HTMX: Update lead status, return updated status badge partial."""
form = await request.form
new_status = form.get("status", "")
if new_status not in LEAD_STATUSES:
return Response("Invalid status", status=422)
await execute(
"UPDATE lead_requests SET status = ? WHERE id = ?", (new_status, lead_id)
)
return await render_template(
"admin/partials/lead_status_badge.html", status=new_status, lead_id=lead_id,
)
@bp.route("/leads/<int:lead_id>/forward-htmx", methods=["POST"])
@role_required("admin")
@csrf_protect
async def lead_forward_htmx(lead_id: int):
"""HTMX: Forward lead to supplier, return updated forward history partial."""
form = await request.form
supplier_id_str = form.get("supplier_id", "")
if not supplier_id_str.isdigit():
return Response("Select a supplier.", status=422)
supplier_id = int(supplier_id_str)
existing = await fetch_one(
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
(lead_id, supplier_id),
)
if existing:
return Response("Already forwarded to this supplier.", status=422)
now = utcnow_iso()
await execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
VALUES (?, ?, 0, 'sent', ?)""",
(lead_id, supplier_id, now),
)
await execute(
"UPDATE lead_requests SET unlock_count = unlock_count + 1, status = 'forwarded' WHERE id = ?",
(lead_id,),
)
from ..worker import enqueue
await enqueue("send_lead_forward_email", {"lead_id": lead_id, "supplier_id": supplier_id})
lead = await get_lead_detail(lead_id)
return await render_template(
"admin/partials/lead_forward_history.html",
forwards=lead["forwards"] if lead else [],
)
@bp.route("/marketplace")
@role_required("admin")
async def marketplace_dashboard():
"""Marketplace health dashboard."""
# Lead funnel
funnel = await fetch_one(
"""SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'new' AND verified_at IS NOT NULL THEN 1 ELSE 0 END) as verified_new,
SUM(CASE WHEN status = 'forwarded' THEN 1 ELSE 0 END) as forwarded_count,
SUM(CASE WHEN status = 'closed_won' THEN 1 ELSE 0 END) as won_count
FROM lead_requests WHERE lead_type = 'quote'"""
)
total = funnel["total"] or 0
won = funnel["won_count"] or 0
conversion_rate = round((won / total) * 100, 1) if total > 0 else 0
unlocked_count = (await fetch_one(
"SELECT COUNT(DISTINCT lead_id) as cnt FROM lead_forwards"
) or {}).get("cnt", 0)
# Credit economy
credit_agg = await fetch_one(
"""SELECT
SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END) as total_issued,
SUM(CASE WHEN event_type = 'lead_unlock' THEN ABS(delta) ELSE 0 END) as total_consumed,
SUM(CASE WHEN event_type = 'lead_unlock'
AND created_at >= datetime('now', '-30 days')
THEN ABS(delta) ELSE 0 END) as monthly_burn
FROM credit_ledger"""
)
outstanding = (await fetch_one(
"SELECT SUM(credit_balance) as bal FROM suppliers WHERE tier != 'free'"
) or {}).get("bal", 0) or 0
# Supplier engagement
supplier_agg = await fetch_one(
"""SELECT
COUNT(*) as active_count,
ROUND(AVG(unlock_count), 1) as avg_unlocks
FROM (
SELECT s.id, COUNT(lf.id) as unlock_count
FROM suppliers s
LEFT JOIN lead_forwards lf ON lf.supplier_id = s.id
WHERE s.tier != 'free' AND s.credit_balance > 0
GROUP BY s.id
)"""
)
response_agg = await fetch_one(
"""SELECT
COUNT(*) as total,
SUM(CASE WHEN status != 'sent' THEN 1 ELSE 0 END) as responded
FROM lead_forwards"""
)
resp_total = (response_agg or {}).get("total", 0) or 0
resp_responded = (response_agg or {}).get("responded", 0) or 0
response_rate = round((resp_responded / resp_total) * 100) if resp_total > 0 else 0
# Feature flags
flags = await fetch_all(
"SELECT name, enabled FROM feature_flags WHERE name IN ('lead_unlock', 'supplier_signup')"
)
flag_map = {f["name"]: bool(f["enabled"]) for f in flags}
return await render_template(
"admin/marketplace.html",
funnel={
"total": total,
"verified_new": funnel["verified_new"] or 0,
"unlocked": unlocked_count,
"won": won,
"conversion_rate": conversion_rate,
},
credits={
"issued": (credit_agg or {}).get("total_issued", 0) or 0,
"consumed": (credit_agg or {}).get("total_consumed", 0) or 0,
"outstanding": outstanding,
"monthly_burn": (credit_agg or {}).get("monthly_burn", 0) or 0,
},
suppliers={
"active": (supplier_agg or {}).get("active_count", 0) or 0,
"avg_unlocks": (supplier_agg or {}).get("avg_unlocks", 0) or 0,
"response_rate": response_rate,
},
flags=flag_map,
)
@bp.route("/marketplace/activity")
@role_required("admin")
async def marketplace_activity():
"""HTMX: Recent marketplace activity stream."""
rows = await fetch_all(
"""SELECT 'lead' as event_type, id as ref_id, NULL as ref2_id,
contact_name as actor, status as detail,
country as extra, created_at
FROM lead_requests WHERE lead_type = 'quote'
UNION ALL
SELECT 'unlock' as event_type, lf.lead_id as ref_id, lf.supplier_id as ref2_id,
s.name as actor, lf.status as detail,
CAST(lf.credit_cost AS TEXT) as extra, lf.created_at
FROM lead_forwards lf
JOIN suppliers s ON s.id = lf.supplier_id
UNION ALL
SELECT 'credit' as event_type, id as ref_id, supplier_id as ref2_id,
s.name as actor, cl.event_type as detail,
CAST(cl.delta AS TEXT) as extra, cl.created_at
FROM credit_ledger cl
JOIN suppliers s ON s.id = cl.supplier_id
ORDER BY created_at DESC LIMIT 50"""
)
return await render_template("admin/partials/marketplace_activity.html", events=rows)
# =============================================================================
# Supplier Management
# =============================================================================
@@ -771,7 +1033,7 @@ async def supplier_new():
instagram_url = form.get("instagram_url", "").strip()
youtube_url = form.get("youtube_url", "").strip()
now = datetime.utcnow().isoformat()
now = utcnow_iso()
supplier_id = await execute(
"""INSERT INTO suppliers
(name, slug, country_code, city, region, website, description, category,
@@ -865,14 +1127,15 @@ async def flag_toggle():
return redirect(url_for("admin.flags"))
new_enabled = 0 if row["enabled"] else 1
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?",
(new_enabled, now, flag_name),
)
state = "enabled" if new_enabled else "disabled"
await flash(f"Flag '{flag_name}' {state}.", "success")
return redirect(url_for("admin.flags"))
next_url = form.get("next", "") or url_for("admin.flags")
return redirect(next_url)
# =============================================================================
@@ -940,7 +1203,7 @@ async def get_email_stats() -> dict:
total = await fetch_one("SELECT COUNT(*) as cnt FROM email_log")
delivered = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'delivered'")
bounced = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'bounced'")
today = datetime.utcnow().date().isoformat()
today = utcnow().date().isoformat()
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
return {
"total": total["cnt"] if total else 0,
@@ -986,6 +1249,45 @@ async def emails():
)
@bp.route("/emails/gallery")
@role_required("admin")
async def email_gallery():
"""Gallery of all email template types with sample previews."""
return await render_template(
"admin/email_gallery.html",
registry=EMAIL_TEMPLATE_REGISTRY,
)
@bp.route("/emails/gallery/<slug>")
@role_required("admin")
async def email_gallery_preview(slug: str):
"""Rendered preview of a single email template with sample data."""
entry = EMAIL_TEMPLATE_REGISTRY.get(slug)
if not entry:
await flash(f"Unknown email template: {slug!r}", "error")
return redirect(url_for("admin.email_gallery"))
lang = request.args.get("lang", "en")
if lang not in ("en", "de"):
lang = "en"
try:
sample = entry["sample_data"](lang)
rendered_html = render_email_template(entry["template"], lang=lang, **sample)
except Exception:
logger.exception("email_gallery_preview: render failed for %s (lang=%s)", slug, lang)
rendered_html = "<p style='padding:2rem;color:#DC2626;'>Render error — see logs.</p>"
return await render_template(
"admin/email_gallery_preview.html",
slug=slug,
entry=entry,
lang=lang,
rendered_html=rendered_html,
)
@bp.route("/emails/results")
@role_required("admin")
async def email_results():
@@ -1022,12 +1324,21 @@ async def email_detail(email_id: int):
else:
enriched_html = getattr(result, "html", "")
except Exception:
pass # Metadata-only fallback
logger.warning("Failed to fetch email body from Resend for %s", email["resend_id"], exc_info=True)
related_lead = await fetch_one(
"SELECT id FROM lead_requests WHERE contact_email = ? LIMIT 1", (email["to_addr"],)
)
related_supplier = await fetch_one(
"SELECT id, name FROM suppliers WHERE contact_email = ? LIMIT 1", (email["to_addr"],)
)
return await render_template(
"admin/email_detail.html",
email=email,
enriched_html=enriched_html,
related_lead=related_lead,
related_supplier=related_supplier,
)
@@ -1127,10 +1438,16 @@ async def email_compose():
email_addresses=EMAIL_ADDRESSES,
)
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>"
if wrap:
from ..worker import _email_wrap
html = _email_wrap(html)
html = render_email_template(
"emails/admin_compose.html",
lang="en",
body_html=body_html,
preheader="",
)
else:
html = body_html
result = await send_email(
to=to, subject=subject, html=html,
@@ -1147,8 +1464,39 @@ async def email_compose():
email_addresses=EMAIL_ADDRESSES,
)
prefill_to = request.args.get("to", "")
return await render_template(
"admin/email_compose.html", data={}, email_addresses=EMAIL_ADDRESSES,
"admin/email_compose.html", data={"to": prefill_to}, email_addresses=EMAIL_ADDRESSES,
)
@bp.route("/emails/compose/preview", methods=["POST"])
@role_required("admin")
async def compose_preview():
"""HTMX endpoint: render live preview for compose textarea (no CSRF — read-only)."""
form = await request.form
body = form.get("body", "").strip()
wrap = form.get("wrap", "") == "1"
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>" if body else ""
if wrap and body_html:
try:
rendered_html = render_email_template(
"emails/admin_compose.html",
lang="en",
body_html=body_html,
preheader="",
)
except Exception:
logger.exception("compose_preview: template render failed")
rendered_html = body_html
else:
rendered_html = body_html
return await render_template(
"admin/partials/email_preview_frame.html",
rendered_html=rendered_html,
)
@@ -1158,9 +1506,11 @@ async def email_compose():
@role_required("admin")
async def audiences():
"""List Resend audiences with local cache + API contact counts."""
audience_list = await fetch_all("SELECT * FROM resend_audiences ORDER BY name")
# Cap at 20 — Resend free plan limit is 3 audiences, paid is more but still
# small. One API call per audience is unavoidable (no bulk contacts endpoint).
audience_list = await fetch_all("SELECT * FROM resend_audiences ORDER BY name LIMIT 20")
# Enrich with contact count from API (best-effort)
# Enrich with contact count from API (best-effort, one call per audience)
for a in audience_list:
a["contact_count"] = None
if config.RESEND_API_KEY and a.get("audience_id"):
@@ -1175,7 +1525,7 @@ async def audiences():
data = getattr(contacts, "data", [])
a["contact_count"] = len(data) if data else 0
except Exception:
pass
logger.warning("Failed to fetch contact count for audience %s", a.get("audience_id"), exc_info=True)
return await render_template("admin/audiences.html", audiences=audience_list)
@@ -1201,6 +1551,7 @@ async def audience_contacts(audience_id: str):
else:
contacts = getattr(result, "data", []) or []
except Exception:
logger.exception("Failed to fetch contacts from Resend for audience %s", audience_id)
await flash("Failed to fetch contacts from Resend.", "error")
return await render_template(
@@ -1239,21 +1590,20 @@ async def audience_contact_remove(audience_id: str):
@role_required("admin")
async def templates():
"""List content templates scanned from disk."""
from ..content import discover_templates, fetch_template_data
from ..content import count_template_data, discover_templates
template_list = discover_templates()
# Attach DuckDB row counts
for t in template_list:
count_rows = await fetch_template_data(t["data_table"], limit=501)
t["data_count"] = len(count_rows)
# Count generated articles for this template
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?",
(t["slug"],),
# Single query: article counts for all templates — avoids N SQLite round-trips
counts_raw = await fetch_all(
"SELECT template_slug, COUNT(*) as cnt FROM articles GROUP BY template_slug"
)
t["generated_count"] = row["cnt"] if row else 0
article_counts = {r["template_slug"]: r["cnt"] for r in counts_raw}
# One DuckDB COUNT(*) per template (N queries, but cheap vs SELECT * LIMIT 501)
for t in template_list:
t["data_count"] = await count_template_data(t["data_table"])
t["generated_count"] = article_counts.get(t["slug"], 0)
return await render_template("admin/templates.html", templates=template_list)
@@ -1392,14 +1742,74 @@ SCENARIO_FORM_FIELDS = [
]
async def _query_scenarios(search: str, country: str, venue_type: str) -> tuple[list, int]:
"""Execute filtered scenario query. Returns (rows, total_count)."""
wheres = ["1=1"]
params: list = []
if search:
wheres.append("(title LIKE ? OR location LIKE ? OR slug LIKE ?)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if country:
wheres.append("country = ?")
params.append(country)
if venue_type:
wheres.append("venue_type = ?")
params.append(venue_type)
where = " AND ".join(wheres)
rows = await fetch_all(
f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC LIMIT 500",
tuple(params),
)
total_row = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios")
return rows, (total_row["cnt"] if total_row else 0)
@bp.route("/scenarios")
@role_required("admin")
async def scenarios():
"""List published scenarios."""
scenario_list = await fetch_all(
"SELECT * FROM published_scenarios ORDER BY created_at DESC"
"""List published scenarios with optional filters."""
search = request.args.get("search", "").strip()
country_filter = request.args.get("country", "")
venue_filter = request.args.get("venue_type", "")
scenario_list, total = await _query_scenarios(search, country_filter, venue_filter)
countries = await fetch_all(
"SELECT DISTINCT country FROM published_scenarios WHERE country != '' ORDER BY country"
)
venue_types = await fetch_all(
"SELECT DISTINCT venue_type FROM published_scenarios WHERE venue_type != '' ORDER BY venue_type"
)
return await render_template(
"admin/scenarios.html",
scenarios=scenario_list,
countries=[r["country"] for r in countries],
venue_types=[r["venue_type"] for r in venue_types],
total=total,
current_search=search,
current_country=country_filter,
current_venue_type=venue_filter,
is_generating=await _is_generating(),
)
@bp.route("/scenarios/results")
@role_required("admin")
async def scenario_results():
"""HTMX partial for scenario results (used by live polling)."""
search = request.args.get("search", "").strip()
country_filter = request.args.get("country", "")
venue_filter = request.args.get("venue_type", "")
scenario_list, total = await _query_scenarios(search, country_filter, venue_filter)
return await render_template(
"admin/partials/scenario_results.html",
scenarios=scenario_list,
total=total,
is_generating=await _is_generating(),
)
return await render_template("admin/scenarios.html", scenarios=scenario_list)
@bp.route("/scenarios/new", methods=["GET", "POST"])
@@ -1487,7 +1897,7 @@ async def scenario_edit(scenario_id: int):
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"""UPDATE published_scenarios
@@ -1640,14 +2050,22 @@ async def _get_article_stats() -> dict:
row = await fetch_one(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END) AS live,
SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END) AS scheduled,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft
COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live,
COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled,
COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft
FROM articles"""
)
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
async def _is_generating() -> bool:
"""Return True if a generate_articles task is currently pending."""
row = await fetch_one(
"SELECT COUNT(*) AS cnt FROM tasks WHERE task_name = 'generate_articles' AND status = 'pending'"
)
return bool(row and row["cnt"] > 0)
@bp.route("/articles")
@role_required("admin")
async def articles():
@@ -1677,6 +2095,7 @@ async def articles():
current_template=template_filter,
current_language=language_filter,
page=page,
is_generating=await _is_generating(),
)
@@ -1695,7 +2114,10 @@ async def article_results():
language=language_filter or None, search=search or None, page=page,
)
return await render_template(
"admin/partials/article_results.html", articles=article_list, page=page,
"admin/partials/article_results.html",
articles=article_list,
page=page,
is_generating=await _is_generating(),
)
@@ -1740,7 +2162,7 @@ async def article_new():
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body)
pub_dt = published_at or datetime.utcnow().isoformat()
pub_dt = published_at or utcnow_iso()
await execute(
"""INSERT INTO articles
@@ -1800,7 +2222,7 @@ async def article_edit(article_id: int):
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article['slug']}.md").write_text(body)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
pub_dt = published_at or article["published_at"]
await execute(
@@ -1867,7 +2289,7 @@ async def article_publish(article_id: int):
return redirect(url_for("admin.articles"))
new_status = "published" if article["status"] == "draft" else "draft"
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
(new_status, now, article_id),

View File

@@ -18,7 +18,7 @@
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Rebuild all articles? This will re-render every article from its template.', this.closest('form'))">Rebuild All</button>
</form>
</div>
</header>
@@ -28,7 +28,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.article_results') }}"
hx-target="#article-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms"
hx-indicator="#articles-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -65,6 +66,11 @@
<option value="de" {% if current_language == 'de' %}selected{% endif %}>DE</option>
</select>
</div>
<svg id="articles-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -30,7 +30,7 @@
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}">
<button type="submit" class="btn-outline btn-sm" style="color:#DC2626" onclick="return confirm('Remove this contact?')">Remove</button>
<button type="button" class="btn-outline btn-sm" style="color:#DC2626" onclick="confirmAction('Remove this contact from the audience?', this.closest('form'))">Remove</button>
</form>
</td>
</tr>

View File

@@ -27,6 +27,15 @@
.admin-main { flex: 1; padding: 2rem; overflow-y: auto; }
#confirm-dialog {
border: none; border-radius: 12px; padding: 1.5rem; max-width: 380px; width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.08);
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0;
}
#confirm-dialog::backdrop { background: rgba(15,23,42,0.45); backdrop-filter: blur(3px); }
#confirm-dialog p { margin: 0 0 1.25rem; font-size: 0.9375rem; color: #0F172A; line-height: 1.55; }
#confirm-dialog .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
@media (max-width: 768px) {
.admin-layout { flex-direction: column; }
.admin-sidebar {
@@ -54,7 +63,11 @@
Dashboard
</a>
<div class="admin-sidebar__section">Leads</div>
<div class="admin-sidebar__section">Marketplace</div>
<a href="{{ url_for('admin.marketplace_dashboard') }}" class="{% if admin_page == 'marketplace' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
Dashboard
</a>
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
Leads
@@ -86,6 +99,12 @@
Templates
</a>
<div class="admin-sidebar__section">pSEO</div>
<a href="{{ url_for('pseo.pseo_dashboard') }}" class="{% if admin_page == 'pseo' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/></svg>
pSEO Engine
</a>
<div class="admin-sidebar__section">Email</div>
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
@@ -99,6 +118,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
Compose
</a>
<a href="{{ url_for('admin.email_gallery') }}" class="{% if admin_page == 'gallery' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z"/></svg>
Gallery
</a>
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
Audiences
@@ -130,4 +153,24 @@
{% block admin_content %}{% endblock %}
</main>
</div>
<dialog id="confirm-dialog">
<p id="confirm-msg"></p>
<div class="dialog-actions">
<button id="confirm-cancel" class="btn-outline btn-sm">Cancel</button>
<button id="confirm-ok" class="btn btn-sm">Confirm</button>
</div>
</dialog>
<script>
function confirmAction(message, form) {
var dialog = document.getElementById('confirm-dialog');
document.getElementById('confirm-msg').textContent = message;
var ok = document.getElementById('confirm-ok');
var newOk = ok.cloneNode(true);
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); form.submit(); });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal();
}
</script>
{% endblock %}

View File

@@ -2,14 +2,28 @@
{% set admin_page = "compose" %}
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.compose-layout { display: grid; grid-template-columns: 480px 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 1100px) { .compose-layout { grid-template-columns: 1fr; } }
.preview-panel { position: sticky; top: 1rem; }
.preview-label { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
#preview-pane { min-height: 400px; background: #F1F5F9; border-radius: 8px; }
#preview-pane.loading { opacity: 0.5; }
</style>
{% endblock %}
{% block admin_content %}
<header class="mb-6">
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">&larr; Sent Log</a>
<h1 class="text-2xl mt-1">Compose Email</h1>
</header>
<div class="card" style="padding:1.5rem;max-width:640px">
<form method="post" action="{{ url_for('admin.email_compose') }}">
<div class="compose-layout">
{# ── Left: form ────────────────────────────────────── #}
<div>
<div class="card" style="padding:1.5rem;">
<form id="compose-form" method="post" action="{{ url_for('admin.email_compose') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
@@ -33,12 +47,28 @@
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
<textarea name="body" rows="12" class="form-input" placeholder="Plain text (line breaks become &lt;br&gt;)" required>{{ data.get('body', '') }}</textarea>
<textarea
name="body" rows="14" class="form-input"
placeholder="Plain text (line breaks become &lt;br&gt;)"
required
hx-post="{{ url_for('admin.compose_preview') }}"
hx-trigger="input delay:500ms, change"
hx-target="#preview-pane"
hx-include="#compose-form [name='wrap']"
hx-indicator="#preview-pane"
>{{ data.get('body', '') }}</textarea>
</div>
<div class="mb-6">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="wrap" value="1" checked>
<input
type="checkbox" name="wrap" value="1"
{% if data.get('wrap', True) %}checked{% endif %}
hx-post="{{ url_for('admin.compose_preview') }}"
hx-trigger="change"
hx-target="#preview-pane"
hx-include="#compose-form textarea[name='body']"
>
Wrap in branded email template
</label>
</div>
@@ -49,4 +79,14 @@
</div>
</form>
</div>
</div>
{# ── Right: live preview panel ─────────────────────── #}
<div class="preview-panel">
<div class="preview-label">Live preview</div>
<div id="preview-pane">
<p style="padding:2rem;font-size:0.875rem;color:#94A3B8;text-align:center;">Start typing to see a preview…</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -14,7 +14,15 @@
<h2 class="text-lg mb-4">Details</h2>
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">To</dt>
<dd>{{ email.to_addr }}</dd>
<dd class="flex items-center gap-2 flex-wrap">
{{ email.to_addr }}
{% if related_lead %}
<a href="{{ url_for('admin.lead_detail', lead_id=related_lead.id) }}" class="badge" style="background:#DBEAFE;color:#1E40AF">Lead #{{ related_lead.id }}</a>
{% endif %}
{% if related_supplier %}
<a href="{{ url_for('admin.supplier_detail', supplier_id=related_supplier.id) }}" class="badge" style="background:#D1FAE5;color:#065F46">{{ related_supplier.name }}</a>
{% endif %}
</dd>
<dt class="text-slate">From</dt>
<dd>{{ email.from_addr }}</dd>
<dt class="text-slate">Subject</dt>

View File

@@ -0,0 +1,81 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "gallery" %}
{% block title %}Email Gallery - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 1100px) { .gallery-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 720px) { .gallery-grid { grid-template-columns: 1fr; } }
.gallery-card {
display: block;
text-decoration: none;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 10px;
padding: 1.25rem 1.25rem 1rem;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
position: relative;
overflow: hidden;
}
.gallery-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: #1D4ED8;
transform: scaleY(0);
transform-origin: bottom;
transition: transform 0.2s ease;
}
.gallery-card:hover {
border-color: #BFDBFE;
box-shadow: 0 4px 16px rgba(29,78,216,0.08);
transform: translateY(-1px);
}
.gallery-card:hover::before { transform: scaleY(1); }
.gallery-card__icon {
width: 36px; height: 36px;
background: #EFF6FF;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 0.875rem;
flex-shrink: 0;
}
.gallery-card__icon svg { width: 18px; height: 18px; color: #1D4ED8; }
.gallery-card__label { font-size: 0.9375rem; font-weight: 600; color: #0F172A; margin-bottom: 0.25rem; }
.gallery-card__desc { font-size: 0.8125rem; color: #64748B; line-height: 1.5; margin-bottom: 0.875rem; }
.gallery-card__cta { font-size: 0.8125rem; font-weight: 500; color: #1D4ED8; }
</style>
{% endblock %}
{% block admin_content %}
<header class="mb-6">
<div style="display:flex;align-items:baseline;gap:1rem;flex-wrap:wrap;">
<h1 class="text-2xl">Email Gallery</h1>
<span style="font-size:0.8125rem;color:#94A3B8;">{{ registry | length }} template{{ 's' if registry | length != 1 else '' }}</span>
</div>
<p style="margin-top:0.25rem;font-size:0.875rem;color:#64748B;">Rendered previews of all transactional email templates with sample data.</p>
</header>
<div class="gallery-grid">
{% for slug, entry in registry.items() %}
<a class="gallery-card" href="{{ url_for('admin.email_gallery_preview', slug=slug) }}">
<div class="gallery-card__icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>
</svg>
</div>
<div class="gallery-card__label">{{ entry.label }}</div>
<div class="gallery-card__desc">{{ entry.description }}</div>
<div class="gallery-card__cta">Preview &rarr;</div>
</a>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "gallery" %}
{% block title %}{{ entry.label }} Preview - Email Gallery - Admin{% endblock %}
{% block admin_head %}
<style>
.preview-toolbar {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.preview-toolbar__title { flex: 1; min-width: 0; }
.lang-toggle { display: flex; gap: 2px; background: #F1F5F9; border-radius: 6px; padding: 2px; }
.lang-toggle a {
padding: 4px 12px; font-size: 0.75rem; font-weight: 600;
border-radius: 4px; text-decoration: none; color: #64748B; transition: all 0.1s;
}
.lang-toggle a.active { background: #FFFFFF; color: #1D4ED8; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.preview-iframe {
width: 100%; height: 700px;
border: 1px solid #E2E8F0; border-radius: 8px;
background: #F1F5F9;
}
</style>
{% endblock %}
{% block admin_content %}
<header class="mb-4">
<a href="{{ url_for('admin.email_gallery') }}" class="text-sm text-slate">&larr; Email Gallery</a>
<h1 class="text-2xl mt-1">{{ entry.label }}</h1>
<p style="margin-top:0.25rem;font-size:0.875rem;color:#64748B;">{{ entry.description }}</p>
</header>
<div class="preview-toolbar">
<div class="preview-toolbar__title">
{% if entry.email_type %}
<a href="{{ url_for('admin.emails') }}?email_type={{ entry.email_type }}"
style="font-size:0.8125rem;color:#1D4ED8;text-decoration:none;">
View in sent log &rarr;
</a>
{% endif %}
</div>
{# Language toggle #}
<div class="lang-toggle">
<a href="{{ url_for('admin.email_gallery_preview', slug=slug, lang='en') }}"
class="{{ 'active' if lang == 'en' else '' }}">EN</a>
<a href="{{ url_for('admin.email_gallery_preview', slug=slug, lang='de') }}"
class="{{ 'active' if lang == 'de' else '' }}">DE</a>
</div>
</div>
<iframe
class="preview-iframe"
srcdoc="{{ rendered_html | e }}"
sandbox="allow-same-origin"
title="{{ entry.label }} email preview"
></iframe>
{% endblock %}

View File

@@ -24,7 +24,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.email_results') }}"
hx-target="#email-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms"
hx-indicator="#emails-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -51,6 +52,11 @@
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
<input type="text" name="search" value="{{ current_search }}" class="form-input" placeholder="Email or subject..." style="min-width:180px">
</div>
<svg id="emails-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -45,7 +45,7 @@
</p>
</div>
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate articles? Existing articles will be updated.')">
<button type="button" class="btn" style="width: 100%;" onclick="confirmAction('Generate articles? Existing articles will be updated in-place.', this.closest('form'))">
Generate Articles
</button>
</form>

View File

@@ -2,122 +2,137 @@
{% set admin_page = "leads" %}
{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.lead-heat-hot { background:#FEE2E2; color:#991B1B; font-weight:600; font-size:0.75rem; padding:3px 10px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
.lead-heat-warm { background:#FEF3C7; color:#92400E; font-weight:600; font-size:0.75rem; padding:3px 10px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
.lead-heat-cool { background:#DBEAFE; color:#1E40AF; font-weight:600; font-size:0.75rem; padding:3px 10px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
.lead-status-new { background:#DBEAFE; color:#1E40AF; }
.lead-status-pending_verification { background:#FEF3C7; color:#92400E; }
.lead-status-contacted { background:#EDE9FE; color:#5B21B6; }
.lead-status-forwarded { background:#D1FAE5; color:#065F46; }
.lead-status-closed_won { background:#D1FAE5; color:#14532D; }
.lead-status-closed_lost { background:#F1F5F9; color:#475569; }
.lead-status-badge {
font-size:0.75rem; font-weight:600; padding:3px 10px; border-radius:9999px;
display:inline-block; white-space:nowrap;
}
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<div>
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a>
<h1 class="text-2xl mt-1">Lead #{{ lead.id }}
{% if lead.heat_score == 'hot' %}<span class="badge-danger">HOT</span>
{% elif lead.heat_score == 'warm' %}<span class="badge-warning">WARM</span>
{% else %}<span class="badge">COOL</span>{% endif %}
</h1>
<div class="flex items-center gap-3 mt-1">
<h1 class="text-2xl">Lead #{{ lead.id }}</h1>
<span class="lead-heat-{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
</div>
<!-- Status update -->
<form method="post" action="{{ url_for('admin.lead_status', lead_id=lead.id) }}" class="flex items-center gap-2">
</div>
<!-- Inline HTMX status update -->
<div id="lead-status-section">
<div class="flex items-center gap-3 mb-3">
<span class="lead-status-badge lead-status-{{ lead.status | replace(' ', '_') }}" style="font-size:0.875rem;padding:4px 12px;">
{{ lead.status | replace('_', ' ') }}
</span>
</div>
<form hx-post="{{ url_for('admin.lead_status_htmx', lead_id=lead.id) }}"
hx-target="#lead-status-section"
hx-swap="outerHTML"
class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="status" class="form-input" style="min-width:140px">
<select name="status" class="form-input" style="min-width:160px">
{% for s in statuses %}
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s }}</option>
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-outline btn-sm">Update</button>
</form>
</div>
</header>
<div class="grid-2" style="gap:1.5rem">
<!-- Project brief -->
<div class="card" style="padding:1.5rem">
<h2 class="text-lg mb-4">Project Brief</h2>
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">Facility</dt>
<dd>{{ lead.facility_type or '-' }}</dd>
<dt class="text-slate">Courts</dt>
<dd>{{ lead.court_count or '-' }}</dd>
<dt class="text-slate">Glass</dt>
<dd>{{ lead.glass_type or '-' }}</dd>
<dt class="text-slate">Lighting</dt>
<dd>{{ lead.lighting_type or '-' }}</dd>
<dt class="text-slate">Build Context</dt>
<dd>{{ lead.build_context or '-' }}</dd>
<dl style="display:grid;grid-template-columns:160px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">Facility</dt> <dd>{{ lead.facility_type or '-' }}</dd>
<dt class="text-slate">Build Context</dt> <dd>{{ lead.build_context or '-' }}</dd>
<dt class="text-slate">Courts</dt> <dd>{{ lead.court_count or '-' }}</dd>
<dt class="text-slate">Glass</dt> <dd>{{ lead.glass_type or '-' }}</dd>
<dt class="text-slate">Lighting</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
<dt class="text-slate">Location</dt>
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
<dt class="text-slate">Timeline</dt>
<dd>{{ lead.timeline or '-' }}</dd>
<dt class="text-slate">Phase</dt>
<dd>{{ lead.location_status or '-' }}</dd>
<dt class="text-slate">Budget</dt>
<dd>{{ lead.budget_estimate or '-' }}</dd>
<dt class="text-slate">Financing</dt>
<dd>{{ lead.financing_status or '-' }}</dd>
<dt class="text-slate">Services</dt>
<dd>{{ lead.services_needed or '-' }}</dd>
<dt class="text-slate">Additional Info</dt>
<dd>{{ lead.additional_info or '-' }}</dd>
<dt class="text-slate">Credit Cost</dt>
<dd>{{ lead.credit_cost or '-' }} credits</dd>
<dd>{{ lead.location or '-' }}{% if lead.country %},
<a href="{{ url_for('admin.leads', country=lead.country) }}" class="text-sm">{{ lead.country }}</a>
{% else %}-{% endif %}</dd>
<dt class="text-slate">Phase</dt> <dd>{{ lead.location_status or '-' }}</dd>
<dt class="text-slate">Timeline</dt> <dd>{{ lead.timeline or '-' }}</dd>
<dt class="text-slate">Budget</dt> <dd>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</dd>
<dt class="text-slate">Financing</dt> <dd>{{ lead.financing_status or '-' }}</dd>
<dt class="text-slate">Services</dt> <dd>{{ lead.services_needed or '-' }}</dd>
<dt class="text-slate">Additional Info</dt><dd>{{ lead.additional_info or '-' }}</dd>
<dt class="text-slate">Credit Cost</dt> <dd>{{ lead.credit_cost or '-' }} credits</dd>
</dl>
</div>
<!-- Contact info -->
<!-- Contact + forward -->
<div>
<div class="card mb-4" style="padding:1.5rem">
<h2 class="text-lg mb-4">Contact</h2>
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">Name</dt>
<dd>{{ lead.contact_name or '-' }}</dd>
<dt class="text-slate">Name</dt> <dd>{{ lead.contact_name or '-' }}</dd>
<dt class="text-slate">Email</dt>
<dd>{{ lead.contact_email or '-' }}</dd>
<dt class="text-slate">Phone</dt>
<dd>{{ lead.contact_phone or '-' }}</dd>
<dt class="text-slate">Company</dt>
<dd>{{ lead.contact_company or '-' }}</dd>
<dt class="text-slate">Role</dt>
<dd>{{ lead.stakeholder_type or '-' }}</dd>
<dt class="text-slate">Created</dt>
<dd class="mono">{{ lead.created_at or '-' }}</dd>
<dt class="text-slate">Verified</dt>
<dd class="mono">{{ lead.verified_at or 'Not verified' }}</dd>
<dd class="flex items-center gap-2 flex-wrap">
{{ lead.contact_email or '-' }}
{% if lead.contact_email %}
<a href="{{ url_for('admin.emails', search=lead.contact_email) }}" class="text-xs text-slate" title="Email log">📧</a>
<a href="mailto:{{ lead.contact_email }}" class="text-xs text-slate" title="mailto"></a>
<a href="{{ url_for('admin.email_compose') }}?to={{ lead.contact_email }}" class="btn-outline btn-sm" style="padding:1px 8px;font-size:0.7rem">Send email</a>
{% endif %}
</dd>
<dt class="text-slate">Phone</dt> <dd>{{ lead.contact_phone or '-' }}</dd>
<dt class="text-slate">Company</dt> <dd>{{ lead.contact_company or '-' }}</dd>
<dt class="text-slate">Role</dt> <dd>{{ lead.stakeholder_type or '-' }}</dd>
<dt class="text-slate">Created</dt> <dd class="mono">{{ lead.created_at or '-' }}</dd>
<dt class="text-slate">Verified</dt> <dd class="mono">{{ lead.verified_at or 'Not verified' }}</dd>
</dl>
</div>
<!-- Forward to supplier -->
<!-- Forward to supplier (HTMX) -->
<div class="card" style="padding:1.5rem">
<h2 class="text-lg mb-4">Forward to Supplier</h2>
<form method="post" action="{{ url_for('admin.lead_forward', lead_id=lead.id) }}">
<form hx-post="{{ url_for('admin.lead_forward_htmx', lead_id=lead.id) }}"
hx-target="#forward-history"
hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="supplier_id" class="form-input mb-3" style="width:100%">
<option value="">Select supplier...</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.country_code }}, {{ s.category }})</option>
<option value="">Select supplier</option>
{# Lead's country first, then alphabetical #}
{% set lead_country = lead.country or '' %}
{% for s in suppliers | sort(attribute='name') | sort(attribute='country_code', reverse=(lead_country == '')) %}
<option value="{{ s.id }}"
{% if s.country_code == lead_country %}style="font-weight:600"{% endif %}>
{% if s.country_code == lead_country %}★ {% endif %}{{ s.name }} ({{ s.country_code }}, {{ s.category }})
</option>
{% endfor %}
</select>
<button type="submit" class="btn" style="width:100%">Forward Lead</button>
<button type="submit" class="btn" style="width:100%"
hx-disabled-elt="this">Forward Lead</button>
</form>
</div>
</div>
</div>
<!-- Forward history -->
{% if lead.forwards %}
<section class="mt-6">
<h2 class="text-lg mb-3">Forward History</h2>
<div class="card">
<table class="table">
<thead>
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Sent</th></tr>
</thead>
<tbody>
{% for f in lead.forwards %}
<tr>
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=f.supplier_id) }}">{{ f.supplier_name }}</a></td>
<td>{{ f.credit_cost }}</td>
<td><span class="badge">{{ f.status }}</span></td>
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="text-lg mb-3">Forward History
<span class="text-sm text-slate font-normal">({{ lead.forwards | length }} total)</span>
</h2>
<div class="card" style="padding:1rem">
<div id="forward-history">
{% include "admin/partials/lead_forward_history.html" with context %}
</div>
</div>
</section>
{% endif %}
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<div style="max-width:640px">
<div style="max-width:720px">
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a>
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
@@ -21,8 +21,7 @@
<input type="email" name="contact_email" class="form-input" required value="{{ data.get('contact_email', '') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Phone</label>
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
@@ -31,6 +30,14 @@
<label class="form-label">Company</label>
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
</div>
<div>
<label class="form-label">Role</label>
<select name="stakeholder_type" class="form-input">
{% for v in ['entrepreneur','tennis_club','municipality','developer','operator','architect'] %}
<option value="{{ v }}" {{ 'selected' if data.get('stakeholder_type') == v }}>{{ v | replace('_', ' ') | title }}</option>
{% endfor %}
</select>
</div>
</div>
<hr style="margin:1.5rem 0">
@@ -40,18 +47,44 @@
<div>
<label class="form-label">Facility Type *</label>
<select name="facility_type" class="form-input" required>
<option value="indoor">Indoor</option>
<option value="outdoor">Outdoor</option>
<option value="both">Both</option>
{% for v in ['indoor','outdoor','both'] %}
<option value="{{ v }}" {{ 'selected' if data.get('facility_type', 'indoor') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Build Context</label>
<select name="build_context" class="form-input">
<option value=""></option>
{% for v in ['new_build','conversion','expansion'] %}
<option value="{{ v }}" {{ 'selected' if data.get('build_context') == v }}>{{ v | replace('_', ' ') | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Courts</label>
<input type="number" name="court_count" class="form-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Budget (&euro;)</label>
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
<label class="form-label">Glass Type</label>
<select name="glass_type" class="form-input">
<option value=""></option>
{% for v in ['tempered','standard'] %}
<option value="{{ v }}" {{ 'selected' if data.get('glass_type') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Lighting</label>
<select name="lighting_type" class="form-input">
<option value=""></option>
{% for v in ['led','standard','none'] %}
<option value="{{ v }}" {{ 'selected' if data.get('lighting_type') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
</div>
@@ -60,7 +93,7 @@
<label class="form-label">Country *</label>
<select name="country" class="form-input" required>
{% for code, name in [('DE','Germany'),('ES','Spain'),('IT','Italy'),('FR','France'),('NL','Netherlands'),('SE','Sweden'),('GB','United Kingdom'),('PT','Portugal'),('AE','UAE'),('SA','Saudi Arabia')] %}
<option value="{{ code }}">{{ name }}</option>
<option value="{{ code }}" {{ 'selected' if data.get('country') == code }}>{{ name }}</option>
{% endfor %}
</select>
</div>
@@ -72,41 +105,68 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Timeline *</label>
<select name="timeline" class="form-input" required>
<option value="asap">ASAP</option>
<option value="3-6mo">3-6 Months</option>
<option value="6-12mo">6-12 Months</option>
<option value="12+mo">12+ Months</option>
<label class="form-label">Location Status</label>
<select name="location_status" class="form-input">
<option value=""></option>
{% for v in ['secured','searching','evaluating'] %}
<option value="{{ v }}" {{ 'selected' if data.get('location_status') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Stakeholder Type *</label>
<select name="stakeholder_type" class="form-input" required>
<option value="entrepreneur">Entrepreneur</option>
<option value="tennis_club">Tennis Club</option>
<option value="municipality">Municipality</option>
<option value="developer">Developer</option>
<option value="operator">Operator</option>
<option value="architect">Architect</option>
<label class="form-label">Timeline *</label>
<select name="timeline" class="form-input" required>
{% for v, label in [('asap','ASAP'),('3-6mo','36 Months'),('6-12mo','612 Months'),('12+mo','12+ Months')] %}
<option value="{{ v }}" {{ 'selected' if data.get('timeline') == v }}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Budget (€)</label>
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
</div>
<div>
<label class="form-label">Financing Status</label>
<select name="financing_status" class="form-input">
<option value=""></option>
{% for v in ['secured','in_progress','not_started'] %}
<option value="{{ v }}" {{ 'selected' if data.get('financing_status') == v }}>{{ v | replace('_', ' ') | title }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Services Needed</label>
<input type="text" name="services_needed" class="form-input" placeholder="e.g. construction, consulting, equipment"
value="{{ data.get('services_needed', '') }}">
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Additional Info</label>
<textarea name="additional_info" class="form-input" rows="3" style="width:100%">{{ data.get('additional_info', '') }}</textarea>
</div>
<hr style="margin:1.5rem 0">
<h2 class="text-lg mb-3">Classification</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
<div>
<label class="form-label">Heat Score</label>
<select name="heat_score" class="form-input">
<option value="hot">Hot</option>
<option value="warm" selected>Warm</option>
<option value="cool">Cool</option>
{% for v in ['hot','warm','cool'] %}
<option value="{{ v }}" {{ 'selected' if data.get('heat_score', 'warm') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-input">
{% for s in statuses %}
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s }}</option>
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
</div>

View File

@@ -1,33 +1,83 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "leads" %}
{% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %}
{% block title %}Leads - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.lead-heat-hot { background:#FEE2E2; color:#991B1B; font-weight:600; font-size:0.6875rem; padding:2px 8px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
.lead-heat-warm { background:#FEF3C7; color:#92400E; font-weight:600; font-size:0.6875rem; padding:2px 8px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
.lead-heat-cool { background:#DBEAFE; color:#1E40AF; font-weight:600; font-size:0.6875rem; padding:2px 8px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
.lead-status-new { background:#DBEAFE; color:#1E40AF; }
.lead-status-pending_verification { background:#FEF3C7; color:#92400E; }
.lead-status-contacted { background:#EDE9FE; color:#5B21B6; }
.lead-status-forwarded { background:#D1FAE5; color:#065F46; }
.lead-status-closed_won { background:#D1FAE5; color:#14532D; }
.lead-status-closed_lost { background:#F1F5F9; color:#475569; }
.lead-status-badge {
font-size:0.6875rem; font-weight:600; padding:2px 8px; border-radius:9999px;
display:inline-block; white-space:nowrap;
}
.date-pills { display:flex; gap:4px; }
.date-pills label {
cursor:pointer; font-size:0.75rem; padding:4px 10px; border-radius:9999px;
border:1px solid #CBD5E1; color:#475569; transition:all 0.1s;
}
.date-pills input[type=radio] { display:none; }
.date-pills input[type=radio]:checked + label {
background:#1D4ED8; border-color:#1D4ED8; color:#fff;
}
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl">Lead Management</h1>
<p class="text-sm text-slate mt-1">
{{ leads | length }} leads shown
{% if lead_stats %}
&middot; {{ lead_stats.get('new', 0) }} new
&middot; {{ lead_stats.get('forwarded', 0) }} forwarded
{% endif %}
</p>
<h1 class="text-2xl">Leads</h1>
<p class="text-sm text-slate mt-1">{{ total }} leads found</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
</div>
</header>
<!-- Summary Cards -->
<div class="grid-4 mb-6">
<div class="card text-center">
<p class="card-header">Total Leads</p>
<p class="text-3xl font-bold text-navy">{{ lead_stats._total }}</p>
</div>
<div class="card text-center">
<p class="card-header">New / Unverified</p>
<p class="text-3xl font-bold text-navy">{{ lead_stats._new_unverified }}</p>
<p class="text-xs text-slate mt-1">awaiting action</p>
</div>
<div class="card text-center">
<p class="card-header">Hot Pipeline</p>
<p class="text-3xl font-bold text-navy">{{ lead_stats._hot_pipeline }}</p>
<p class="text-xs text-slate mt-1">credits (hot leads)</p>
</div>
<div class="card text-center">
<p class="card-header">Forward Rate</p>
<p class="text-3xl font-bold {% if lead_stats._forward_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ lead_stats._forward_rate }}%</p>
<p class="text-xs text-slate mt-1">{{ lead_stats.get('forwarded', 0) }} forwarded</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-6" style="padding:1rem 1.25rem;">
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.lead_results') }}"
hx-target="#lead-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms"
hx-indicator="#leads-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="flex:1;min-width:180px">
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
<input type="text" name="search" class="form-input" placeholder="Name, email, company…"
value="{{ current_search }}" style="width:100%">
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
<select name="status" class="form-input" style="min-width:140px">
@@ -57,6 +107,22 @@
{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Period</label>
<div class="date-pills">
{% for label, val in [('Today', '1'), ('7d', '7'), ('30d', '30'), ('All', '')] %}
<input type="radio" name="days" id="days-{{ val }}" value="{{ val }}"
{% if current_days == val %}checked{% endif %}>
<label for="days-{{ val }}">{{ label }}</label>
{% endfor %}
</div>
</div>
<svg id="leads-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -0,0 +1,142 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "marketplace" %}
{% block title %}Marketplace — Admin — {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.mkt-flag-toggle {
display:inline-flex; align-items:center; gap:8px;
padding:6px 14px; border:1px solid #CBD5E1; border-radius:8px;
font-size:0.8125rem; cursor:pointer; transition:all 0.15s;
}
.mkt-flag-toggle.enabled { background:#D1FAE5; border-color:#6EE7B7; color:#065F46; }
.mkt-flag-toggle.disabled { background:#F1F5F9; border-color:#CBD5E1; color:#64748B; }
.mkt-flag-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.mkt-flag-toggle.enabled .mkt-flag-dot { background:#10B981; }
.mkt-flag-toggle.disabled .mkt-flag-dot { background:#94A3B8; }
.activity-dot {
width:8px; height:8px; border-radius:50%; flex-shrink:0; margin-top:5px;
}
.activity-dot-lead { background:#3B82F6; }
.activity-dot-unlock { background:#10B981; }
.activity-dot-credit { background:#F59E0B; }
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Marketplace</h1>
<p class="text-slate text-sm mt-1">Lead funnel, credit economy, and supplier engagement</p>
</div>
<a href="{{ url_for('admin.leads') }}" class="btn-outline btn-sm">All Leads</a>
</header>
<!-- Lead Funnel -->
<h2 class="text-sm font-bold text-slate mb-3" style="text-transform:uppercase;letter-spacing:.06em">Lead Funnel</h2>
<div class="grid-4 mb-8">
<div class="card text-center">
<p class="card-header">Total Leads</p>
<a href="{{ url_for('admin.leads') }}" class="text-3xl font-bold text-navy">{{ funnel.total }}</a>
</div>
<div class="card text-center">
<p class="card-header">Verified New</p>
<a href="{{ url_for('admin.leads', status='new') }}" class="text-3xl font-bold text-navy">{{ funnel.verified_new }}</a>
<p class="text-xs text-slate mt-1">ready to unlock</p>
</div>
<div class="card text-center">
<p class="card-header">Unlocked</p>
<a href="{{ url_for('admin.leads', status='forwarded') }}" class="text-3xl font-bold text-navy">{{ funnel.unlocked }}</a>
<p class="text-xs text-slate mt-1">by suppliers</p>
</div>
<div class="card text-center">
<p class="card-header">Conversion</p>
<p class="text-3xl font-bold {% if funnel.conversion_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ funnel.conversion_rate }}%</p>
<p class="text-xs text-slate mt-1"><a href="{{ url_for('admin.leads', status='closed_won') }}">{{ funnel.won }} won</a></p>
</div>
</div>
<!-- Credit Economy + Supplier Engagement side-by-side -->
<div class="grid-2 mb-8" style="gap:1.5rem">
<div class="card" style="padding:1.5rem">
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Credit Economy</h2>
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<p class="text-xs text-slate">Issued (all time)</p>
<p class="text-2xl font-bold text-navy">{{ "{:,}".format(credits.issued | int) }}</p>
</div>
<div>
<p class="text-xs text-slate">Consumed (all time)</p>
<p class="text-2xl font-bold text-navy">{{ "{:,}".format(credits.consumed | int) }}</p>
</div>
<div>
<p class="text-xs text-slate">Outstanding balance</p>
<p class="text-2xl font-bold text-navy">{{ "{:,}".format(credits.outstanding | int) }}</p>
</div>
<div>
<p class="text-xs text-slate">Monthly burn (30d)</p>
<p class="text-2xl font-bold {% if credits.monthly_burn > 0 %}text-navy{% else %}text-slate{% endif %}">{{ "{:,}".format(credits.monthly_burn | int) }}</p>
</div>
</dl>
</div>
<div class="card" style="padding:1.5rem">
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Supplier Engagement</h2>
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<p class="text-xs text-slate">Active suppliers</p>
<a href="{{ url_for('admin.suppliers') }}" class="text-2xl font-bold text-navy">{{ suppliers.active }}</a>
<p class="text-xs text-slate mt-1">growth/pro w/ credits</p>
</div>
<div>
<p class="text-xs text-slate">Avg unlocks / supplier</p>
<p class="text-2xl font-bold text-navy">{{ suppliers.avg_unlocks }}</p>
</div>
<div>
<p class="text-xs text-slate">Response rate</p>
<p class="text-2xl font-bold {% if suppliers.response_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ suppliers.response_rate }}%</p>
<p class="text-xs text-slate mt-1">replied or updated status</p>
</div>
<div>
<p class="text-xs text-slate">Leads pipeline</p>
<p class="text-2xl font-bold text-navy">{{ funnel.verified_new }}</p>
<p class="text-xs text-slate mt-1">available to unlock</p>
</div>
</dl>
</div>
</div>
<!-- Feature Flag Toggles -->
<div class="card mb-8" style="padding:1.5rem">
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Feature Flags</h2>
<div class="flex gap-4 flex-wrap">
{% for flag_name, flag_label in [('lead_unlock', 'Lead Unlock (self-service)'), ('supplier_signup', 'Supplier Signup')] %}
{% set is_on = flags.get(flag_name, false) %}
<form method="post" action="{{ url_for('admin.flag_toggle') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="name" value="{{ flag_name }}">
<input type="hidden" name="next" value="{{ url_for('admin.marketplace_dashboard') }}">
<button type="submit"
class="mkt-flag-toggle {% if is_on %}enabled{% else %}disabled{% endif %}">
<span class="mkt-flag-dot"></span>
{{ flag_label }}
<span class="text-xs">{{ 'ON' if is_on else 'OFF' }}</span>
</button>
</form>
{% endfor %}
<a href="{{ url_for('admin.flags') }}" class="text-xs text-slate" style="align-self:center;">All flags →</a>
</div>
</div>
<!-- Activity Stream (HTMX lazy-loaded) -->
<div id="activity-panel"
hx-get="{{ url_for('admin.marketplace_activity') }}"
hx-trigger="load delay:300ms"
hx-target="#activity-panel"
hx-swap="outerHTML">
<div class="card" style="padding:1.5rem">
<p class="text-slate text-sm">Loading activity stream…</p>
</div>
</div>
{% endblock %}

View File

@@ -1,3 +1,16 @@
{% if is_generating %}
<div class="generating-banner"
hx-get="{{ url_for('admin.article_results') }}"
hx-trigger="every 3s"
hx-target="#article-results"
hx-swap="innerHTML">
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
<span>Generating articles…</span>
</div>
{% endif %}
{% if articles %}
<div class="card">
<table class="table text-sm">

View File

@@ -0,0 +1,8 @@
{# HTMX partial: sandboxed iframe showing a rendered email preview.
Rendered by POST /admin/emails/compose/preview. #}
<iframe
srcdoc="{{ rendered_html | e }}"
style="width:100%;height:600px;border:none;border-radius:8px;background:#F1F5F9;"
sandbox="allow-same-origin"
title="Email preview"
></iframe>

View File

@@ -0,0 +1,40 @@
{# HTMX swap target: forward history table after a successful forward #}
{% if forwards %}
<table class="table">
<thead>
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Response</th><th>Sent</th></tr>
</thead>
<tbody>
{% for f in forwards %}
<tr>
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=f.supplier_id) }}">{{ f.supplier_name }}</a></td>
<td>{{ f.credit_cost }}</td>
<td>
{% set fwd_status = f.status or 'sent' %}
{% if fwd_status == 'won' %}
<span class="badge-success">Won</span>
{% elif fwd_status == 'lost' %}
<span class="badge">Lost</span>
{% elif fwd_status in ('contacted', 'quoted') %}
<span class="badge-warning">{{ fwd_status | capitalize }}</span>
{% else %}
<span class="badge">{{ fwd_status | capitalize }}</span>
{% endif %}
</td>
<td>
{% if f.supplier_note %}
<span class="text-xs text-slate" title="{{ f.supplier_note }}">{{ f.supplier_note | truncate(40) }}</span>
{% elif f.status_updated_at %}
<span class="text-xs text-slate mono">{{ (f.status_updated_at or '')[:10] }}</span>
{% else %}
<span class="text-xs text-slate"></span>
{% endif %}
</td>
<td class="mono text-sm">{{ (f.created_at or '')[:16] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No forwards yet.</p>
{% endif %}

View File

@@ -1,3 +1,28 @@
{% set page = page | default(1) %}
{% set per_page = per_page | default(50) %}
{% set total = total | default(0) %}
{% set start = (page - 1) * per_page + 1 %}
{% set end = [page * per_page, total] | min %}
{% set has_prev = page > 1 %}
{% set has_next = (page * per_page) < total %}
{% macro heat_badge(score) %}
<span class="lead-heat-{{ score or 'cool' }}">{{ (score or 'cool') | upper }}</span>
{% endmacro %}
{% macro status_badge(status) %}
<span class="lead-status-badge lead-status-{{ status | replace(' ', '_') }}">{{ status | replace('_', ' ') }}</span>
{% endmacro %}
{# Hidden inputs carry current filters for pagination hx-include #}
<div id="lead-filter-state" style="display:none">
<input type="hidden" name="status" value="{{ current_status | default('') }}">
<input type="hidden" name="heat" value="{{ current_heat | default('') }}">
<input type="hidden" name="country" value="{{ current_country | default('') }}">
<input type="hidden" name="search" value="{{ current_search | default('') }}">
<input type="hidden" name="days" value="{{ current_days | default('') }}">
</div>
{% if leads %}
<div class="card">
<table class="table">
@@ -18,23 +43,15 @@
{% for lead in leads %}
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
<td>
{% if lead.heat_score == 'hot' %}
<span class="badge-danger">HOT</span>
{% elif lead.heat_score == 'warm' %}
<span class="badge-warning">WARM</span>
{% else %}
<span class="badge">COOL</span>
{% endif %}
</td>
<td>{{ heat_badge(lead.heat_score) }}</td>
<td>
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
</td>
<td>{{ lead.country or '-' }}</td>
<td>{{ lead.court_count or '-' }}</td>
<td>{{ lead.budget_estimate or '-' }}</td>
<td><span class="badge">{{ lead.status }}</span></td>
<td>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</td>
<td>{{ status_badge(lead.status) }}</td>
<td>{{ lead.unlock_count or 0 }}</td>
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
</tr>
@@ -42,6 +59,39 @@
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total > per_page %}
<div class="flex justify-between items-center mt-4" style="font-size:0.8125rem; color:#64748B;">
<span>Showing {{ start }}{{ end }} of {{ total }}</span>
<div class="flex gap-2">
{% if has_prev %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.lead_results') }}"
hx-vals='{"page": "{{ page - 1 }}"}'
hx-include="#lead-filter-state"
hx-target="#lead-results"
hx-swap="innerHTML">
&larr; Prev
</button>
{% endif %}
<span>Page {{ page }}</span>
{% if has_next %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.lead_results') }}"
hx-vals='{"page": "{{ page + 1 }}"}'
hx-include="#lead-filter-state"
hx-target="#lead-results"
hx-swap="innerHTML">
Next &rarr;
</button>
{% endif %}
</div>
</div>
{% else %}
<p class="text-xs text-slate mt-3">Showing all {{ total }} results</p>
{% endif %}
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No leads match the current filters.</p>

View File

@@ -0,0 +1,21 @@
{# HTMX swap target: returns updated status badge + inline form #}
<div id="lead-status-section">
<div class="flex items-center gap-3 mb-3">
<span class="lead-status-badge lead-status-{{ status | replace(' ', '_') }}" style="font-size:0.875rem;padding:4px 12px;">
{{ status | replace('_', ' ') }}
</span>
<span class="text-xs text-slate">updated</span>
</div>
<form hx-post="{{ url_for('admin.lead_status_htmx', lead_id=lead_id) }}"
hx-target="#lead-status-section"
hx-swap="outerHTML"
class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="status" class="form-input" style="min-width:160px">
{% for s in ['new','pending_verification','contacted','forwarded','closed_won','closed_lost'] %}
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-outline btn-sm">Update</button>
</form>
</div>

View File

@@ -0,0 +1,33 @@
<div id="activity-panel" class="card" style="padding:1.5rem">
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Recent Activity</h2>
{% if events %}
<div style="display:flex;flex-direction:column;gap:10px">
{% for ev in events %}
<div class="flex gap-3 items-start">
<span class="activity-dot activity-dot-{{ ev.event_type }}"></span>
<div style="flex:1;font-size:0.8125rem">
{% if ev.event_type == 'lead' %}
<span class="font-semibold"><a href="{{ url_for('admin.lead_detail', lead_id=ev.ref_id) }}">New lead</a></span>
{% if ev.actor %} from {{ ev.actor }}{% endif %}
{% if ev.extra %} — {{ ev.extra }}{% endif %}
{% if ev.detail %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
{% elif ev.event_type == 'unlock' %}
<a href="{{ url_for('admin.supplier_detail', supplier_id=ev.ref2_id) }}" class="font-semibold">{{ ev.actor }}</a>
unlocked <a href="{{ url_for('admin.lead_detail', lead_id=ev.ref_id) }}">lead #{{ ev.ref_id }}</a>
{% if ev.extra %} — {{ ev.extra }} credits{% endif %}
{% if ev.detail and ev.detail != 'sent' %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
{% elif ev.event_type == 'credit' %}
<a href="{{ url_for('admin.supplier_detail', supplier_id=ev.ref2_id) }}">{{ ev.actor }}</a>
{% if ev.extra and ev.extra | int > 0 %}<span class="text-slate">+{{ ev.extra }}</span>
{% elif ev.extra %}<span class="text-slate">{{ ev.extra }}</span>{% endif %}
{% if ev.detail %} <span class="text-xs text-slate">({{ ev.detail }})</span>{% endif %}
{% endif %}
</div>
<span class="mono text-xs text-slate" style="flex-shrink:0">{{ (ev.created_at or '')[:10] }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-slate text-sm">No activity yet.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,50 @@
{% if is_generating %}
<div class="generating-banner"
hx-get="{{ url_for('admin.scenario_results') }}"
hx-trigger="every 3s"
hx-target="#scenario-results"
hx-swap="innerHTML">
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
<span>Generating scenarios…</span>
</div>
{% endif %}
{% if scenarios %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Delete this scenario? This cannot be undone.', this.closest('form'))">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No scenarios match the current filters.</p>
{% endif %}

View File

@@ -0,0 +1,60 @@
<div class="card">
{% if users %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-href="{{ url_for('admin.user_detail', user_id=u.id) }}">
<td class="mono text-sm">{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<span class="badge">{{ u.plan }}</span>
{% else %}
<span class="text-sm text-slate">free</span>
{% endif %}
</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex gap-4 justify-center mt-4 mb-2 text-sm">
{% if page > 1 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.user_results') }}?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}"
hx-target="#user-results"
hx-swap="innerHTML">&larr; Previous</button>
{% endif %}
<span class="text-slate self-center">Page {{ page }}</span>
{% if users | length == 50 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.user_results') }}?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}"
hx-target="#user-results"
hx-swap="innerHTML">Next &rarr;</button>
{% endif %}
</div>
{% else %}
<p class="text-slate text-sm" style="padding:1.5rem 1rem">No users found.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,195 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "pseo" %}
{% block title %}pSEO Engine - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.pseo-status-badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.6875rem; font-weight: 600; padding: 2px 8px;
border-radius: 9999px;
}
.pseo-status-fresh { background: #D1FAE5; color: #065F46; }
.pseo-status-stale { background: #FEF3C7; color: #92400E; }
.pseo-status-no_data { background: #F1F5F9; color: #64748B; }
.pseo-status-no_articles { background: #EDE9FE; color: #5B21B6; }
.pseo-gaps-panel { border-top: 1px solid #E2E8F0; margin-top: 8px; padding-top: 8px; }
.progress-bar-wrap { height: 6px; background: #E2E8F0; border-radius: 9999px; overflow: hidden; min-width: 80px; }
.progress-bar-fill { height: 100%; background: #1D4ED8; border-radius: 9999px; transition: width 0.3s; }
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">pSEO Engine</h1>
<p class="text-slate text-sm mt-1">Operational dashboard for programmatic SEO</p>
</div>
<a href="{{ url_for('pseo.pseo_jobs') }}" class="btn-outline btn-sm">All Jobs</a>
</header>
<!-- Summary Cards -->
<div class="grid-4 mb-8">
<div class="card text-center">
<p class="card-header">Total Articles</p>
<p class="text-3xl font-bold text-navy">{{ total_articles }}</p>
<p class="text-xs text-slate mt-1">{{ total_published }} published</p>
</div>
<div class="card text-center">
<p class="card-header">Templates</p>
<p class="text-3xl font-bold text-navy">{{ total_templates }}</p>
</div>
<div class="card text-center">
<p class="card-header">Stale Templates</p>
<p class="text-3xl font-bold {% if stale_count > 0 %}text-amber-600{% else %}text-navy{% endif %}">
{{ stale_count }}
</p>
<p class="text-xs text-slate mt-1">data newer than articles</p>
</div>
<div class="card text-center">
<p class="card-header">Health Checks</p>
<p class="text-3xl font-bold text-navy"></p>
<p class="text-xs text-slate mt-1">see Health section below</p>
</div>
</div>
<!-- Per-Template Table -->
<div class="card mb-8">
<div class="card-header mb-4 flex justify-between items-center">
<span>Templates</span>
<span class="text-xs text-slate">Click "Gaps" to load missing articles per template</span>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Template</th>
<th>Data rows</th>
<th>Articles EN</th>
<th>Articles DE</th>
<th>Freshness</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for r in template_rows %}
{% set t = r.template %}
{% set stats = r.stats %}
{% set fr = r.freshness %}
<tr>
<td>
<strong>{{ t.name }}</strong><br>
<span class="text-xs text-slate">{{ t.slug }}</span>
</td>
<td>{{ fr.row_count if fr.row_count is not none else '—' }}</td>
<td>{{ stats.by_language.get('en', {}).get('total', 0) }}</td>
<td>{{ stats.by_language.get('de', {}).get('total', 0) }}</td>
<td>
{% set status = fr.status | default('no_data') %}
<span class="pseo-status-badge pseo-status-{{ status }}">
{% if status == 'fresh' %}&#x1F7E2; Fresh
{% elif status == 'stale' %}&#x1F7E1; Stale
{% elif status == 'no_articles' %}&#x1F7E3; No articles
{% else %}&#x26AA; No data
{% endif %}
</span>
</td>
<td class="flex gap-2 items-center">
<button class="btn-outline btn-sm"
hx-get="{{ url_for('pseo.pseo_gaps_template', slug=t.slug) }}"
hx-target="#gaps-panel-{{ t.slug }}"
hx-swap="innerHTML"
hx-indicator="#gaps-panel-{{ t.slug }}">
Gaps
</button>
<form method="post" action="{{ url_for('pseo.pseo_generate_gaps', slug=t.slug) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm">Generate gaps</button>
</form>
</td>
</tr>
<tr>
<td colspan="6" class="p-0">
<div id="gaps-panel-{{ t.slug }}" class="pseo-gaps-panel" style="padding: 0 1rem 0.5rem;">
<!-- Loaded via HTMX on "Gaps" click -->
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Recent Jobs -->
{% if jobs %}
<div class="card mb-8">
<div class="card-header mb-4 flex justify-between items-center">
<span>Recent Generation Jobs</span>
<a href="{{ url_for('pseo.pseo_jobs') }}" class="text-xs text-blue">View all →</a>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Job</th>
<th>Status</th>
<th>Progress</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>
<a href="{{ url_for('pseo.pseo_jobs') }}#job-{{ job.id }}" class="text-blue">#{{ job.id }}</a>
{% if job.payload %}
— {{ (job.payload | fromjson).get('template_slug', '') }}
{% endif %}
</td>
<td>
{% if job.status == 'complete' %}
<span class="badge-success">Complete</span>
{% elif job.status == 'failed' %}
<span class="badge-danger">Failed</span>
{% elif job.status == 'pending' %}
<span class="badge-warning">Running</span>
{% else %}
<span class="badge">{{ job.status }}</span>
{% endif %}
</td>
<td>
{% if job.progress_total and job.progress_total > 0 %}
<div class="flex items-center gap-2">
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width: {{ [((job.progress_current / job.progress_total) * 100) | int, 100] | min }}%"></div>
</div>
<span class="text-xs text-slate">{{ job.progress_current }}/{{ job.progress_total }}</span>
</div>
{% else %}
{% endif %}
</td>
<td class="text-xs text-slate">{{ job.created_at | default('') | truncate(16, True, '') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Health Issues (HTMX-loaded) -->
<div id="health-panel"
hx-get="{{ url_for('pseo.pseo_health') }}"
hx-trigger="load delay:500ms"
hx-target="#health-panel"
hx-swap="outerHTML">
<div class="card">
<p class="text-slate text-sm">Loading health checks…</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{# HTMX partial — rendered inside the gaps panel for one template.
Loaded via GET /admin/pseo/gaps/<slug>. #}
{% if not gaps %}
<p class="text-success text-sm p-2">&#x2713; No gaps — all {{ template.name }} rows have articles.</p>
{% else %}
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-semibold">{{ gaps | length }} missing row{{ 's' if gaps | length != 1 else '' }}</span>
<form method="post" action="{{ url_for('pseo.pseo_generate_gaps', slug=template.slug) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm">Generate {{ gaps | length }} missing</button>
</form>
</div>
<div class="table-wrap" style="max-height: 300px; overflow-y: auto;">
<table class="table text-sm">
<thead>
<tr>
<th>{{ template.natural_key }}</th>
<th>Missing languages</th>
{% for key in (gaps[0].keys() | list | reject('equalto', '_natural_key') | reject('equalto', '_missing_languages') | list)[:4] %}
<th>{{ key }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for gap in gaps[:100] %}
<tr>
<td class="font-mono text-xs">{{ gap._natural_key }}</td>
<td class="text-xs text-amber-700">{{ gap._missing_languages | join(', ') }}</td>
{% for key in (gap.keys() | list | reject('equalto', '_natural_key') | reject('equalto', '_missing_languages') | list)[:4] %}
<td class="text-xs text-slate">{{ gap[key] | truncate(30) if gap[key] is string else gap[key] }}</td>
{% endfor %}
</tr>
{% endfor %}
{% if gaps | length > 100 %}
<tr>
<td colspan="10" class="text-xs text-slate text-center">… and {{ gaps | length - 100 }} more rows</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endif %}

View File

@@ -0,0 +1,99 @@
{# HTMX partial — loaded by pseo_dashboard.html and /admin/pseo/health directly.
When loaded via HTMX (hx-swap="outerHTML"), renders a full card.
When loaded standalone (full page), also works since it just outputs HTML. #}
<div class="card" id="health-panel">
<div class="card-header mb-4 flex justify-between items-center">
<span>Health Checks</span>
<span class="text-xs text-slate">{{ health.counts.total }} issue{{ 's' if health.counts.total != 1 else '' }}</span>
</div>
{% if health.counts.total == 0 %}
<p class="text-success text-sm">&#x2713; No issues found — all articles are healthy.</p>
{% else %}
<!-- Hreflang Orphans -->
{% if health.hreflang_orphans %}
<details class="mb-4">
<summary class="cursor-pointer font-semibold text-sm text-amber-700">
&#x26A0; Hreflang orphans ({{ health.counts.hreflang_orphans }})
<span class="text-xs font-normal text-slate ml-2">— articles missing a sibling language</span>
</summary>
<div class="table-wrap mt-2">
<table class="table text-sm">
<thead><tr><th>Template</th><th>URL path</th><th>Present</th><th>Missing</th></tr></thead>
<tbody>
{% for o in health.hreflang_orphans[:50] %}
<tr>
<td class="text-xs text-slate">{{ o.template_slug }}</td>
<td><a href="{{ o.url_path }}" class="text-blue text-xs" target="_blank">{{ o.url_path }}</a></td>
<td class="text-xs">{{ o.present_languages | join(', ') }}</td>
<td class="text-xs text-red-600">{{ o.missing_languages | join(', ') }}</td>
</tr>
{% endfor %}
{% if health.hreflang_orphans | length > 50 %}
<tr><td colspan="4" class="text-xs text-slate text-center">… and {{ health.hreflang_orphans | length - 50 }} more</td></tr>
{% endif %}
</tbody>
</table>
</div>
</details>
{% endif %}
<!-- Missing Build Files -->
{% if health.missing_build_files %}
<details class="mb-4">
<summary class="cursor-pointer font-semibold text-sm text-red-700">
&#x274C; Missing build files ({{ health.counts.missing_build_files }})
<span class="text-xs font-normal text-slate ml-2">— published articles with no HTML on disk</span>
</summary>
<div class="table-wrap mt-2">
<table class="table text-sm">
<thead><tr><th>Slug</th><th>Language</th><th>URL path</th><th>Expected path</th></tr></thead>
<tbody>
{% for m in health.missing_build_files[:50] %}
<tr>
<td class="text-xs font-mono">{{ m.slug }}</td>
<td class="text-xs">{{ m.language }}</td>
<td class="text-xs"><a href="{{ m.url_path }}" class="text-blue" target="_blank">{{ m.url_path }}</a></td>
<td class="text-xs text-slate font-mono">{{ m.expected_path }}</td>
</tr>
{% endfor %}
{% if health.missing_build_files | length > 50 %}
<tr><td colspan="4" class="text-xs text-slate text-center">… and {{ health.missing_build_files | length - 50 }} more</td></tr>
{% endif %}
</tbody>
</table>
</div>
</details>
{% endif %}
<!-- Broken Scenario Refs -->
{% if health.broken_scenario_refs %}
<details class="mb-4">
<summary class="cursor-pointer font-semibold text-sm text-red-700">
&#x274C; Broken scenario refs ({{ health.counts.broken_scenario_refs }})
<span class="text-xs font-normal text-slate ml-2">— [scenario:slug] markers referencing deleted scenarios</span>
</summary>
<div class="table-wrap mt-2">
<table class="table text-sm">
<thead><tr><th>Slug</th><th>Language</th><th>Broken refs</th></tr></thead>
<tbody>
{% for b in health.broken_scenario_refs[:50] %}
<tr>
<td class="text-xs font-mono">{{ b.slug }}</td>
<td class="text-xs">{{ b.language }}</td>
<td class="text-xs text-red-600 font-mono">{{ b.broken_scenario_refs | join(', ') }}</td>
</tr>
{% endfor %}
{% if health.broken_scenario_refs | length > 50 %}
<tr><td colspan="3" class="text-xs text-slate text-center">… and {{ health.broken_scenario_refs | length - 50 }} more</td></tr>
{% endif %}
</tbody>
</table>
</div>
</details>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,45 @@
{# HTMX partial — replaces the entire <tr> for a job row while it's running.
Stops polling once the job is complete or failed (hx-trigger="every 2s" only applies
while this partial keeps returning a polling trigger). #}
{% set pct = [((job.progress_current / job.progress_total) * 100) | int, 100] | min if job.progress_total else 0 %}
<tr id="job-{{ job.id }}"
{% if job.status == 'pending' %}
hx-get="{{ url_for('pseo.pseo_job_status', job_id=job.id) }}"
hx-trigger="every 2s"
hx-target="this"
hx-swap="outerHTML"
{% endif %}>
<td class="text-xs text-slate">#{{ job.id }}</td>
<td></td>{# payload not re-fetched in status endpoint — static display #}
<td>
{% if job.status == 'complete' %}
<span class="badge-success">Complete</span>
{% elif job.status == 'failed' %}
<span class="badge-danger">Failed</span>
{% else %}
<span class="badge-warning">Running…</span>
{% endif %}
</td>
<td>
{% if job.progress_total and job.progress_total > 0 %}
<div class="flex items-center gap-2">
<div class="progress-bar-wrap" style="min-width:120px;">
<div class="progress-bar-fill" style="width: {{ pct }}%"></div>
</div>
<span class="text-xs text-slate">{{ job.progress_current }}/{{ job.progress_total }}</span>
</div>
{% else %}—{% endif %}
</td>
<td class="text-xs text-slate">{{ (job.created_at or '') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ (job.completed_at or '') | truncate(19, True, '') }}</td>
<td>
{% if job.error %}
<details>
<summary class="text-xs text-red-600 cursor-pointer">Error</summary>
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto max-w-xs">{{ job.error[:500] }}</pre>
</details>
{% else %}—{% endif %}
</td>
</tr>

View File

@@ -0,0 +1,95 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "pseo" %}
{% block title %}pSEO Jobs - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.progress-bar-wrap { height: 6px; background: #E2E8F0; border-radius: 9999px; overflow: hidden; min-width: 120px; }
.progress-bar-fill { height: 100%; background: #1D4ED8; border-radius: 9999px; transition: width 0.3s; }
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Generation Jobs</h1>
<p class="text-slate text-sm mt-1">Recent article generation runs</p>
</div>
<a href="{{ url_for('pseo.pseo_dashboard') }}" class="btn-outline btn-sm">← pSEO Engine</a>
</header>
{% if not jobs %}
<div class="card">
<p class="text-slate text-sm">No generation jobs found. Use the pSEO Engine dashboard to generate articles.</p>
</div>
{% else %}
<div class="card">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Template</th>
<th>Status</th>
<th>Progress</th>
<th>Started</th>
<th>Completed</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr id="job-{{ job.id }}">
<td class="text-xs text-slate">#{{ job.id }}</td>
<td>
{% if job.payload %}
{% set payload = job.payload | fromjson %}
<span class="font-mono text-xs">{{ payload.get('template_slug', '—') }}</span>
{% else %}—{% endif %}
</td>
<td>
{% if job.status == 'complete' %}
<span class="badge-success">Complete</span>
{% elif job.status == 'failed' %}
<span class="badge-danger">Failed</span>
{% elif job.status == 'pending' %}
{# Poll live status for running jobs #}
<div hx-get="{{ url_for('pseo.pseo_job_status', job_id=job.id) }}"
hx-trigger="load, every 2s"
hx-target="closest tr"
hx-swap="outerHTML">
<span class="badge-warning">Running…</span>
</div>
{% else %}
<span class="badge">{{ job.status }}</span>
{% endif %}
</td>
<td>
{% if job.progress_total and job.progress_total > 0 %}
<div class="flex items-center gap-2">
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width: {{ [((job.progress_current / job.progress_total) * 100) | int, 100] | min }}%"></div>
</div>
<span class="text-xs text-slate">{{ job.progress_current }}/{{ job.progress_total }}</span>
</div>
{% else %}—{% endif %}
</td>
<td class="text-xs text-slate">{{ (job.created_at or '') | truncate(19, True, '') }}</td>
<td class="text-xs text-slate">{{ (job.completed_at or '') | truncate(19, True, '') }}</td>
<td>
{% if job.error %}
<details>
<summary class="text-xs text-red-600 cursor-pointer">Error</summary>
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto max-w-xs">{{ job.error[:500] }}</pre>
</details>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,57 +1,68 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "scenarios" %}
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
{% block title %}Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl">Published Scenarios</h1>
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
<h1 class="text-2xl">Scenarios</h1>
<p class="text-slate text-sm">
Pre-computed calculator outputs — embedded as cards in articles and PDFs.
Showing {{ scenarios | length }} of {{ total }}.
</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div>
</header>
<div class="card">
{% if scenarios %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this scenario?')">Delete</button>
</form>
</td>
</tr>
<form class="card mb-4 flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.scenario_results') }}"
hx-target="#scenario-results"
hx-trigger="change, input delay:300ms"
hx-indicator="#scenario-loading">
<div class="flex-1 min-w-48">
<label class="block text-sm text-slate mb-1">Search</label>
<input type="text" name="search" value="{{ current_search }}"
placeholder="Title, location, slug…"
class="form-input w-full">
</div>
<div>
<label class="block text-sm text-slate mb-1">Country</label>
<select name="country" class="form-input">
<option value="">All countries</option>
{% for c in countries %}
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No published scenarios yet.</p>
{% endif %}
</select>
</div>
<div>
<label class="block text-sm text-slate mb-1">Venue type</label>
<select name="venue_type" class="form-input">
<option value="">All types</option>
{% for v in venue_types %}
<option value="{{ v }}" {% if v == current_venue_type %}selected{% endif %}>{{ v | capitalize }}</option>
{% endfor %}
</select>
</div>
<div class="flex gap-2 items-center">
<button type="button" class="btn-outline"
onclick="this.closest('form').reset(); htmx.trigger(this.closest('form'), 'change')">Clear</button>
<svg id="scenario-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</div>
</form>
<div class="card">
<div id="scenario-results">
{% include "admin/partials/scenario_results.html" %}
</div>
</div>
{% endblock %}

View File

@@ -45,7 +45,14 @@
<dd>{% if supplier.website %}<a href="{{ supplier.website }}" target="_blank" class="text-sm">{{ supplier.website }}</a>{% else %}-{% endif %}</dd>
<dt class="text-slate">Contact</dt>
<dd>{{ supplier.contact_name or '-' }}<br>
<span class="text-xs text-slate">{{ supplier.contact_email or '-' }}</span></dd>
<span class="text-xs text-slate flex items-center gap-2 flex-wrap mt-1">
{{ supplier.contact_email or '-' }}
{% if supplier.contact_email %}
<a href="{{ url_for('admin.emails', search=supplier.contact_email) }}" title="Email log">📧</a>
<a href="mailto:{{ supplier.contact_email }}" title="mailto"></a>
<a href="{{ url_for('admin.email_compose') }}?to={{ supplier.contact_email }}" class="btn-outline btn-sm" style="padding:1px 8px;font-size:0.7rem">Send email</a>
{% endif %}
</span></dd>
<dt class="text-slate">Tagline</dt>
<dd>{{ supplier.tagline or '-' }}</dd>
<dt class="text-slate">Description</dt>
@@ -73,7 +80,7 @@
<dt class="text-slate">Enquiries</dt>
<dd>{{ enquiry_count }}</dd>
<dt class="text-slate">Claimed By</dt>
<dd>{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}</dd>
<dd>{% if supplier.claimed_by %}<a href="{{ url_for('admin.user_detail', user_id=supplier.claimed_by) }}">User #{{ supplier.claimed_by }}</a>{% else %}Unclaimed{% endif %}</dd>
<dt class="text-slate">Created</dt>
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
</dl>

View File

@@ -24,7 +24,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.supplier_results') }}"
hx-target="#supplier-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms"
hx-indicator="#suppliers-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -52,6 +53,11 @@
{% endfor %}
</select>
</div>
<svg id="suppliers-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -15,7 +15,7 @@
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Regenerate all articles for this template with fresh data?')">
<button type="button" class="btn-outline" onclick="confirmAction('Regenerate all articles for this template with fresh data? Existing articles will be overwritten.', this.closest('form'))">
Regenerate
</button>
</form>

View File

@@ -9,69 +9,24 @@
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a>
</header>
<!-- Search -->
<form method="get" class="mb-8">
<div class="flex gap-3 max-w-md">
<input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
<button type="submit" class="btn">Search</button>
<div class="card mb-6" style="padding:1rem 1.25rem">
<form class="flex gap-3 items-center"
hx-get="{{ url_for('admin.user_results') }}"
hx-target="#user-results"
hx-trigger="input delay:300ms"
hx-indicator="#user-loading">
<div class="flex-1 max-w-sm">
<input type="search" name="search" class="form-input w-full"
placeholder="Search by email…" value="{{ search }}">
</div>
<svg id="user-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
<!-- User Table -->
<div class="card">
{% if users %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-href="{{ url_for('admin.user_detail', user_id=u.id) }}">
<td class="mono text-sm">{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<span class="badge">{{ u.plan }}</span>
{% else %}
<span class="text-sm text-slate">free</span>
{% endif %}
</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex gap-4 justify-center mt-6 text-sm">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">&larr; Previous</a>
{% endif %}
<span class="text-slate">Page {{ page }}</span>
{% if users | length == 50 %}
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next &rarr;</a>
{% endif %}
</div>
{% else %}
<p class="text-slate text-sm">No users found.</p>
{% endif %}
<div id="user-results">
{% include "admin/partials/user_results.html" %}
</div>
{% endblock %}

View File

@@ -10,13 +10,20 @@ Usage:
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])
"""
import asyncio
import logging
import os
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_conn = None # duckdb.DuckDBPyConnection | None — lazy import
_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
# DuckDB queries run in the asyncio thread pool. Cap them so a slow scan
# cannot starve the pool and leave all workers busy.
_QUERY_TIMEOUT_SECONDS = 30
def open_analytics_db() -> None:
"""Open the DuckDB connection. Call once at app startup."""
@@ -43,7 +50,7 @@ async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str
Run a read-only DuckDB query and return rows as dicts.
Returns [] if analytics DB is unavailable (not yet built, or DUCKDB_PATH unset).
Never raises — callers should treat empty results as "no data yet".
Returns [] on query error after logging — callers treat empty results as "no data yet".
"""
assert sql, "sql must not be empty"
@@ -51,11 +58,22 @@ async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str
return []
def _run() -> list[dict]:
rel = _conn.execute(sql, params or [])
cur = _conn.cursor()
try:
rel = cur.execute(sql, params or [])
cols = [d[0] for d in rel.description]
return [dict(zip(cols, row)) for row in rel.fetchall()]
finally:
cur.close()
try:
return await asyncio.to_thread(_run)
except Exception:
return await asyncio.wait_for(
asyncio.to_thread(_run),
timeout=_QUERY_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error("DuckDB analytics query timed out after %ds: %.200s", _QUERY_TIMEOUT_SECONDS, sql)
return []
except Exception:
logger.exception("DuckDB analytics query failed: %.200s", sql)
return []

View File

@@ -1,14 +1,25 @@
"""
Padelnomics - Application factory and entry point.
"""
import json
import time
from pathlib import Path
from quart import Quart, Response, abort, g, redirect, request, session, url_for
from .analytics import close_analytics_db, open_analytics_db
from .core import close_db, config, get_csrf_token, init_db, is_flag_enabled, setup_request_id
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
from .core import (
close_db,
config,
get_csrf_token,
init_db,
is_flag_enabled,
setup_logging,
setup_request_id,
)
setup_logging()
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_country_name, get_translations
_ASSET_VERSION = str(int(time.time()))
@@ -94,6 +105,8 @@ def create_app() -> Quart:
app.jinja_env.filters["fmt_x"] = _fmt_x
app.jinja_env.filters["fmt_n"] = _fmt_n
app.jinja_env.filters["tformat"] = _tformat # translate with placeholders: {{ t.key | tformat(count=n) }}
app.jinja_env.filters["country_name"] = get_country_name # {{ article.country | country_name(lang) }}
app.jinja_env.filters["fromjson"] = json.loads # {{ job.payload | fromjson }}
# Session config
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
@@ -208,7 +221,7 @@ def create_app() -> Quart:
@app.context_processor
def inject_globals():
from datetime import datetime
from .core import utcnow as _utcnow
lang = g.get("lang") or _detect_lang()
g.lang = lang # ensure g.lang is always set (e.g. for dashboard/billing routes)
effective_lang = lang if lang in SUPPORTED_LANGS else "en"
@@ -217,7 +230,7 @@ def create_app() -> Quart:
"user": g.get("user"),
"subscription": g.get("subscription"),
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
"now": datetime.utcnow(),
"now": _utcnow(),
"csrf_token": get_csrf_token,
"ab_variant": getattr(g, "ab_variant", None),
"ab_tag": getattr(g, "ab_tag", None),
@@ -292,10 +305,15 @@ def create_app() -> Quart:
async def legacy_suppliers():
return redirect("/en/suppliers", 301)
@app.route("/market-score")
async def legacy_market_score():
return redirect("/en/market-score", 301)
# -------------------------------------------------------------------------
# Blueprint registration
# -------------------------------------------------------------------------
from .admin.pseo_routes import bp as pseo_bp
from .admin.routes import bp as admin_bp
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
@@ -320,6 +338,7 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(pseo_bp)
app.register_blueprint(webhooks_bp)
# Content catch-all LAST — lives under /<lang> too

View File

@@ -3,7 +3,7 @@ Auth domain: magic link authentication, user management, decorators.
"""
import secrets
from datetime import datetime, timedelta
from datetime import timedelta
from functools import wraps
from pathlib import Path
@@ -18,6 +18,8 @@ from ..core import (
fetch_one,
is_disposable_email,
is_flag_enabled,
utcnow,
utcnow_iso,
)
from ..i18n import SUPPORTED_LANGS, get_translations
@@ -64,7 +66,7 @@ async def get_user_by_email(email: str) -> dict | None:
async def create_user(email: str) -> int:
"""Create new user, return ID."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now)
)
@@ -82,10 +84,10 @@ async def update_user(user_id: int, **fields) -> None:
async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int:
"""Create auth token for user."""
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
expires = datetime.utcnow() + timedelta(minutes=minutes)
expires = utcnow() + timedelta(minutes=minutes)
return await execute(
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, token, expires.isoformat()),
(user_id, token, expires.strftime("%Y-%m-%d %H:%M:%S")),
)
@@ -98,14 +100,14 @@ async def get_valid_token(token: str) -> dict | None:
JOIN users u ON u.id = at.user_id
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
""",
(token, datetime.utcnow().isoformat()),
(token, utcnow_iso()),
)
async def mark_token_used(token_id: int) -> None:
"""Mark token as used."""
await execute(
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id)
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", (utcnow_iso(), token_id)
)
@@ -331,7 +333,7 @@ async def verify():
await mark_token_used(token_data["id"])
# Update last login
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
await update_user(token_data["user_id"], last_login_at=utcnow_iso())
# Set session
session.permanent = True

View File

@@ -5,7 +5,7 @@ Payment provider: paddle
import json
import secrets
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from paddle_billing import Client as PaddleClient
@@ -14,7 +14,7 @@ from paddle_billing.Notifications import Secret, Verifier
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
from ..auth.routes import login_required
from ..core import config, execute, fetch_one, get_paddle_price
from ..core import config, execute, fetch_one, get_paddle_price, utcnow, utcnow_iso
from ..i18n import get_translations
@@ -69,7 +69,7 @@ async def upsert_subscription(
current_period_end: str = None,
) -> int:
"""Create or update subscription. Finds existing by provider_subscription_id."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
existing = await fetch_one(
"SELECT id FROM subscriptions WHERE provider_subscription_id = ?",
@@ -104,7 +104,7 @@ async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
"""Update subscription status by provider subscription ID."""
extra["updated_at"] = datetime.utcnow().isoformat()
extra["updated_at"] = utcnow_iso()
extra["status"] = status
sets = ", ".join(f"{k} = ?" for k in extra)
values = list(extra.values())
@@ -343,7 +343,7 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
base_plan, tier = _derive_tier_from_plan(plan)
monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db_transaction() as db:
# Update supplier record — Basic tier also gets is_verified = 1
@@ -392,7 +392,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
supplier_id = custom_data.get("supplier_id")
user_id = custom_data.get("user_id")
now = datetime.utcnow().isoformat()
now = utcnow_iso()
items = data.get("items", [])
for item in items:
@@ -412,10 +412,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
# Sticky boost purchases
elif key == "boost_sticky_week" and supplier_id:
from datetime import timedelta
from ..core import transaction as db_transaction
expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat()
expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%d %H:%M:%S")
country = custom_data.get("sticky_country", "")
async with db_transaction() as db:
await db.execute(
@@ -430,10 +428,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
)
elif key == "boost_sticky_month" and supplier_id:
from datetime import timedelta
from ..core import transaction as db_transaction
expires = (datetime.utcnow() + timedelta(days=30)).isoformat()
expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
country = custom_data.get("sticky_country", "")
async with db_transaction() as db:
await db.execute(

View File

@@ -6,7 +6,9 @@ Data comes from DuckDB serving tables. Only articles + published_scenarios
are stored in SQLite (routing / application state).
"""
import json
import logging
import re
import time
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
@@ -15,7 +17,9 @@ import yaml
from jinja2 import ChainableUndefined, Environment
from ..analytics import fetch_analytics
from ..core import execute, fetch_one, slugify
from ..core import slugify, transaction, utcnow_iso
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
@@ -124,6 +128,15 @@ async def fetch_template_data(
)
async def count_template_data(data_table: str) -> int:
"""Return the row count of a DuckDB serving table. Returns 0 if unavailable."""
assert "." in data_table, "data_table must be schema-qualified"
_validate_table_name(data_table)
rows = await fetch_analytics(f"SELECT COUNT(*) AS cnt FROM {data_table}")
return rows[0]["cnt"] if rows else 0
def _validate_table_name(data_table: str) -> None:
"""Guard against SQL injection in table names."""
assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
@@ -135,7 +148,7 @@ def _validate_table_name(data_table: str) -> None:
def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str:
"""Jinja2 filter: format a date string (or 'now') with strftime."""
from datetime import UTC, datetime
from datetime import datetime
if value == "now":
dt = datetime.now(UTC)
@@ -271,6 +284,7 @@ async def generate_articles(
*,
limit: int = 500,
base_url: str = "https://padelnomics.io",
task_id: int | None = None,
) -> int:
"""
Generate articles from a git template + DuckDB data.
@@ -284,8 +298,14 @@ async def generate_articles(
- write HTML to disk
- upsert article row in SQLite
Returns count of articles generated.
If task_id is given, writes progress_current / progress_total / error_log
to the tasks table every _PROGRESS_BATCH articles so the pSEO dashboard
can show a live progress bar. Per-article errors are logged and collected
rather than aborting the run — the full task still completes.
Returns count of articles generated (excluding per-article errors).
"""
from ..core import execute as db_execute
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards, is_reserved_path
@@ -298,28 +318,57 @@ async def generate_articles(
if not rows:
return 0
# Pre-compile all Jinja templates once — avoids creating a new Environment()
# and re-parsing the same template strings on every iteration.
_env = Environment(undefined=ChainableUndefined)
_env.filters["slugify"] = slugify
_env.filters["datetimeformat"] = _datetimeformat
url_tmpl = _env.from_string(config["url_pattern"])
title_tmpl = _env.from_string(config["title_pattern"])
meta_tmpl = _env.from_string(config["meta_description_pattern"])
body_tmpl = _env.from_string(config["body_template"])
publish_date = start_date
published_today = 0
generated = 0
now_iso = datetime.now(UTC).isoformat()
now_iso = utcnow_iso()
# Timing accumulators — logged at end so we can see where time goes.
t_calc = t_render = t_bake = 0.0
_BATCH_SIZE = 200
_PROGRESS_BATCH = 50 # write task progress every N articles (avoid write amplification)
# Write progress_total before the loop so the dashboard can show 0/N immediately.
if task_id is not None:
total = len(rows) * len(config["languages"])
await db_execute(
"UPDATE tasks SET progress_total = ? WHERE id = ?",
(total, task_id),
)
async with transaction() as db:
for row in rows:
for lang in config["languages"]:
# Build render context: row data + language
ctx = {**row, "language": lang}
# Build render context, replacing None with 0 so numeric
# Jinja filters (round, int) don't crash.
safe_ctx = {k: (v if v is not None else 0) for k, v in row.items()}
safe_ctx["language"] = lang
# Render URL pattern (no lang prefix — blueprint provides /<lang>)
url_path = _render_pattern(config["url_pattern"], ctx)
url_path = url_tmpl.render(**safe_ctx)
if is_reserved_path(url_path):
continue
title = _render_pattern(config["title_pattern"], ctx)
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
title = title_tmpl.render(**safe_ctx)
meta_desc = meta_tmpl.render(**safe_ctx)
article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])
# Calculator content type: create scenario
scenario_slug = None
scenario_overrides = None
if config["content_type"] == "calculator":
t0 = time.perf_counter()
# DuckDB lowercases all column names; build a case-insensitive
# reverse map so "ratepeak" (stored) matches "ratePeak" (DEFAULTS).
_defaults_ci = {k.lower(): k for k in DEFAULTS}
@@ -330,6 +379,7 @@ async def generate_articles(
}
state = validate_state(calc_overrides)
d = calc(state, lang=lang)
t_calc += time.perf_counter() - t0
scenario_slug = slug + "-" + str(row[config["natural_key"]])
dbl = state.get("dblCourts", 0)
@@ -338,24 +388,15 @@ async def generate_articles(
city = row.get("city_name", row.get("city", ""))
country = row.get("country", state.get("country", ""))
# Upsert published scenario
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?",
(scenario_slug,),
)
if existing:
await execute(
"""UPDATE published_scenarios
SET state_json = ?, calc_json = ?, updated_at = ?
WHERE slug = ?""",
(json.dumps(state), json.dumps(d), now_iso, scenario_slug),
)
else:
await execute(
await db.execute(
"""INSERT INTO published_scenarios
(slug, title, location, country, venue_type, ownership,
court_config, state_json, calc_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
state_json = excluded.state_json,
calc_json = excluded.calc_json,
updated_at = excluded.created_at""",
(
scenario_slug, city, city, country,
state.get("venue", "indoor"),
@@ -365,12 +406,36 @@ async def generate_articles(
),
)
ctx["scenario_slug"] = scenario_slug
safe_ctx["scenario_slug"] = scenario_slug
# Pass scenario data directly so bake_scenario_cards skips the
# DB re-fetch (the row was just upserted and may not be visible
# on a separate connection within the same uncommitted transaction).
scenario_overrides = {
scenario_slug: {
"slug": scenario_slug,
"title": city,
"location": city,
"country": country,
"venue_type": state.get("venue", "indoor"),
"ownership": state.get("own", "rent"),
"court_config": court_config,
"state_json": json.dumps(state),
"calc_json": json.dumps(d),
"created_at": now_iso,
}
}
# Render body template
body_md = _render_pattern(config["body_template"], ctx)
t0 = time.perf_counter()
body_md = body_tmpl.render(**safe_ctx)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
t_render += time.perf_counter() - t0
t0 = time.perf_counter()
body_html = await bake_scenario_cards(
body_html, lang=lang, scenario_overrides=scenario_overrides
)
t_bake += time.perf_counter() - t0
# Extract FAQ pairs for structured data
faq_pairs = _extract_faq_pairs(body_md)
@@ -382,16 +447,16 @@ async def generate_articles(
8, 0, 0,
).isoformat()
# Hreflang links
# Hreflang links — reuse compiled url_tmpl with swapped language
hreflang_links = []
for alt_lang in config["languages"]:
alt_url = f"/{alt_lang}" + _render_pattern(config["url_pattern"], {**row, "language": alt_lang})
alt_url = f"/{alt_lang}" + url_tmpl.render(**{**safe_ctx, "language": alt_lang})
hreflang_links.append(
f'<link rel="alternate" hreflang="{alt_lang}" href="{base_url}{alt_url}" />'
)
# x-default points to English (or first language)
default_lang = "en" if "en" in config["languages"] else config["languages"][0]
default_url = f"/{default_lang}" + _render_pattern(config["url_pattern"], {**row, "language": default_lang})
default_url = f"/{default_lang}" + url_tmpl.render(**{**safe_ctx, "language": default_lang})
hreflang_links.append(
f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_url}" />'
)
@@ -434,28 +499,21 @@ async def generate_articles(
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body_md)
# Upsert article in SQLite — keyed by (url_path, language) since
# multiple languages share the same url_path
existing_article = await fetch_one(
"SELECT id FROM articles WHERE url_path = ? AND language = ?",
(url_path, lang),
)
if existing_article:
await execute(
"""UPDATE articles
SET title = ?, meta_description = ?, template_slug = ?,
language = ?, date_modified = ?, updated_at = ?,
seo_head = ?
WHERE url_path = ? AND language = ?""",
(title, meta_desc, slug, lang, now_iso, now_iso, seo_head, url_path, lang),
)
else:
await execute(
# Upsert article — keyed by (url_path, language).
# Single statement: no SELECT round-trip, no per-row commit.
await db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified,
seo_head, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)
ON CONFLICT(url_path, language) DO UPDATE SET
title = excluded.title,
meta_description = excluded.meta_description,
template_slug = excluded.template_slug,
date_modified = excluded.date_modified,
seo_head = excluded.seo_head,
updated_at = excluded.date_modified""",
(
url_path, article_slug, title, meta_desc,
row.get("country", ""), row.get("region", ""),
@@ -465,12 +523,39 @@ async def generate_articles(
generated += 1
# Commit every _BATCH_SIZE articles so the admin UI shows progress
# earlier rather than waiting for the full run to complete.
if generated % _BATCH_SIZE == 0:
await db.commit()
logger.info("%s: committed batch — %d articles", slug, generated)
elif generated % 25 == 0:
logger.info("%s: %d articles written…", slug, generated)
# Write progress every _PROGRESS_BATCH articles so the pSEO
# dashboard live-updates without excessive write amplification.
if task_id is not None and generated % _PROGRESS_BATCH == 0:
await db_execute(
"UPDATE tasks SET progress_current = ? WHERE id = ?",
(generated, task_id),
)
# Stagger dates
published_today += 1
if published_today >= articles_per_day:
published_today = 0
publish_date += timedelta(days=1)
# Write final progress so the dashboard shows 100% on completion.
if task_id is not None:
await db_execute(
"UPDATE tasks SET progress_current = ? WHERE id = ?",
(generated, task_id),
)
logger.info(
"%s: done — %d total | calc=%.1fs render=%.1fs bake=%.1fs",
slug, generated, t_calc, t_render, t_bake,
)
return generated

View File

@@ -0,0 +1,397 @@
"""
pSEO Engine health checks and content gap queries.
All functions are async, pure queries — no side effects.
Used by the pSEO Engine admin dashboard.
Functions overview:
get_template_stats() — article counts per status/language for one template
get_template_freshness() — compare _serving_meta.json timestamp vs last article generation
get_content_gaps() — DuckDB rows with no matching article for a template+language
check_hreflang_orphans() — published articles missing a sibling language
check_missing_build_files()— published articles whose HTML file is absent from disk
check_broken_scenario_refs()— articles referencing [scenario:slug] that doesn't exist
get_all_health_issues() — run all checks, return counts + details
"""
import json
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from ..analytics import fetch_analytics
from ..core import fetch_all
logger = logging.getLogger(__name__)
# Directory where generate_articles() writes HTML + markdown source files.
BUILD_DIR = Path("data/content/_build")
# Pattern matching [scenario:slug] and [scenario:slug:section] markers.
_SCENARIO_REF_RE = re.compile(r"\[scenario:([a-z0-9_-]+)(?::[a-z]+)?\]")
def _validate_table_name(data_table: str) -> None:
"""Guard against SQL injection in table names."""
assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
f"Invalid table name: {data_table}"
)
def _read_serving_meta() -> dict:
"""Read _serving_meta.json written by export_serving.py. Returns {} if absent."""
serving_path = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
meta_path = Path(serving_path).parent / "_serving_meta.json"
if not meta_path.exists():
return {}
try:
return json.loads(meta_path.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _parse_dt(s: str | None) -> datetime | None:
"""Parse an ISO datetime string to a naive UTC datetime. Returns None on failure."""
if not s:
return None
try:
dt = datetime.fromisoformat(s)
# Strip timezone info so both aware (from meta) and naive (from SQLite) compare cleanly.
return dt.replace(tzinfo=None)
except (ValueError, TypeError):
return None
# ── Template statistics ───────────────────────────────────────────────────────
async def get_template_stats(template_slug: str) -> dict:
"""Article counts for a template: total, published, draft, scheduled, by language.
Returns:
{
"total": N,
"published": N,
"draft": N,
"scheduled": N,
"by_language": {"en": {"total": N, "published": N, ...}, ...},
}
"""
rows = await fetch_all(
"SELECT status, language, COUNT(*) as cnt FROM articles"
" WHERE template_slug = ? GROUP BY status, language",
(template_slug,),
)
stats: dict = {"total": 0, "published": 0, "draft": 0, "scheduled": 0, "by_language": {}}
for r in rows:
cnt = r["cnt"]
status = r["status"]
lang = r["language"]
stats["total"] += cnt
if status in stats:
stats[status] += cnt
if lang not in stats["by_language"]:
stats["by_language"][lang] = {"total": 0, "published": 0, "draft": 0, "scheduled": 0}
stats["by_language"][lang]["total"] += cnt
if status in stats["by_language"][lang]:
stats["by_language"][lang][status] += cnt
return stats
# ── Data freshness ────────────────────────────────────────────────────────────
async def get_template_freshness(templates: list[dict]) -> list[dict]:
"""Compare _serving_meta.json exported_at vs max(articles.updated_at) per template.
Returns list of dicts — one per template:
{
"slug": str,
"name": str,
"data_table": str,
"exported_at_utc": str | None, # from _serving_meta.json
"last_generated": str | None, # max(updated_at) in articles
"row_count": int | None, # DuckDB row count from meta
"status": "fresh" | "stale" | "no_articles" | "no_data",
}
Freshness semantics:
"fresh" — articles generated after last data export (up to date)
"stale" — data export is newer than last article generation (regen needed)
"no_articles" — DuckDB data exists but no articles generated yet
"no_data" — _serving_meta.json absent (export_serving not yet run)
"""
meta = _read_serving_meta()
exported_at_str = meta.get("exported_at_utc")
exported_at = _parse_dt(exported_at_str)
table_meta = meta.get("tables", {})
result = []
for t in templates:
slug = t["slug"]
data_table = t.get("data_table", "")
# Strip schema prefix to match the key in _serving_meta.json tables dict.
# e.g. "serving.pseo_city_costs_de" → "pseo_city_costs_de"
table_key = data_table.split(".")[-1] if "." in data_table else data_table
rows = await fetch_all(
"SELECT MAX(COALESCE(updated_at, created_at)) as last_gen FROM articles"
" WHERE template_slug = ?",
(slug,),
)
last_gen_str = rows[0]["last_gen"] if rows else None
last_gen = _parse_dt(last_gen_str)
row_count = table_meta.get(table_key, {}).get("row_count")
if not exported_at_str:
status = "no_data"
elif last_gen is None:
status = "no_articles"
elif exported_at and last_gen and exported_at > last_gen:
# New data available — articles haven't been regenerated against it yet.
status = "stale"
else:
status = "fresh"
result.append({
"slug": slug,
"name": t.get("name", slug),
"data_table": data_table,
"exported_at_utc": exported_at_str,
"last_generated": last_gen_str,
"row_count": row_count,
"status": status,
})
return result
# ── Content gaps ──────────────────────────────────────────────────────────────
async def get_content_gaps(
template_slug: str,
data_table: str,
natural_key: str,
languages: list[str],
limit: int = 200,
) -> list[dict]:
"""Return DuckDB rows that have no matching article for at least one language.
The article slug is constructed as: "{template_slug}-{lang}-{natural_key_value}"
This lets us efficiently detect gaps without rendering URL patterns.
Returns list of dicts — each is the DuckDB row with two extra keys:
"_natural_key": str — the natural key value for this row
"_missing_languages": list[str] — languages with no article
"""
assert languages, "languages must not be empty"
_validate_table_name(data_table)
# Fetch all article slugs for this template to determine which rows exist.
slug_rows = await fetch_all(
"SELECT slug, language FROM articles WHERE template_slug = ?",
(template_slug,),
)
# Build lookup: (lang, natural_key_value) → True
prefix_by_lang = {lang: f"{template_slug}-{lang}-" for lang in languages}
existing: set[tuple[str, str]] = set()
for r in slug_rows:
lang = r["language"]
if lang not in prefix_by_lang:
continue
prefix = prefix_by_lang[lang]
if r["slug"].startswith(prefix):
nk_val = r["slug"][len(prefix):]
existing.add((lang, nk_val))
duckdb_rows = await fetch_analytics(
f"SELECT * FROM {data_table} LIMIT ?",
[limit],
)
gaps = []
for row in duckdb_rows:
nk_val = str(row.get(natural_key, ""))
missing = [lang for lang in languages if (lang, nk_val) not in existing]
if missing:
gaps.append({**row, "_natural_key": nk_val, "_missing_languages": missing})
return gaps
# ── Health checks ─────────────────────────────────────────────────────────────
async def check_hreflang_orphans(templates: list[dict]) -> list[dict]:
"""Published articles missing a sibling language expected by their template.
For example: city-cost-de generates EN + DE. If the EN article exists but
DE is absent, that article is an hreflang orphan.
Orphan detection is based on the slug pattern "{template_slug}-{lang}-{natural_key}".
Articles are grouped by natural key; if any expected language is missing, the group
is an orphan.
Returns list of dicts:
{
"template_slug": str,
"url_path": str, # url_path of one present article for context
"present_languages": list[str],
"missing_languages": list[str],
}
"""
orphans = []
for t in templates:
expected = set(t.get("languages", ["en"]))
if len(expected) < 2:
continue # Single-language template — no orphans possible.
rows = await fetch_all(
"SELECT slug, language, url_path FROM articles"
" WHERE template_slug = ? AND status = 'published'",
(t["slug"],),
)
# Group by natural key extracted from slug pattern:
# "{template_slug}-{lang}-{natural_key}" → strip template prefix, then lang prefix.
slug_prefix = t["slug"] + "-"
by_nk: dict[str, dict] = {} # nk → {"langs": set, "url_path": str}
for r in rows:
slug = r["slug"]
lang = r["language"]
if not slug.startswith(slug_prefix):
continue
rest = slug[len(slug_prefix):] # "{lang}-{natural_key}"
lang_prefix = lang + "-"
if not rest.startswith(lang_prefix):
continue
nk = rest[len(lang_prefix):]
if nk not in by_nk:
by_nk[nk] = {"langs": set(), "url_path": r["url_path"]}
by_nk[nk]["langs"].add(lang)
for nk, info in by_nk.items():
present = info["langs"]
missing = sorted(expected - present)
if missing:
orphans.append({
"template_slug": t["slug"],
"url_path": info["url_path"],
"present_languages": sorted(present),
"missing_languages": missing,
})
return orphans
async def check_missing_build_files(build_dir: Path | None = None) -> list[dict]:
"""Published articles whose HTML file is absent from disk.
Expected path: BUILD_DIR/{language}/{slug}.html
Returns list of dicts:
{"id", "slug", "language", "url_path", "template_slug", "expected_path"}
"""
bd = build_dir or BUILD_DIR
rows = await fetch_all(
"SELECT id, slug, language, url_path, template_slug FROM articles"
" WHERE status = 'published'",
)
missing = []
for r in rows:
path = bd / r["language"] / f"{r['slug']}.html"
if not path.exists():
missing.append({
"id": r["id"],
"slug": r["slug"],
"language": r["language"],
"url_path": r["url_path"],
"template_slug": r["template_slug"],
"expected_path": str(path),
})
return missing
async def check_broken_scenario_refs(build_dir: Path | None = None) -> list[dict]:
"""pSEO articles referencing [scenario:slug] markers that don't exist.
Reads markdown source from BUILD_DIR/{language}/md/{slug}.md.
Only checks published articles with a template_slug (pSEO-generated).
Returns list of dicts:
{"id", "slug", "language", "url_path", "broken_scenario_refs": [str, ...]}
"""
bd = build_dir or BUILD_DIR
scenario_rows = await fetch_all("SELECT slug FROM published_scenarios")
valid_slugs = {r["slug"] for r in scenario_rows}
articles = await fetch_all(
"SELECT id, slug, language, url_path FROM articles"
" WHERE status = 'published' AND template_slug IS NOT NULL",
)
broken = []
for a in articles:
md_path = bd / a["language"] / "md" / f"{a['slug']}.md"
if not md_path.exists():
continue
markdown = md_path.read_text()
refs = {m.group(1) for m in _SCENARIO_REF_RE.finditer(markdown)}
missing_refs = sorted(refs - valid_slugs)
if missing_refs:
broken.append({
"id": a["id"],
"slug": a["slug"],
"language": a["language"],
"url_path": a["url_path"],
"broken_scenario_refs": missing_refs,
})
return broken
# ── Aggregate check ───────────────────────────────────────────────────────────
async def get_all_health_issues(
templates: list[dict],
build_dir: Path | None = None,
) -> dict:
"""Run all health checks, return issue counts and full detail lists.
Returns:
{
"hreflang_orphans": [...],
"missing_build_files": [...],
"broken_scenario_refs": [...],
"counts": {
"hreflang_orphans": N,
"missing_build_files": N,
"broken_scenario_refs": N,
"total": N,
},
}
"""
orphans = await check_hreflang_orphans(templates)
missing_files = await check_missing_build_files(build_dir)
broken_refs = await check_broken_scenario_refs(build_dir)
return {
"hreflang_orphans": orphans,
"missing_build_files": missing_files,
"broken_scenario_refs": broken_refs,
"counts": {
"hreflang_orphans": len(orphans),
"missing_build_files": len(missing_files),
"broken_scenario_refs": len(broken_refs),
"total": len(orphans) + len(missing_files) + len(broken_refs),
},
}

View File

@@ -20,8 +20,8 @@ priority_column: population
<div class="stats-strip__value">{{ padel_venue_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Market Score</div>
<div class="stats-strip__value">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if market_score >= 65 %}#16A34A{% elif market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Spitzenpreis</div>
@@ -33,7 +33,7 @@ priority_column: population
</div>
</div>
{{ city_name }} erreicht einen **<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 70 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 45 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.
{{ city_name }} erreicht einen **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 70 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 45 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.
Die entscheidende Frage für Investoren: Was bringt ein Padel-Investment bei den aktuellen Preisen, Auslastungsraten und Baukosten tatsächlich? Das Finanzmodell unten rechnet mit echten Marktdaten aus {{ city_name }}.
@@ -92,23 +92,41 @@ Eine detaillierte Preisanalyse mit Preisspannen und Vergleichsdaten findest Du a
## FAQ
**Ist {{ city_name }} ein guter Standort für eine Padelhalle?**
<details>
<summary>Ist {{ city_name }} ein guter Standort für eine Padelhalle?</summary>
{{ city_name }} erreicht **{{ market_score | round(1) }}/100** auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score, der Bevölkerungsgröße, Anlagendichte und Datenqualität berücksichtigt. {% if market_score >= 70 %}Ein Score über 70 signalisiert einen starken Markt: große Bevölkerung, wachsende Anlagenzahl und belastbare Preisdaten. {% elif market_score >= 45 %}Ein mittlerer Score bedeutet solide Grundlagen, aber einen teils stärker umkämpften oder datenlimitierten Markt. {% else %}Ein niedrigerer Score spricht für eine kleinere Stadt, begrenzte Datenlage oder einen Markt im Aufbau — was gleichzeitig weniger Wettbewerb und First-Mover-Vorteile bedeuten kann. {% endif %}Mit dem [Finanzplaner](/{{ language }}/planner) kannst Du Deine eigenen Annahmen durchrechnen.
</details>
<details>
<summary>Wie hoch ist die Rendite einer Padelhalle in {{ city_name }}?</summary>
**Wie hoch ist die Rendite einer Padelhalle in {{ city_name }}?**
Die Rendite hängt von Deinen Baukosten, der Courtanzahl, Preisgestaltung und Auslastungsannahmen ab. Das Finanzmodell oben nutzt echte Marktdaten aus {{ city_name }} als Ausgangswerte — Spitzenpreis {% if median_peak_rate %}{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std{% else %}geschätzt auf Basis regionaler Benchmarks{% endif %}, geschätzte Auslastung {% if median_occupancy_rate %}{{ (median_occupancy_rate * 100) | round(0) | int }}%{% else %}basierend auf Landesdurchschnitt{% endif %}. [Passe die Eingaben im Planer an](/{{ language }}/planner), um Dein Szenario zu vergleichen.
</details>
<details>
<summary>Was kostet es, eine Padelhalle in {{ city_name }} zu bauen?</summary>
**Was kostet es, eine Padelhalle in {{ city_name }} zu bauen?**
Das Gesamtinvestment hängt vom Hallentyp (Indoor vs. Outdoor), Grundstückskosten und lokalen Baustandards in {{ country_name_en }} ab. Das CAPEX-Modell oben schlüsselt die wichtigsten Kostentreiber für einen typischen Bau in {{ city_name }} auf.
</details>
<details>
<summary>Wie viele Padelplätze gibt es in {{ city_name }}?</summary>
**Wie viele Padelplätze gibt es in {{ city_name }}?**
{{ city_name }} hat **{{ padel_venue_count }} Padelanlagen**. Bei {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohnern entspricht das **{{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner**.
</details>
<details>
<summary>Was kosten Padel-Courts in {{ city_name }}?</summary>
**Was kosten Padel-Courts in {{ city_name }}?**
{% if median_peak_rate %}Zu Hauptzeiten liegen die Preise bei durchschnittlich **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std**, in Nebenzeiten bei ca. **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**. Die Daten stammen aus Live-Buchungsdaten von Playtomic.{% else %}Für {{ city_name }} liegen noch keine Playtomic-Preisdaten vor. Das Finanzmodell nutzt Benchmarks aus {{ country_name_en }} als Näherung.{% endif %}
</details>
<details>
<summary>Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
**Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?**
Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score von {{ market_score | round(1) }}/100 zeigt {{ city_name }}s Position unter den erfassten Städten in {{ country_name_en }}. In der [Marktübersicht für {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Bereit für Deine eigene Kalkulation? →
@@ -127,8 +145,8 @@ Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;co
<div class="stats-strip__value">{{ padel_venue_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Market Score</div>
<div class="stats-strip__value">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if market_score >= 65 %}#16A34A{% elif market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Peak Rate</div>
@@ -140,7 +158,7 @@ Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;co
</div>
</div>
{{ city_name }} has a **<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 70 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 45 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
{{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 70 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 45 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
The question investors actually need answered is: given current pricing, occupancy, and build costs, what does the return look like? The financial model below uses real {{ city_name }} market data to give you that answer.
@@ -199,23 +217,41 @@ For a detailed pricing breakdown with price ranges and venue comparisons, see th
## FAQ
**Is {{ city_name }} a good location for a padel center?**
<details>
<summary>Is {{ city_name }} a good location for a padel center?</summary>
{{ city_name }} scores **{{ market_score | round(1) }}/100** on the <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score, which accounts for population size, existing venue density, and data completeness. {% if market_score >= 70 %}A score above 70 indicates a strong market: high population, growing venue count, and solid pricing data. {% elif market_score >= 45 %}A mid-range score means decent fundamentals but a more competitive or data-limited market. {% else %}A lower score reflects either a smaller city, sparse venue data, or an early-stage market — which can also mean lower competition and first-mover advantage. {% endif %}Use the [Padelnomics planner](/{{ language }}/planner) to model your specific assumptions.
</details>
<details>
<summary>What is the return on investment for a padel center in {{ city_name }}?</summary>
**What is the return on investment for a padel center in {{ city_name }}?**
ROI depends on your build cost, court count, pricing, and occupancy assumptions. The financial model above uses real {{ city_name }} market data as defaults — peak rate {% if median_peak_rate %}{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr{% else %}estimated from regional benchmarks{% endif %}, estimated occupancy {% if median_occupancy_rate %}{{ (median_occupancy_rate * 100) | round(0) | int }}%{% else %}based on country averages{% endif %}. [Adjust the inputs in the planner](/{{ language }}/planner) to see how your scenario compares.
</details>
<details>
<summary>How much does it cost to build a padel center in {{ city_name }}?</summary>
**How much does it cost to build a padel center in {{ city_name }}?**
Total investment depends on venue type (indoor vs outdoor), land costs, and local construction standards in {{ country_name_en }}. The capex model above breaks down the key cost drivers for a typical {{ city_name }} build based on current market assumptions.
</details>
<details>
<summary>How many padel courts are there in {{ city_name }}?</summary>
**How many padel courts are there in {{ city_name }}?**
{{ city_name }} has **{{ padel_venue_count }} padel venues**. With a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %}, that translates to **{{ venues_per_100k | round(1) }} venues per 100,000 residents**.
</details>
<details>
<summary>What are typical padel court rental prices in {{ city_name }}?</summary>
**What are typical padel court rental prices in {{ city_name }}?**
{% if median_peak_rate %}Peak hour rates average around **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr**, while off-peak rates are approximately **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr**. These figures come from live Playtomic booking data.{% else %}Pricing data from Playtomic is not yet available for {{ city_name }}. The financial model uses {{ country_name_en }}-wide benchmarks as a proxy.{% endif %}
</details>
<details>
<summary>How does {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
**How does {{ city_name }} compare to other {{ country_name_en }} cities?**
{{ city_name }}'s <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ market_score | round(1) }}/100 reflects its ranking among tracked {{ country_name_en }} cities. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full comparison across cities.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Ready to run the numbers for {{ city_name }}? →

View File

@@ -55,7 +55,7 @@ Die Preisspanne von {{ hourly_rate_p25 | round(0) | int }} bis {{ hourly_rate_p7
## Wie steht {{ city_name }} im Vergleich da?
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 65 %}Mit einem Market Score von {{ market_score | round(1) }}/100 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 40 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %}
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 65 %}Mit einem <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> von {{ market_score | round(1) }}/100 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 40 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %}
Die Anlagendichte von {{ venues_per_100k | round(1) }} pro 100K Einwohner beeinflusst die Preisgestaltung direkt: {% if venues_per_100k >= 3.0 %}Höhere Dichte bedeutet mehr Wettbewerb, was die Preise eher stabilisiert oder senkt.{% elif venues_per_100k >= 1.0 %}Moderate Dichte ermöglicht marktgerechte Preise bei gleichzeitigem Wachstumsspielraum.{% else %}Niedrige Dichte gibt Betreibern mehr Preissetzungsmacht — vorausgesetzt, die Nachfrage ist da.{% endif %}
@@ -86,20 +86,35 @@ Diese Preisdaten fließen direkt in das Finanzmodell für {{ city_name }} ein:
## FAQ
**Was kostet eine Padel-Stunde in {{ city_name }}?**
<details>
<summary>Was kostet eine Padel-Stunde in {{ city_name }}?</summary>
Der mediane Stundenpreis in {{ city_name }} liegt bei **{{ median_hourly_rate | round(0) | int }} {{ price_currency }}/Std** — zu Hauptzeiten **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std**, in Nebenzeiten **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**. Die günstigsten Anlagen starten bei {{ hourly_rate_p25 | round(0) | int }} {{ price_currency }}/Std, Premium-Anlagen verlangen {{ hourly_rate_p75 | round(0) | int }} {{ price_currency }}/Std oder mehr. Datenbasis: {{ venue_count }} aktive Playtomic-Anlagen.
</details>
<details>
<summary>Wann ist Padel in {{ city_name }} am günstigsten?</summary>
**Wann ist Padel in {{ city_name }} am günstigsten?**
In Nebenzeiten — typischerweise vormittags und am frühen Nachmittag unter der Woche — liegen die Preise bei ca. **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**, verglichen mit **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std** zu Hauptzeiten.
</details>
<details>
<summary>Wie viele Padelanlagen gibt es in {{ city_name }}?</summary>
**Wie viele Padelanlagen gibt es in {{ city_name }}?**
{{ city_name }} hat **{{ padel_venue_count }} Padelanlagen** insgesamt. Diese Preisanalyse erfasst **{{ venue_count }} Anlagen** mit ausreichend Playtomic-Buchungsdaten.
</details>
<details>
<summary>Wie schneidet {{ city_name }} preislich im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
**Wie schneidet {{ city_name }} preislich im Vergleich zu anderen Städten in {{ country_name_en }} ab?**
Die Preise in {{ city_name }} liegen {% if median_peak_rate >= 40 %}im oberen Bereich{% elif median_peak_rate >= 25 %}im Mittelfeld{% else %}unter dem Durchschnitt{% endif %} für {{ country_name_en }}. In der [Marktübersicht {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
</details>
<details>
<summary>Steigen oder fallen die Padel-Preise in {{ city_name }}?</summary>
**Steigen oder fallen die Padel-Preise in {{ city_name }}?**
Die aktuellen Daten sind eine Momentaufnahme auf Basis von Playtomic-Livedaten. Generell stabilisieren sich Preise in reiferen Märkten, während sie in wachsenden Märkten tendenziell steigen. Im [Finanzplaner](/{{ language }}/planner) kannst Du verschiedene Preisszenarien durchrechnen.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Nutze die {{ city_name }}-Preisdaten für Deinen Businessplan →
@@ -153,7 +168,7 @@ The P25P75 price range of {{ hourly_rate_p25 | round(0) | int }} to {{ hourly
## How Does {{ city_name }} Compare?
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 65 %}With a market score of {{ market_score | round(1) }}/100, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 40 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %}
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 65 %}With a <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 40 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %}
Venue density of {{ venues_per_100k | round(1) }} per 100K residents directly influences pricing: {% if venues_per_100k >= 3.0 %}higher density means more competition, which tends to stabilize or compress prices.{% elif venues_per_100k >= 1.0 %}moderate density supports market-rate pricing with room for growth.{% else %}low density gives operators more pricing power — provided demand exists.{% endif %}
@@ -184,20 +199,35 @@ These pricing numbers feed directly into the financial model for {{ city_name }}
## FAQ
**How much does it cost to rent a padel court in {{ city_name }}?**
<details>
<summary>How much does it cost to rent a padel court in {{ city_name }}?</summary>
The median padel court rental rate in {{ city_name }} is **{{ median_hourly_rate | round(0) | int }} {{ price_currency }}/hr** overall — **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr** at peak times and **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr** off-peak. The cheapest venues charge from {{ hourly_rate_p25 | round(0) | int }} {{ price_currency }}/hr; premium venues reach {{ hourly_rate_p75 | round(0) | int }} {{ price_currency }}/hr or more. Data comes from {{ venue_count }} active Playtomic venues.
</details>
<details>
<summary>When are padel courts cheapest in {{ city_name }}?</summary>
**When are padel courts cheapest in {{ city_name }}?**
Off-peak slots — typically weekday mornings and early afternoons — are priced at around **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr**, compared to **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr** during peak hours.
</details>
<details>
<summary>How many padel venues are there in {{ city_name }}?</summary>
**How many padel venues are there in {{ city_name }}?**
{{ city_name }} has **{{ padel_venue_count }} padel venues** in total. This pricing analysis covers **{{ venue_count }} venues** with sufficient Playtomic booking data.
</details>
<details>
<summary>How does padel pricing in {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
**How does padel pricing in {{ city_name }} compare to other {{ country_name_en }} cities?**
{{ city_name }}'s pricing sits {% if median_peak_rate >= 40 %}at the higher end{% elif median_peak_rate >= 25 %}in the mid-range{% else %}below average{% endif %} for {{ country_name_en }}. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full city-by-city comparison.
</details>
<details>
<summary>Are padel court prices in {{ city_name }} going up or down?</summary>
**Are padel court prices in {{ city_name }} going up or down?**
The current data is a snapshot based on live Playtomic booking data. In general, prices stabilise in mature markets and tend to rise in growing ones. Use the [financial planner](/{{ language }}/planner) to model different pricing scenarios and stress-test your business plan.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Use {{ city_name }} pricing data in your business plan →

View File

@@ -25,8 +25,8 @@ priority_column: total_venues
<div class="stats-strip__value">{{ city_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Ø Market Score</div>
<div class="stats-strip__value">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if avg_market_score >= 65 %}#16A34A{% elif avg_market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Median Spitzenpreis</div>
@@ -34,7 +34,7 @@ priority_column: total_venues
</div>
</div>
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 40 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}.
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 40 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}.
## Marktlandschaft
@@ -82,20 +82,35 @@ Jede Stadt hat andere Kostenstrukturen, Wettbewerbsbedingungen und Zielgruppen.
## FAQ
**Wie viele Padel-Courts gibt es in {{ country_name_en }}?**
<details>
<summary>Wie viele Padel-Courts gibt es in {{ country_name_en }}?</summary>
Wir erfassen aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten** in {{ country_name_en }}. Unsere Daten stammen von Playtomic und aus Overpass/OpenStreetMap. Die tatsächliche Zahl liegt vermutlich höher, da unabhängige Clubs ohne Buchungsplattform nicht immer erfasst werden.
</details>
<details>
<summary>Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?</summary>
**Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?**
Unsere Spitzenstadt nach <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score ist **{{ top_city_names[0] }}** (Score: {{ top_city_market_score }}/100). Der Score kombiniert Bevölkerungsgröße, Anlagendichte und Datenqualität. Eine hohe Punktzahl deutet auf einen großen adressierbaren Markt mit validierten Preisdaten hin. Die beste Stadt für *Dein* Vorhaben hängt aber von Faktoren wie Flächenverfügbarkeit, lokalem Wettbewerb und Deiner Zielgruppe ab. Nutze den <a href="/{{ language }}/planner">Finanzplaner</a>, um verschiedene Standorte durchzurechnen.
</details>
<details>
<summary>Was kostet eine Stunde Padel in {{ country_name_en }}?</summary>
**Was kostet eine Stunde Padel in {{ country_name_en }}?**
{% if median_peak_rate %}Laut Live-Daten von Playtomic liegt der Median-Hauptzeitpreis in {{ country_name_en }} bei **{{ median_peak_rate | int }} {{ price_currency }}/Std**, Nebenzeit bei **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/Std**. Die Preise variieren stark zwischen Städten — auf den jeweiligen Stadtseiten findest Du lokale Benchmarks.{% else %}Aggregierte Preisdaten sind für {{ country_name_en }} noch nicht verfügbar. Prüfe die einzelnen Stadtseiten für lokale Daten.{% endif %}
</details>
<details>
<summary>Wie schnell wächst Padel in {{ country_name_en }}?</summary>
**Wie schnell wächst Padel in {{ country_name_en }}?**
Padel gehört zu den am schnellsten wachsenden Racketsportarten in Europa. Mit {{ total_venues }} erfassten Anlagen in {{ city_count }} Städten zeigt {{ country_name_en }} {% if avg_market_score >= 65 %}bereits eine reife Infrastruktur — Wachstum kommt hier vor allem aus steigender Spielfrequenz und Premiumangeboten{% elif avg_market_score >= 40 %}eine klare Wachstumsdynamik mit steigender Nachfrage und neuen Anlagen{% else %}ein frühes Wachstumsstadium mit großem Potenzial für Neueintritte{% endif %}. Die Sportart profitiert von niedriger Einstiegshürde, hohem Spaßfaktor und starker Mund-zu-Mund-Verbreitung.
</details>
<details>
<summary>Welche Städte haben die besten Preisdaten?</summary>
**Welche Städte haben die besten Preisdaten?**
Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score (wie {{ top_city_names[0] }}) haben in der Regel die umfassendsten Preisdaten, weil dort mehr Anlagen auf Playtomic gelistet sind. In unserem <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }}-Marktüberblick</a> findest Du alle Städte nach Market Score sortiert.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Du überlegst, eine Padelhalle in {{ country_name_en }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
@@ -115,8 +130,8 @@ Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;f
<div class="stats-strip__value">{{ city_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Avg Market Score</div>
<div class="stats-strip__value">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if avg_market_score >= 65 %}#16A34A{% elif avg_market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Median Peak Rate</div>
@@ -124,7 +139,7 @@ Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;f
</div>
</div>
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 40 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}.
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 40 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}.
## Market Landscape
@@ -172,20 +187,35 @@ Every city has a different cost structure, competitive landscape, and customer b
## FAQ
**How many padel courts are there in {{ country_name_en }}?**
<details>
<summary>How many padel courts are there in {{ country_name_en }}?</summary>
We currently track **{{ total_venues }} padel venues** across **{{ city_count }} cities** in {{ country_name_en }}. This covers venues listed on Playtomic and venues identified through our Overpass/OpenStreetMap data source. The actual total may be higher as independent clubs not listed on booking platforms are not always captured.
</details>
<details>
<summary>Which city in {{ country_name_en }} is best for a padel center?</summary>
**Which city in {{ country_name_en }} is best for a padel center?**
Our top-ranked city by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score is **{{ top_city_names[0] }}** (score: {{ top_city_market_score }}/100). The <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score combines population size, existing venue density, and data quality — a high score indicates a large addressable market with validated pricing data. However, the best city for *you* depends on land availability, local competition, and your target customer profile. Use the <a href="/{{ language }}/planner">financial planner</a> to model different locations side by side.
</details>
<details>
<summary>What are typical padel court prices in {{ country_name_en }}?</summary>
**What are typical padel court prices in {{ country_name_en }}?**
{% if median_peak_rate %}Based on live Playtomic data, median peak rates across {{ country_name_en }} cities are **{{ median_peak_rate | int }} {{ price_currency }}/hr** and off-peak rates are around **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/hr**. Individual cities vary — see each city's page for local benchmarks.{% else %}Pricing data is not yet available in aggregate for {{ country_name_en }}. Check individual city pages where Playtomic data is available.{% endif %}
</details>
<details>
<summary>How fast is padel growing in {{ country_name_en }}?</summary>
**How fast is padel growing in {{ country_name_en }}?**
Padel is one of the fastest-growing racquet sports in Europe. With {{ total_venues }} venues tracked across {{ city_count }} cities, {{ country_name_en }} shows {% if avg_market_score >= 65 %}a mature infrastructure — growth here comes mainly from increasing play frequency and premium offerings{% elif avg_market_score >= 40 %}clear growth momentum with rising demand and new venues opening{% else %}early-stage growth with significant potential for new entrants{% endif %}. The sport benefits from a low barrier to entry, high enjoyment factor, and strong word-of-mouth growth among players.
</details>
<details>
<summary>Which cities have the best pricing data?</summary>
**Which cities have the best pricing data?**
Cities with higher <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Scores (like {{ top_city_names[0] }}) typically have the most comprehensive pricing data, because more venues are listed on Playtomic. Browse our <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }} market overview</a> to see all cities ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →

View File

@@ -8,7 +8,7 @@
{% endif %}
<div class="flex items-center gap-2">
{% if article.country %}
<span class="badge">{{ article.country }}</span>
<span class="badge">{{ article.country | country_name(lang) }}</span>
{% endif %}
{% if article.region %}
<span class="text-xs text-slate">{{ article.region }}</span>

View File

@@ -4,19 +4,25 @@ Core infrastructure: database, config, email, and shared utilities.
import hashlib
import hmac
import logging
import os
import random
import re
import secrets
import unicodedata
from contextvars import ContextVar
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from functools import wraps
from pathlib import Path
import aiosqlite
import resend
from dotenv import load_dotenv
# Cap all Resend API calls at 10 s — the default RequestsClient timeout is 30 s.
# These calls run synchronously on the event loop thread; a shorter cap limits stalls.
_RESEND_TIMEOUT_SECONDS = 10
resend.default_http_client = resend.RequestsClient(timeout=_RESEND_TIMEOUT_SECONDS)
from quart import g, make_response, render_template, request, session
load_dotenv()
@@ -71,7 +77,7 @@ class Config:
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
RATE_LIMIT_WINDOW_SECONDS: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
PLAN_FEATURES: dict = {
"free": ["basic"],
@@ -88,6 +94,45 @@ class Config:
config = Config()
def setup_logging() -> None:
"""Configure root logger. Call once from each entry point (app, worker, scripts)."""
level_name = os.environ.get("LOG_LEVEL", "DEBUG" if config.DEBUG else "INFO")
level = getattr(logging, level_name.upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logging.getLogger("granian").setLevel(logging.WARNING)
logging.getLogger("granian.access").setLevel(logging.WARNING)
logging.getLogger("hypercorn").setLevel(logging.WARNING)
logging.getLogger("hypercorn.error").setLevel(logging.WARNING)
logging.getLogger("hypercorn.access").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# =============================================================================
# Datetime helpers
# =============================================================================
def utcnow() -> datetime:
"""Timezone-aware UTC now (replaces deprecated datetime.utcnow())."""
return datetime.now(UTC)
def utcnow_iso() -> str:
"""UTC now as naive ISO string for SQLite TEXT columns.
Produces YYYY-MM-DD HH:MM:SS (space separator, no +00:00 suffix) to match
SQLite's native datetime('now') format so lexicographic SQL comparisons work.
"""
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
# =============================================================================
# Database
# =============================================================================
@@ -106,7 +151,8 @@ async def init_db(path: str = None) -> None:
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
await _db.execute("PRAGMA busy_timeout=5000")
_BUSY_TIMEOUT_MS = 5000
await _db.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
await _db.execute("PRAGMA synchronous=NORMAL")
await _db.execute("PRAGMA cache_size=-64000")
await _db.execute("PRAGMA temp_store=MEMORY")
@@ -364,7 +410,7 @@ async def send_email(
resend_id = None
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
logger.info("Would send to %s: %s", to, subject)
resend_id = "dev"
else:
resend.api_key = config.RESEND_API_KEY
@@ -380,7 +426,7 @@ async def send_email(
)
resend_id = result.get("id") if isinstance(result, dict) else getattr(result, "id", None)
except Exception as e:
print(f"[EMAIL] Error sending to {to}: {e}")
logger.error("Error sending to %s: %s", to, e)
return None
# Log to email_log (best-effort, never fail the send)
@@ -391,7 +437,7 @@ async def send_email(
(resend_id, sender, to, subject, email_type),
)
except Exception as e:
print(f"[EMAIL] Failed to log email: {e}")
logger.error("Failed to log email: %s", e)
return resend_id
@@ -417,6 +463,7 @@ async def _get_or_create_resend_audience(name: str) -> str | None:
)
return audience_id
except Exception:
logger.exception("Failed to create Resend audience %r", name)
return None
@@ -457,7 +504,8 @@ async def capture_waitlist_email(
)
is_new = cursor_result > 0
except Exception:
# If anything fails, treat as not-new to avoid double-sending
logger.exception("Failed to insert waitlist entry for %r", email)
# Treat as not-new to avoid double-sending on retry
is_new = False
# Enqueue confirmation email only if new
@@ -479,7 +527,8 @@ async def capture_waitlist_email(
resend.api_key = config.RESEND_API_KEY
resend.Contacts.create({"email": email, "audience_id": audience_id})
except Exception:
pass # Silent fail
# Non-critical: audience sync failure doesn't affect waitlist capture
logger.warning("Failed to add %r to Resend audience %r", email, audience_name, exc_info=True)
return is_new
@@ -527,18 +576,19 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
Uses SQLite for storage - no Redis needed.
"""
limit = limit or config.RATE_LIMIT_REQUESTS
window = window or config.RATE_LIMIT_WINDOW
now = datetime.utcnow()
window = window or config.RATE_LIMIT_WINDOW_SECONDS
now = utcnow()
window_start = now - timedelta(seconds=window)
# Clean old entries and count recent
await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat())
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
(key, window_start.strftime("%Y-%m-%d %H:%M:%S")),
)
result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
(key, window_start.isoformat()),
(key, window_start.strftime("%Y-%m-%d %H:%M:%S")),
)
count = result["count"] if result else 0
@@ -552,7 +602,10 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
return False, info
# Record this request
await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat()))
await execute(
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.strftime("%Y-%m-%d %H:%M:%S")),
)
return True, info
@@ -628,7 +681,7 @@ async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted."""
result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id),
(utcnow_iso(), id),
)
return result > 0
@@ -647,7 +700,7 @@ async def hard_delete(table: str, id: int) -> bool:
async def purge_deleted(table: str, days: int = 30) -> int:
"""Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
)

View File

@@ -5,9 +5,7 @@ All balance mutations go through this module to keep credit_ledger (source of tr
and suppliers.credit_balance (denormalized cache) in sync within a single transaction.
"""
from datetime import datetime
from .core import execute, fetch_all, fetch_one, transaction
from .core import execute, fetch_all, fetch_one, transaction, utcnow_iso
# Credit cost per heat tier
HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8}
@@ -44,7 +42,7 @@ async def add_credits(
note: str = None,
) -> int:
"""Add credits to a supplier. Returns new balance."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with transaction() as db:
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
@@ -73,7 +71,7 @@ async def spend_credits(
note: str = None,
) -> int:
"""Spend credits from a supplier. Returns new balance. Raises InsufficientCredits."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with transaction() as db:
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
@@ -116,7 +114,7 @@ async def unlock_lead(supplier_id: int, lead_id: int) -> dict:
raise ValueError("Lead not found")
cost = lead["credit_cost"] or compute_credit_cost(lead)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with transaction() as db:
# Check balance
@@ -180,7 +178,7 @@ async def monthly_credit_refill(supplier_id: int) -> int:
if not row or not row["monthly_credits"]:
return 0
now = datetime.utcnow().isoformat()
now = utcnow_iso()
new_balance = await add_credits(
supplier_id,
row["monthly_credits"],
@@ -201,6 +199,6 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]:
FROM credit_ledger cl
LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock'
WHERE cl.supplier_id = ?
ORDER BY cl.created_at DESC LIMIT ?""",
ORDER BY cl.created_at DESC, cl.id DESC LIMIT ?""",
(supplier_id, limit),
)

View File

@@ -1,13 +1,12 @@
"""
Dashboard domain: user dashboard and settings.
"""
from datetime import datetime
from pathlib import Path
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
from ..auth.routes import login_required, update_user
from ..core import csrf_protect, fetch_one, soft_delete
from ..core import csrf_protect, fetch_one, soft_delete, utcnow_iso
from ..i18n import get_translations
bp = Blueprint(
@@ -57,7 +56,7 @@ async def settings():
await update_user(
g.user["id"],
name=form.get("name", "").strip() or None,
updated_at=datetime.utcnow().isoformat(),
updated_at=utcnow_iso(),
)
t = get_translations(g.get("lang") or "en")
await flash(t["dash_settings_saved"], "success")

View File

@@ -2,13 +2,12 @@
Supplier directory: public, searchable listing of padel court suppliers.
"""
from datetime import UTC, datetime
from pathlib import Path
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
from ..core import csrf_protect, execute, fetch_all, fetch_one
from ..i18n import get_translations
from ..core import csrf_protect, execute, fetch_all, fetch_one, utcnow_iso
from ..i18n import COUNTRY_LABELS, get_translations
bp = Blueprint(
"directory",
@@ -17,41 +16,6 @@ bp = Blueprint(
template_folder=str(Path(__file__).parent / "templates"),
)
COUNTRY_LABELS = {
"DE": "Germany",
"ES": "Spain",
"IT": "Italy",
"FR": "France",
"PT": "Portugal",
"GB": "United Kingdom",
"NL": "Netherlands",
"BE": "Belgium",
"SE": "Sweden",
"DK": "Denmark",
"FI": "Finland",
"NO": "Norway",
"AT": "Austria",
"SI": "Slovenia",
"IS": "Iceland",
"CH": "Switzerland",
"EE": "Estonia",
"US": "United States",
"CA": "Canada",
"MX": "Mexico",
"BR": "Brazil",
"AR": "Argentina",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
"CN": "China",
"IN": "India",
"SG": "Singapore",
"ID": "Indonesia",
"TH": "Thailand",
"AU": "Australia",
"ZA": "South Africa",
"EG": "Egypt",
}
CATEGORY_LABELS = {
"manufacturer": "Manufacturer",
@@ -89,7 +53,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
lang = g.get("lang", "en")
cat_labels, country_labels, region_labels = get_directory_labels(lang)
now = datetime.now(UTC).isoformat()
now = utcnow_iso()
params: list = []
wheres: list[str] = []

View File

@@ -0,0 +1,307 @@
"""
Standalone Jinja2 email template renderer.
Used by both the worker (outside Quart request context) and admin gallery routes.
Creates a module-level Environment pointing at the same templates/ directory
used by the web app, so templates share the same file tree.
Usage:
from .email_templates import render_email_template, EMAIL_TEMPLATE_REGISTRY
html = render_email_template("emails/magic_link.html", lang="en", link=link, expiry_minutes=15)
"""
from pathlib import Path
import jinja2
from .core import config, utcnow
from .i18n import get_translations
_TEMPLATES_DIR = Path(__file__).parent / "templates"
# Standalone environment — not tied to Quart's request context.
# autoescape=True: user-supplied data (names, emails, messages) is auto-escaped.
# Trusted HTML sections use the `| safe` filter explicitly.
_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=True,
undefined=jinja2.StrictUndefined,
)
def _tformat(s: str, **kwargs) -> str:
"""Jinja filter: interpolate {placeholders} into a translation string.
Mirrors the `tformat` filter registered in app.py so email templates
and web templates use the same syntax:
{{ t.some_key | tformat(name=supplier.name, count=n) }}
"""
if not kwargs:
return s
return s.format(**kwargs)
_env.filters["tformat"] = _tformat
def render_email_template(template_name: str, lang: str = "en", **kwargs) -> str:
"""Render an email template with standard context injected.
Args:
template_name: Path relative to templates/ (e.g. "emails/magic_link.html").
lang: Language code ("en" or "de"). Used for translations + html lang attr.
**kwargs: Additional context variables passed to the template.
Returns:
Rendered HTML string containing a full <!DOCTYPE html> document.
"""
assert lang in ("en", "de"), f"Unsupported lang: {lang!r}"
assert template_name.startswith("emails/"), f"Expected emails/ prefix: {template_name!r}"
translations = get_translations(lang)
year = utcnow().year
# Pre-interpolate footer strings so templates don't need to call tformat on them.
tagline = translations.get("email_footer_tagline", "")
copyright_text = translations.get("email_footer_copyright", "").format(
year=year, app_name=config.APP_NAME
)
context = {
"lang": lang,
"app_name": config.APP_NAME,
"base_url": config.BASE_URL,
"t": translations,
"tagline": tagline,
"copyright_text": copyright_text,
**kwargs,
}
tmpl = _env.get_template(template_name)
rendered = tmpl.render(**context)
assert "<!DOCTYPE html>" in rendered, f"Template {template_name!r} must produce a DOCTYPE document"
assert "padelnomics" in rendered.lower(), f"Template {template_name!r} must include the wordmark"
return rendered
# =============================================================================
# Template registry — used by admin gallery for sample preview rendering
# =============================================================================
def _magic_link_sample(lang: str) -> dict:
return {
"link": f"{config.BASE_URL}/auth/verify?token=sample_token_abc123",
"expiry_minutes": 15,
"preheader": get_translations(lang).get("email_magic_link_preheader", "").format(expiry_minutes=15),
}
def _quote_verification_sample(lang: str) -> dict:
t = get_translations(lang)
court_count = "4"
return {
"link": f"{config.BASE_URL}/{lang}/leads/verify?token=verify123&lead=lead456",
"first_name": "Alex",
"court_count": court_count,
"facility_type": "Indoor Padel Club",
"country": "Germany",
"recap_parts": ["4 courts", "Indoor Padel Club", "Germany"],
"preheader": t.get("email_quote_verify_preheader_courts", "").format(court_count=court_count),
}
def _welcome_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"first_name": "Maria",
"preheader": t.get("email_welcome_preheader", ""),
}
def _waitlist_supplier_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"plan_name": "Growth",
"preheader": t.get("email_waitlist_supplier_preheader", ""),
}
def _waitlist_general_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"preheader": t.get("email_waitlist_general_preheader", ""),
}
def _lead_matched_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"first_name": "Thomas",
"facility_type": "padel",
"court_count": "6",
"country": "Austria",
"preheader": t.get("email_lead_matched_preheader", ""),
}
def _lead_forward_sample(lang: str) -> dict:
return {
"heat": "HOT",
"country": "Spain",
"courts": "8",
"budget": "450000",
"facility_type": "Outdoor Padel Club",
"timeline": "Q3 2025",
"contact_email": "ceo@padelclub.es",
"contact_name": "Carlos Rivera",
"contact_phone": "+34 612 345 678",
"contact_company": "PadelClub Madrid SL",
"stakeholder_type": "Developer / Investor",
"build_context": "New build",
"glass_type": "Panoramic",
"lighting_type": "LED",
"location": "Madrid",
"location_status": "Site confirmed",
"financing_status": "Self-financed",
"services_needed": "Full turnkey construction",
"additional_info": "Seeking experienced international suppliers only.",
"cta_url": f"{config.BASE_URL}/suppliers/leads/cta/sample_cta_token",
"preheader": "Outdoor Padel Club project · Q3 2025 timeline — contact details inside",
"brief_rows": [
("Facility", "Outdoor Padel Club (New build)"),
("Courts", "8 | Glass: Panoramic | Lighting: LED"),
("Location", "Madrid, Spain"),
("Timeline", "Q3 2025 | Budget: €450000"),
("Phase", "Site confirmed | Financing: Self-financed"),
("Services", "Full turnkey construction"),
("Additional Info", "Seeking experienced international suppliers only."),
],
}
def _lead_match_notify_sample(lang: str) -> dict:
return {
"heat": "WARM",
"country": "Netherlands",
"courts": "4",
"facility_type": "Indoor Padel",
"timeline": "Q1 2026",
"credit_cost": 2,
"preheader": "New matching lead in Netherlands",
}
def _weekly_digest_sample(lang: str) -> dict:
return {
"leads": [
{"heat": "HOT", "facility_type": "Outdoor Padel", "court_count": "6", "country": "Germany", "timeline": "Q2 2025"},
{"heat": "WARM", "facility_type": "Indoor Club", "court_count": "4", "country": "Austria", "timeline": "Q3 2025"},
{"heat": "COOL", "facility_type": "Padel Centre", "court_count": "8", "country": "Switzerland", "timeline": "2026"},
],
"preheader": "3 new leads matching your service area",
}
def _business_plan_sample(lang: str) -> dict:
t = get_translations(lang)
return {
"download_url": f"{config.BASE_URL}/planner/export/sample_export_token",
"quote_url": f"{config.BASE_URL}/{lang}/leads/quote",
"preheader": t.get("email_business_plan_preheader", ""),
}
def _admin_compose_sample(lang: str) -> dict:
return {
"body_html": "<p>Hello,</p><p>This is a test message from the admin compose panel.</p><p>Best regards,<br>Padelnomics Team</p>",
"preheader": "Test message from admin",
}
# Registry entry shape:
# template: path relative to templates/
# label: human-readable name shown in gallery
# description: one-line description
# email_type: email_type value stored in email_log (for cross-linking)
# sample_data: callable(lang) → dict of template context
EMAIL_TEMPLATE_REGISTRY: dict[str, dict] = {
"magic_link": {
"template": "emails/magic_link.html",
"label": "Magic Link",
"description": "Passwordless sign-in link sent to users requesting access.",
"email_type": "magic_link",
"sample_data": _magic_link_sample,
},
"quote_verification": {
"template": "emails/quote_verification.html",
"label": "Quote Verification",
"description": "Email address verification for new project quote requests.",
"email_type": "quote_verification",
"sample_data": _quote_verification_sample,
},
"welcome": {
"template": "emails/welcome.html",
"label": "Welcome",
"description": "Sent to new users after their first successful sign-in.",
"email_type": "welcome",
"sample_data": _welcome_sample,
},
"waitlist_supplier": {
"template": "emails/waitlist_supplier.html",
"label": "Waitlist — Supplier",
"description": "Confirmation for suppliers who joined the Growth/Pro waitlist.",
"email_type": "waitlist",
"sample_data": _waitlist_supplier_sample,
},
"waitlist_general": {
"template": "emails/waitlist_general.html",
"label": "Waitlist — General",
"description": "Confirmation for general sign-up waitlist submissions.",
"email_type": "waitlist",
"sample_data": _waitlist_general_sample,
},
"lead_matched": {
"template": "emails/lead_matched.html",
"label": "Lead Matched",
"description": "Notifies the project owner that suppliers are now reviewing their brief.",
"email_type": "lead_matched",
"sample_data": _lead_matched_sample,
},
"lead_forward": {
"template": "emails/lead_forward.html",
"label": "Lead Forward",
"description": "Full project brief sent to a supplier after they unlock a lead.",
"email_type": "lead_forward",
"sample_data": _lead_forward_sample,
},
"lead_match_notify": {
"template": "emails/lead_match_notify.html",
"label": "Lead Match Notify",
"description": "Notifies matching suppliers that a new lead is available in their area.",
"email_type": "lead_match_notify",
"sample_data": _lead_match_notify_sample,
},
"weekly_digest": {
"template": "emails/weekly_digest.html",
"label": "Weekly Digest",
"description": "Monday digest of new leads matching a supplier's service area.",
"email_type": "weekly_digest",
"sample_data": _weekly_digest_sample,
},
"business_plan": {
"template": "emails/business_plan.html",
"label": "Business Plan Ready",
"description": "Notifies the user when their business plan PDF export is ready.",
"email_type": "business_plan",
"sample_data": _business_plan_sample,
},
"admin_compose": {
"template": "emails/admin_compose.html",
"label": "Admin Compose",
"description": "Branded wrapper used for ad-hoc emails sent from the compose panel.",
"email_type": "admin_compose",
"sample_data": _admin_compose_sample,
},
}

View File

@@ -13,6 +13,44 @@ from pathlib import Path
SUPPORTED_LANGS = {"en", "de"}
LANG_BLUEPRINTS = {"public", "planner", "directory", "content", "leads", "suppliers"}
# 2-letter ISO country code → English name.
# Used by the directory, article templates, and get_country_name().
COUNTRY_LABELS: dict[str, str] = {
"DE": "Germany",
"ES": "Spain",
"IT": "Italy",
"FR": "France",
"PT": "Portugal",
"GB": "United Kingdom",
"NL": "Netherlands",
"BE": "Belgium",
"SE": "Sweden",
"DK": "Denmark",
"FI": "Finland",
"NO": "Norway",
"AT": "Austria",
"SI": "Slovenia",
"IS": "Iceland",
"CH": "Switzerland",
"EE": "Estonia",
"US": "United States",
"CA": "Canada",
"MX": "Mexico",
"BR": "Brazil",
"AR": "Argentina",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
"CN": "China",
"IN": "India",
"SG": "Singapore",
"ID": "Indonesia",
"TH": "Thailand",
"AU": "Australia",
"ZA": "South Africa",
"EG": "Egypt",
}
_LOCALES_DIR = Path(__file__).parent / "locales"
@@ -138,3 +176,30 @@ def get_calc_item_names(lang: str) -> dict[str, str]:
"""
assert lang in _CALC_ITEM_NAMES, f"Unknown lang: {lang!r}"
return _CALC_ITEM_NAMES[lang]
# Reverse map: English country name → 2-letter code (e.g. "Germany" → "DE").
# Built once at load time from COUNTRY_LABELS.
_COUNTRY_CODE_BY_EN_NAME: dict[str, str] = {v: k for k, v in COUNTRY_LABELS.items()}
def get_country_name(country_str: str, lang: str) -> str:
"""Return the localised name for a country stored as a 2-letter code or English name.
Handles both formats stored in the DB:
- 2-letter ISO code: "CH""Schweiz" (de) / "Switzerland" (en)
- English name: "Switzerland""Schweiz" (de)
Falls back to the original string if not found in translations.
Used as a Jinja filter: {{ article.country | country_name(lang) }}
"""
if not country_str:
return country_str
effective_lang = lang if lang in _TRANSLATIONS else "en"
# Accept both 2-letter code ("CH") and English name ("Switzerland")
upper = country_str.upper()
code = upper if upper in COUNTRY_LABELS else _COUNTRY_CODE_BY_EN_NAME.get(country_str, "")
if not code:
return country_str
key = f"dir_country_{code}"
return _TRANSLATIONS[effective_lang].get(key, country_str)

View File

@@ -4,7 +4,6 @@ Leads domain: capture interest in court suppliers and financing.
import json
import secrets
from datetime import datetime
from pathlib import Path
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
@@ -27,6 +26,7 @@ from ..core import (
is_disposable_email,
is_plausible_phone,
send_email,
utcnow_iso,
)
from ..i18n import get_translations
@@ -102,7 +102,7 @@ async def suppliers():
form.get("court_count", 0),
form.get("budget", 0),
form.get("message", ""),
datetime.utcnow().isoformat(),
utcnow_iso(),
),
)
# Notify admin
@@ -147,7 +147,7 @@ async def financing():
form.get("court_count", 0),
form.get("budget", 0),
form.get("message", ""),
datetime.utcnow().isoformat(),
utcnow_iso(),
),
)
await send_email(
@@ -346,8 +346,8 @@ async def quote_request():
previous_supplier_contact, services_needed, additional_info,
contact_name, contact_email, contact_phone, contact_company,
stakeholder_type,
heat_score, status, credit_cost, token, created_at)
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
heat_score, status, credit_cost, token, created_at, visible_from)
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+2 hours'))""",
(
user_id,
form.get("court_count", 0),
@@ -375,7 +375,7 @@ async def quote_request():
status,
credit_cost,
secrets.token_urlsafe(16),
datetime.utcnow().isoformat(),
utcnow_iso(),
),
)
@@ -520,9 +520,9 @@ async def verify_quote():
from ..credits import compute_credit_cost
credit_cost = compute_credit_cost(dict(lead))
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?",
"UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ?, visible_from = datetime('now', '+2 hours') WHERE id = ?",
(now, credit_cost, lead["id"]),
)
@@ -556,6 +556,7 @@ async def verify_quote():
from ..worker import enqueue
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
await enqueue("notify_matching_suppliers", {"lead_id": lead["id"], "lang": g.get("lang", "en")})
return await render_template(
"quote_submitted.html",

View File

@@ -1,6 +1,6 @@
{
"nav_planner": "Finanzplaner",
"nav_quotes": "Angebot erhalten",
"nav_quotes": "Angebote anfragen",
"nav_directory": "Anbieterverzeichnis",
"nav_markets": "Märkte",
"nav_suppliers": "Für Anbieter",
@@ -14,7 +14,7 @@
"nav_section_plan": "Planen & Entdecken",
"nav_section_suppliers": "Anbieter",
"nav_section_account": "Konto",
"footer_tagline": "Plane, finanziere und baue dein Padel-Business.",
"footer_tagline": "Plane, finanziere und baue Dein Padel-Business.",
"footer_product": "Produkt",
"footer_legal": "Rechtliches",
"footer_company": "Unternehmen",
@@ -52,29 +52,29 @@
"auth_signup_have_account": "Bereits ein Konto?",
"auth_signup_signin_link": "Anmelden",
"auth_magic_title": "E-Mail prüfen",
"auth_magic_sent_to": "Wir haben dir einen Anmeldelink geschickt an:",
"auth_magic_instructions": "Klick auf den Link in der E-Mail, um dich anzumelden. Der Link läuft in {minutes} Minuten ab.",
"auth_magic_sent_to": "Wir haben Dir einen Anmeldelink geschickt an:",
"auth_magic_instructions": "Klick auf den Link in der E-Mail, um Dich anzumelden. Der Link läuft in {minutes} Minuten ab.",
"auth_magic_no_email": "Keine E-Mail erhalten?",
"auth_magic_check_spam": "Schau in deinen Spam-Ordner",
"auth_magic_correct_email": "Stelle sicher, dass die E-Mail-Adresse korrekt ist",
"auth_magic_wait": "Warte eine Minute und versuche es erneut",
"auth_magic_wait": "Warte einen Moment und versuche es erneut",
"auth_magic_resend_btn": "Link erneut senden",
"auth_waitlist_title": "Sei Erster beim Start deines Padel-Business",
"auth_waitlist_title": "Als Erster mit Deinem Padel-Business durchstarten",
"auth_waitlist_sub": "Wir bereiten die ultimative Planungsplattform für Padel-Unternehmer vor. Trag dich in die Warteliste ein für Frühzugang, exklusive Boni und priorisierten Support.",
"auth_waitlist_hint": "Du gehörst zu den Ersten, die Zugang erhalten, wenn wir launchen.",
"auth_waitlist_btn": "In Warteliste eintragen",
"auth_waitlist_confirmed_title": "Du stehst auf der Warteliste!",
"auth_waitlist_confirmed_sent_to": "Wir haben dir eine Bestätigung geschickt an:",
"auth_waitlist_confirmed_sub": "Du gehörst zu den Ersten, die es wissen, wenn wir launchen. Wir schicken dir Frühzugang, exklusive Launch-Boni und prioriertes Onboarding.",
"auth_waitlist_confirmed_sub": "Du gehörst zu den Ersten, die es erfahren, wenn wir launchen. Wir schicken Dir Frühzugang, exklusive Launch-Boni und bevorzugtes Onboarding.",
"auth_waitlist_confirmed_next": "Was passiert als Nächstes?",
"auth_waitlist_confirmed_step1": "Du erhältst in Kürze eine Bestätigungs-E-Mail",
"auth_waitlist_confirmed_step2": "Wir benachrichtigen dich, sobald wir launchen",
"auth_waitlist_confirmed_step2": "Wir benachrichtigen Dich, sobald wir launchen",
"auth_waitlist_confirmed_step3": "Du erhältst exklusiven Frühzugang vor dem öffentlichen Launch",
"auth_waitlist_confirmed_back": "Zurück zur Startseite",
"auth_flash_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
"auth_flash_disposable_email": "Bitte verwende eine dauerhafte E-Mail-Adresse.",
"auth_flash_login_sent": "Schau in deine E-Mails für den Anmeldelink!",
"auth_flash_account_exists": "Konto existiert bereits. Bitte melde dich an.",
"auth_flash_account_exists": "Konto bereits vorhanden. Bitte melde Dich an.",
"auth_flash_signup_sent": "Schau in deine E-Mails, um die Registrierung abzuschließen!",
"auth_flash_invalid_token": "Ungültiger oder abgelaufener Link.",
"auth_flash_invalid_token_detail": "Ungültiger oder abgelaufener Link. Bitte fordere einen neuen an.",
@@ -84,22 +84,22 @@
"flash_feedback_success": "Vielen Dank für dein Feedback!",
"flash_feedback_empty": "Bitte gib eine Nachricht ein.",
"flash_feedback_rate_limit": "Zu viele Anfragen. Bitte versuch es später erneut.",
"flash_suppliers_success": "Danke! Wir verbinden dich mit verifizierten Hoflieferanten.",
"flash_financing_success": "Danke! Wir verbinden dich mit Finanzierungspartnern.",
"flash_suppliers_success": "Danke! Wir vermitteln Dich an verifizierte Platz-Anbieter.",
"flash_financing_success": "Danke! Wir vermitteln Dich an Finanzierungspartner.",
"flash_verify_invalid": "Ungültiger Verifizierungslink.",
"flash_verify_expired": "Dieser Link ist abgelaufen oder wurde bereits verwendet. Bitte stelle eine neue Anfrage.",
"flash_verify_invalid_lead": "Dieses Angebot wurde bereits verifiziert oder existiert nicht.",
"landing_hero_badge": "Padel-Kostenrechner & Finanzplaner",
"landing_hero_badge": "Padel-Finanzrechner & Businessplan-Tool",
"landing_hero_h1_1": "Plan Dein Padel-",
"landing_hero_h1_2": "Business in Minuten,",
"landing_hero_h1_3": "nicht Monaten",
"landing_hero_btn_primary": "Jetzt planen →",
"landing_hero_btn_primary": "Jetzt Dein Padel-Business planen →",
"landing_hero_btn_secondary": "Anbieter durchsuchen",
"landing_hero_bullet_1": "Keine Registrierung erforderlich",
"landing_hero_bullet_2": "60+ Variablen",
"landing_hero_bullet_3": "Unbegrenzte Szenarien",
"landing_roi_title": "Schnelle Renditeschätzung",
"landing_roi_subtitle": "Schieberegler bewegen und Projektion sehen",
"landing_roi_subtitle": "Schieberegler bewegen und Projektion in Echtzeit sehen",
"landing_roi_courts": "Plätze",
"landing_roi_rate": "Durchschn. Stundensatz",
"landing_roi_util": "Ziel-Auslastung",
@@ -108,7 +108,7 @@
"landing_roi_payback": "Amortisationszeit",
"landing_roi_annual_roi": "Jährlicher ROI",
"landing_roi_note": "Annahmen: Indoorhalle Mietmodell, 8 €/m² Miete, Personalkosten, 5 % Zinsen, 10-jähriges Darlehen. Amortisation und ROI basieren auf der Gesamtinvestition.",
"landing_roi_cta": "Jetzt planen →",
"landing_roi_cta": "Jetzt Dein Padel-Business planen →",
"landing_journey_title": "Deine Reise",
"landing_journey_01": "Analysieren",
"landing_journey_01_badge": "Demnächst",
@@ -118,7 +118,7 @@
"landing_journey_04": "Bauen",
"landing_journey_05": "Wachsen",
"landing_journey_05_badge": "Demnächst",
"landing_features_title": "Für ernsthafte Padel-Unternehmer entwickelt",
"landing_features_title": "Für ernsthafte Padel-Unternehmer gebaut",
"landing_feature_1_h3": "60+ Variablen",
"landing_feature_2_h3": "6 Analyse-Tabs",
"landing_feature_3_h3": "Indoor & Outdoor",
@@ -137,9 +137,9 @@
"landing_faq_q4": "Ist das Anbieterverzeichnis kostenlos?",
"landing_faq_q5": "Wie genau sind die Finanzprojektionen?",
"landing_seo_title": "Padel-Platz-Investitionsplanung",
"landing_final_cta_h2": "Jetzt mit der Planung beginnen",
"landing_final_cta_btn": "Jetzt planen →",
"features_h1": "Alles, was du für dein Padel-Business brauchst",
"landing_final_cta_h2": "Jetzt mit der Planung loslegen",
"landing_final_cta_btn": "Jetzt Dein Padel-Business planen →",
"features_h1": "Alles, was Du für Dein Padel-Business brauchst",
"features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.",
"features_card_1_h2": "60+ Variablen",
"features_card_2_h2": "6 Analyse-Tabs",
@@ -154,19 +154,19 @@
"features_cta_open": "Planer öffnen",
"features_cta_signup": "Kostenloses Konto erstellen",
"about_why_h3": "Warum kostenlos?",
"about_next_h3": "Was kommt als nächstes",
"about_next_h3": "Was als Nächstes kommt",
"about_cta_open": "Planer öffnen",
"about_cta_signup": "Kostenloses Konto erstellen",
"suppliers_hero_cta": "Pläne & Preise ansehen",
"suppliers_stat_plans_label": "Erstellte Geschäftspläne",
"suppliers_stat_avg_value": "Durchschn. Projektwert",
"suppliers_stat_leads_label": "Leads diesen Monat",
"suppliers_problem_h2": "Das Problem bei der Kundengewinnung heute",
"suppliers_problem_sub": "Die meisten Kanäle verschwenden Zeit und Budget, bevor du mit einem echten Käufer sprichst.",
"suppliers_problem_h2": "Das Problem bei der Neukundengewinnung heute",
"suppliers_problem_sub": "Die meisten Kanäle verschwenden Zeit und Budget, bevor Du mit einem einzigen echten Käufer sprichst.",
"suppliers_problem_1_h3": "Messen",
"suppliers_problem_2_h3": "Google Ads",
"suppliers_problem_3_h3": "Kaltakquise",
"suppliers_transition": "Was wäre, wenn jeder Lead mit einem vollständigen Projektbrief und einem Finanzmodell käme?",
"suppliers_transition": "Was wäre, wenn jeder Lead mit einem vollständigen Projektbriefing und einem Finanzmodell käme?",
"suppliers_how_h2": "So funktioniert es",
"suppliers_how_sub": "Drei Schritte zu qualifizierten Leads.",
"suppliers_step_1_h3": "Eintrag beanspruchen",
@@ -205,8 +205,8 @@
"suppliers_boosts_sub": "Mit jedem bezahlten Plan verfügbar. Verwalte sie über Dein Dashboard.",
"suppliers_comparison_h2": "Der direkte Vergleich",
"suppliers_faq_h2": "FAQ für Anbieter",
"suppliers_final_cta_h2": "Dein nächster Kunde erstellt gerade einen Geschäftsplan",
"suppliers_final_cta_desc": "Er hat die Rentabilität berechnet. Er kennt sein Budget. Er sucht einen Anbieter wie dich.",
"suppliers_final_cta_h2": "Dein nächster Kunde erstellt gerade einen Businessplan",
"suppliers_final_cta_desc": "Er hat die Rentabilität berechnet. Er kennt sein Budget. Er sucht einen Anbieter wie Dich.",
"suppliers_final_cta_btn": "Pläne & Preise ansehen",
"planner_page_h2": "100 % kostenlos. Kein Haken.",
"planner_card_1_h3": "Finanzplaner",
@@ -220,7 +220,7 @@
"planner_card_2_signup_btn": "Registrieren und loslegen",
"planner_quote_cta_label": "Nächster Schritt",
"planner_quote_cta_title": "Angebote von verifizierten Anbietern einholen",
"planner_quote_cta_desc": "Teile Deine Projektspezifikationen und wir verbinden dich mit passenden Anbietern.",
"planner_quote_cta_desc": "Teile Deine Projektspezifikationen und wir vermitteln Dich an passende Anbieter.",
"planner_quote_cta_check_1": "Passende Anbieter",
"planner_quote_cta_check_2": "Direktkontakt, kein Vermittler",
"planner_quote_cta_check_3": "Keine Verpflichtung",
@@ -245,12 +245,12 @@
"export_back": "← Zurück zum Planer",
"export_success_title": "Zahlung eingegangen",
"export_success_subtitle": "Dein Geschäftsplan-PDF wird generiert. Dies dauert üblicherweise weniger als eine Minute.",
"export_success_status": "Dein PDF wird erstellt. Aktualisiere diese Seite gleich, oder überprüfe Deine E-Mail — wir senden Dir einen Download-Link, wenn es fertig ist.",
"export_success_status": "Dein PDF wird erstellt. Aktualisiere diese Seite gleich, oder schau in Deine E-Mails — wir senden Dir einen Download-Link, wenn es fertig ist.",
"export_success_refresh": "Status aktualisieren",
"export_success_all": "Alle Exporte anzeigen",
"export_success_planner": "Zurück zum Planer",
"export_gen_title": "Geschäftsplan wird generiert",
"export_gen_subtitle": "Dies dauert üblicherweise weniger als eine Minute. Diese Seite wird automatisch aktualisiert.",
"export_gen_subtitle": "Dies dauert üblicherweise weniger als eine Minute. Diese Seite aktualisiert sich automatisch.",
"export_gen_refresh": "Jetzt aktualisieren",
"export_gen_all": "Alle Exporte anzeigen",
"export_waitlist_title": "Geschäftsplan-PDF-Export demnächst verfügbar",
@@ -264,9 +264,9 @@
"scenario_created": "Erstellt",
"dir_heading": "Padelplatz-Hersteller, Platzbauer & Anbieter",
"dir_page_title": "Padel-Platz Anbieterverzeichnis",
"dir_page_meta_desc": "Über {count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für dein Projekt finden.",
"dir_page_og_desc": "Über {count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.",
"dir_subheading": "Über {n} Anbieter aus {c} Ländern. Hersteller, Platzbauer und schlüsselfertige Lösungen für Deinen Padelplatz.",
"dir_page_meta_desc": "{count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für Dein Projekt finden.",
"dir_page_og_desc": "{count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.",
"dir_subheading": "{n}+ Anbieter aus {c} Ländern. Hersteller, Platzbauer und schlüsselfertige Lösungen für Deine Padel-Anlage.",
"dir_stat_suppliers": "Anbieter",
"dir_stat_countries": "Länder",
"dir_stat_categories": "Kategorien",
@@ -276,7 +276,7 @@
"dir_search_btn": "Suchen",
"dir_filter_clear": "Alle löschen",
"dir_cta_heading": "Bist Du ein Padelplatz-Anbieter?",
"dir_cta_subheading": "Eintrag erstellen und Kontakt zu planenden Unternehmern aufnehmen.",
"dir_cta_subheading": "Eintrag erstellen und Kontakt zu planenden Unternehmern herstellen.",
"dir_cta_btn": "Eintrag erstellen",
"dir_card_verified": "Verifiziert",
"dir_card_featured": "Featured",
@@ -352,16 +352,16 @@
"sp_about": "Über uns",
"sp_services": "Angebotene Leistungen",
"sp_service_area": "Servicegebiet",
"sp_enquiry_heading": "Anfrage senden",
"sp_enquiry_heading": "Anfrage stellen",
"sp_enquiry_name": "Dein Name",
"sp_enquiry_email": "E-Mail",
"sp_enquiry_message": "Nachricht",
"sp_enquiry_submit": "Anfrage senden",
"sp_enquiry_submit": "Anfrage absenden",
"sp_contact": "Kontakt",
"sp_years": "Jahre aktiv",
"sp_projects": "Projekte",
"sp_trust": "Verifizierter Eintrag — Identität und Inhaberschaft bestätigt",
"sp_cta_basic_h3": "Auf der Suche nach direkter Angebotsabstimmung?",
"sp_cta_basic_h3": "Direkte Angebote und Lead-Zugang gesucht?",
"sp_cta_claim_h3": "Ist das Dein Unternehmen?",
"sp_cta_claim_btn": "Eintrag beanspruchen →",
"sp_locked_hint": "Eintrag noch nicht verifiziert",
@@ -369,18 +369,18 @@
"sp_locked_popover_link": "Angebotsassistent nutzen →",
"sp_locked_popover_dismiss": "Schließen",
"sp_enquiry_placeholder": "Erzähl {name} von Deinem Projekt…",
"sp_cta_basic_desc": "Upgrade auf Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.",
"sp_cta_basic_desc": "Wechsle zu Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.",
"sp_cta_basic_btn": "Auf Growth upgraden →",
"sp_locked_popover_desc": "Dieser Anbieter hat seinen Eintrag noch nicht verifiziert. Nutze unseren Angebotsassistenten und wir vermitteln dich mit verifizierten Anbietern in Deiner Region.",
"sp_locked_popover_desc": "Dieser Anbieter hat seinen Eintrag noch nicht verifiziert. Nutze unseren Angebotsassistenten wir vermitteln Dich mit verifizierten Anbietern in Deiner Region.",
"sp_cta_claim_desc": "Beanspruche und verifiziere diesen Eintrag, um Projektanfragen von Padel-Entwicklern zu erhalten.",
"enquiry_success_title": "Anfrage gesendet!",
"enquiry_error_title": "Bitte korrigiere Folgendes:",
"enquiry_forwarded_msg": "Deine Nachricht wurde an {name} weitergeleitet. Der Anbieter meldet sich direkt bei dir.",
"enquiry_received_msg": "Deine Nachricht wurde empfangen. Das Team meldet sich in Kürze bei dir.",
"enquiry_forwarded_msg": "Deine Nachricht wurde an {name} weitergeleitet. Der Anbieter meldet sich direkt bei Dir.",
"enquiry_received_msg": "Deine Nachricht wurde empfangen. Das Team meldet sich in Kürze bei Dir.",
"q_btn_next": "Weiter →",
"q_btn_back": "← Zurück",
"q_btn_submit": "Absenden & Angebote erhalten →",
"q_page_title": "Angebote von Bauunternehmen erhalten",
"q_page_title": "Angebote von Bauunternehmen einholen",
"q_step_counter": "Schritt {step} von {total}",
"q1_heading": "Dein Projekt",
"q1_subheading": "Welche Art von Padel-Anlage planst Du?",
@@ -450,7 +450,7 @@
"q6_decision_partners": "Mit Partnern",
"q6_decision_committee": "Ausschuss / Vorstand",
"q7_heading": "Über Dich",
"q7_subheading": "Das hilft uns, dich mit den richtigen Anbietern zusammenzubringen.",
"q7_subheading": "Das hilft uns, Dich mit den richtigen Anbietern zusammenzubringen.",
"q7_role_label": "Du bist…",
"q7_role_entrepreneur": "Unternehmer / Investor",
"q7_role_tennis": "Tennis- / Sportclub",
@@ -477,7 +477,7 @@
"q8_additional_label": "Noch etwas?",
"q8_additional_placeholder": "Besondere Anforderungen, Fragen oder Hintergrundinformationen…",
"q9_heading": "Kontaktdaten",
"q9_subheading": "Wie sollen passende Anbieter dich erreichen?",
"q9_subheading": "Wie sollen passende Anbieter Dich erreichen?",
"q9_privacy_msg": "Deine Kontaktdaten werden nur mit geprüften Anbietern geteilt, die zu Deinen Projektspezifikationen passen.",
"q9_name_label": "Vollständiger Name",
"q9_email_label": "E-Mail",
@@ -492,7 +492,7 @@
"q9_error_email": "E-Mail ist erforderlich",
"q9_error_phone": "Telefonnummer ist erforderlich",
"qs_title": "Erfolgreich vermittelt!",
"qs_next_h2": "Was als nächstes passiert",
"qs_next_h2": "Was als Nächstes passiert",
"qs_step_1": "Anbieter prüfen Deinen Projektbrief und bereiten Angebote vor",
"qs_step_1_time": "Jetzt",
"qs_step_2": "Passende Anbieter kontaktieren Dich mit maßgeschneiderten Angeboten",
@@ -509,7 +509,7 @@
"qs_matched_court_suffix": "-Platz-",
"qs_matched_facility_fmt": "{type}-",
"qs_matched_project": "Projekt",
"qs_matched_post": "mit verifizierten Anbietern abgestimmt, die sich mit maßgeschneiderten Angeboten bei Dir melden.",
"qs_matched_post": "mit verifizierten Anbietern abgeglichen, die sich mit maßgeschneiderten Angeboten bei Dir melden.",
"qv_heading": "E-Mail prüfen",
"qv_link_expiry": "Der Link läuft in 60 Minuten ab.",
"qv_spam": "Spam-Ordner überprüfen",
@@ -517,7 +517,7 @@
"qv_wrong_email": "Falsche E-Mail?",
"qv_wrong_email_link": "Neue Anfrage stellen",
"qv_sent_msg": "Wir haben einen Verifizierungslink an folgende Adresse gesendet:",
"qv_instructions": "Klick auf den Link in der E-Mail, um Deine Adresse zu bestätigen und Deine Angebotsanfrage zu aktivieren. Dadurch wird auch Dein {app_name}-Konto erstellt und du wirst automatisch angemeldet.",
"qv_instructions": "Klick auf den Link in der E-Mail, um Deine Adresse zu bestätigen und Deine Angebotsanfrage zu aktivieren. Dadurch wird auch Dein {app_name}-Konto erstellt und Du wirst automatisch angemeldet.",
"qv_no_email": "E-Mail nicht erhalten?",
"qv_check_email_pre": "Stell sicher, dass ",
"qv_check_email_post": " korrekt ist",
@@ -529,20 +529,20 @@
"sup_signup_of_steps": "von 4",
"sup_success_h2": "Alles bereit!",
"sup_success_text": "Dein Anbieter-Konto wird aktiviert. Du erhältst in Kürze qualifizierte Leads, die Deinen Leistungen entsprechen.",
"sup_success_next_h3": "Was als nächstes passiert:",
"sup_success_next_h3": "Was als Nächstes passiert:",
"sup_success_btn": "Zum Lead-Feed",
"sup_success_page_title": "Willkommen!",
"sup_success_li1": "Dein Eintrag wird in wenigen Minuten aktualisiert",
"sup_success_li2": "Lead-Credits wurden deinem Konto hinzugefügt",
"sup_success_li3": "Prüfe deine E-Mail auf einen Anmelde-Link",
"sup_success_li4": "Durchsuche und entsperre Leads in deinem Feed",
"sup_waitlist_h1": "Auf die Warteliste für die Anbieter-Plattform",
"sup_waitlist_h1": "Auf die Anbieter-Plattform-Warteliste eintragen",
"sup_waitlist_email_label": "E-Mail",
"sup_waitlist_submit": "Zur Warteliste",
"sup_waitlist_signin_text": "Bereits ein Konto?",
"sup_waitlist_signin_link": "Anmelden",
"sup_waitlist_page_title": "Anbieter-Warteliste",
"sup_waitlist_intro": "Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Sei Erster in der Schlange für den {plan_name}-Tier-Zugang.",
"sup_waitlist_intro": "Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Trag Dich als Erster für den {plan_name}-Tier-Zugang ein.",
"sup_waitlist_plan_h3": "{name} Plan-Highlights",
"sup_waitlist_hint": "Frühzeitiger Zugang, exklusiver Launch-Preis und bevorzugtes Onboarding.",
"sup_waitlist_conf_page_title": "Du stehst auf der Anbieter-Warteliste",
@@ -550,7 +550,7 @@
"sup_waitlist_conf_msg": "Wir haben eine Bestätigung gesendet an:",
"sup_waitlist_conf_first_pre": "Du gehörst zu den ersten Anbietern mit Zugang zum ",
"sup_waitlist_conf_first_post": "-Tier bei unserem Launch.",
"sup_waitlist_conf_early_h3": "Was du als Frühmitglied erhältst:",
"sup_waitlist_conf_early_h3": "Was Du als frühes Mitglied erhältst:",
"sup_waitlist_conf_li1": "Erster Zugang zu qualifizierten Leads von Padel-Unternehmern",
"sup_waitlist_conf_li2": "Exklusiver Launch-Preis (für 12 Monate festgeschrieben)",
"sup_waitlist_conf_li3": "Vorrangiges Onboarding und Support bei der Eintragsoptimierung",
@@ -577,7 +577,7 @@
"sup_step3_free_desc": "Nur Plan-Credits",
"sup_step3_next": "Weiter: Deine Daten",
"sup_step4_title": "Kontodaten",
"sup_step4_sub": "Erzähl uns von deinem Unternehmen und wie wir dich erreichen können.",
"sup_step4_sub": "Erzähl uns von Deinem Unternehmen und wie wir Dich erreichen können.",
"sup_step4_contact_name": "Ansprechpartner",
"sup_step4_email": "E-Mail",
"sup_step4_phone": "Telefon",
@@ -710,8 +710,8 @@
"sl_hold_years": "Haltedauer",
"sl_exit_multiple": "Exit-EBITDA-Multiplikator",
"sl_annual_rev_growth": "Jährliches Umsatzwachstum",
"wiz_summary_label": "Aktuelle Werte",
"tip_permits_compliance": "Baugenehmigungen, Lärmgutachten, Nutzungsänderungen, Brandschutz und behördliche Auflagen. Wird automatisch angepasst, wenn du ein Land wählst — kann manuell überschrieben werden.",
"wiz_summary_label": "Aktuelle Zusammenfassung",
"tip_permits_compliance": "Baugenehmigungen, Lärmgutachten, Nutzungsänderungen, Brandschutz und behördliche Auflagen. Wird automatisch angepasst, wenn Du ein Land wählst — kann manuell überschrieben werden.",
"tip_dbl_courts": "Standard-Padelplatz für 4 Spieler. Häufigstes Format mit der höchsten Freizeitnachfrage.",
"tip_sgl_courts": "Schmaler Platz für 2 Spieler. Beliebt für Coaching, Training und Wettkampf.",
"tip_sqm_dbl_hall": "Gesamte Hallenfläche pro Doppelplatz. Enthält Spielfeld (200m²), Sicherheitszonen, Laufwege und Mindestabstände. Standard: 300350m².",
@@ -723,7 +723,7 @@
"tip_rate_single": "Stundensatz für Einzelplätze. Meist niedriger als Doppelplätze, da sich weniger Spieler die Kosten teilen.",
"tip_peak_pct": "Anteil der gebuchten Stunden zum Spitzentarif. Höherer Wert bedeutet mehr Umsatz, aber schwieriger zu füllende Nebenstunden.",
"tip_booking_fee": "Provision von Buchungsplattformen wie Playtomic oder Matchi. Typisch: 515 % des Platzumsatzes.",
"tip_util_target": "Anteil der verfügbaren Platzstunden, der tatsächlich gebucht wird. 3545 % sind realistisch für neue Anlagen, 50 %+ ist stark.",
"tip_util_target": "Anteil der verfügbaren Platzstunden, der tatsächlich gebucht wird. 3545 % sind realistisch für neue Anlagen, ab 50 % ist stark.",
"tip_hours_per_day": "Gesamte Betriebsstunden pro Tag. Typische Padel-Anlagen öffnen 723Uhr (16h). Manche auch 624Uhr.",
"tip_days_indoor": "Durchschnittliche Betriebstage pro Monat für Indoor-Anlagen. ~29 berücksichtigt Feiertage und Wartungsschließungen.",
"tip_days_outdoor": "Durchschnittliche bespielbaren Tage pro Monat im Freien. Reduziert durch Regen, Extremhitze oder Kälte.",
@@ -750,7 +750,7 @@
"tip_outdoor_site_work": "Geländeausgleich, Entwässerungsinstallation, Versorgungsanschlüsse und Erschließung für Außenplätze.",
"tip_outdoor_lighting": "Flutlichtinstallation pro Platz. LED empfohlen für Energieeffizienz. Wettkampfnormen einhalten, falls relevant.",
"tip_outdoor_fencing": "Einzäunung der Außenplatzanlage. Enthält Windschutz, Sicherheitstore und Ballrückhaltevorrichtungen.",
"tip_working_capital": "Kassenreserve für Betriebsverluste in der Anlaufphase und bei saisonalen Schwankungen. Kritischer Puffer — zu geringes Betriebskapital ist ein häufiger Startup-Fehler.",
"tip_working_capital": "Kassenreserve für Betriebsverluste in der Anlaufphase und bei saisonalen Schwankungen. Kritischer Puffer — zu geringes Betriebskapital ist ein häufiger Fehler in der Startphase.",
"tip_contingency": "Prozentualer Puffer auf den Gesamt-CAPEX für unvorhergesehene Kosten. 1015 % sind beim Bau Standard, 1520 % bei komplexen Projekten.",
"tip_budget_target": "Gesamtbudget festlegen, um den geplanten CAPEX zu vergleichen. 0 lassen, um den Budgetindikator auszublenden.",
"tip_rent_sqm": "Monatliche Miete pro m² für Hallenfläche. Abhängig von Lage, Gebäudequalität und Mietkonditionen.",
@@ -764,11 +764,11 @@
"tip_cleaning": "Monatliche professionelle Reinigung von Plätzen, Umkleiden, Gemeinschaftsflächen und Empfang.",
"tip_marketing": "Monatliche Ausgaben für Marketing, Buchungsplattform-Abonnements, Website, Social Media und Kundengewinnung.",
"tip_staff": "Monatliche Personalkosten: Gehälter, Sozialabgaben und Leistungen. Viele Anlagen fahren schlank mit automatisierten Buchungs- und Zugangssystemen.",
"tip_loan_pct": "Anteil des Gesamt-CAPEX, der fremdfinanziert wird. Banken bieten typisch 7085 %. Höher mit Bürgüschaft oder Fördermitteln.",
"tip_loan_pct": "Anteil des Gesamt-CAPEX, der fremdfinanziert wird. Banken bieten typisch 7085 %. Höher mit Bürgschaft oder Fördermitteln.",
"tip_interest_rate": "Jährlicher Zinssatz des Darlehens. Abhängig von Bonität, Sicherheiten, Marktlage und Bankbeziehung.",
"tip_loan_term": "Kreditlaufzeit in Jahren. Längere Laufzeit bedeutet niedrigere Monatsraten, aber mehr Gesamtzinsen.",
"tip_construction_months": "Monate Bau/Einrichtung vor der Eröffnung. Kosten laufen bereits auf (Zinsen, Miete), aber noch kein Umsatz.",
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch für PE/Investoren: 57 Jahre. Betreiber-Eigentümer können unbegrenzt halten.",
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch für PE/Investoren: 57 Jahre. Eigentümer-Betreiber können unbegrenzt halten.",
"tip_exit_multiple": "EBITDA-Multiplikator zur Unternehmensbewertung beim Exit. Spiegelt Marktnachfrage, Markenstärke und Wachstumspotenzial wider. Kleines Business: 46×, starke Marke: 68×.",
"tip_annual_rev_growth": "Erwartetes jährliches Umsatzwachstum nach der ersten 12-monatigen Anlaufphase. Getrieben durch Preiserhöhungen und steigende Auslastung.",
"tip_result_irr": "Interner Zinsfuß über den Haltezeitraum — der annualisierte Diskontsatz, bei dem der Barwert aller Cashflows null ergibt. Ziel: über 20 %. N/A wenn Cashflows nie positiv werden.",
@@ -783,7 +783,7 @@
"tip_result_yield_on_cost": "Stabilisiertes EBITDA ÷ Gesamtinvestition (CAPEX). Ungehebelte Rendite — nützlich zum Vergleich mit anderen Anlageklassen oder Bauprojekten.",
"btn_save": "Speichern",
"btn_my_scenarios": "Meine Szenarien",
"btn_reset": "Zurücksetzen",
"btn_reset": "Auf Standardwerte zurücksetzen",
"btn_reset_confirm": "Sicher? Zurücksetzen",
"btn_back": "← Zurück",
"btn_next": "Weiter →",
@@ -911,12 +911,12 @@
"sup_prob_transition": "Was wäre, wenn jeder Lead mit einem vollständigen Projektbriefing und einem Finanzmodell käme?",
"sup_how_h2": "So funktioniert es",
"sup_how_sub": "Drei Schritte zu qualifizierten Leads.",
"sup_how_step1_h3": "Dein Inserat beanspruchen",
"sup_how_step1_p": "Dein Unternehmen ist bereits in unserem Verzeichnis. Wähle einen Plan, um dein Inserat aufzuwerten und Zugang zum Lead-Feed zu erhalten.",
"sup_how_step1_h3": "Deinen Eintrag beanspruchen",
"sup_how_step1_p": "Dein Unternehmen ist bereits in unserem Verzeichnis. Wähle einen Plan, um Deinen Eintrag aufzuwerten und Zugang zum Lead-Feed zu erhalten.",
"sup_how_step2_h3": "Vorqualifizierte Leads durchsuchen",
"sup_how_step2_p": "Jeder Lead enthält Projektspezifikationen, Budget, Zeitplan und ein selbst erstelltes Finanzmodell. Setze Credits nur für Leads ein, die zu deinen Leistungen passen.",
"sup_how_step2_p": "Jeder Lead enthält Projektspezifikationen, Budget, Zeitplan und ein selbst erstelltes Finanzmodell. Setze Credits nur für Leads ein, die zu Deinen Leistungen passen.",
"sup_how_step3_h3": "Projekte schneller gewinnen",
"sup_how_step3_p": "Kontaktiere den Unternehmer direkt. Du kennst bereits sein Budget, Zeitplan und Finanzierungsstatus kein Discovery-Call nötig.",
"sup_how_step3_p": "Kontaktiere den Unternehmer direkt. Du kennst bereits sein Budget, seinen Zeitplan und seinen Finanzierungsstatus kein Discovery-Call nötig.",
"sup_credits_h3": "Wie Credits funktionieren",
"sup_credits_sub": "Jeder Lead kostet Credits, je nachdem wie kaufbereit er ist. Growth-Pläne beinhalten 30 Credits/Monat, Pro 100.",
"sup_credits_hot": "Heißer Lead",
@@ -986,7 +986,7 @@
"sup_boost_sticky": "Sticky Top",
"sup_boost_color": "Eigene Kartenfarbe",
"sup_cmp_h2": "So schlagen wir den Vergleich",
"sup_cmp_sub": "Deine Interessenten wägen diese Alternativen bereits ab. Hier der ehrliche Vergleich.",
"sup_cmp_sub": "Deine Interessenten wägen diese Alternativen bereits ab — hier der ehrliche Vergleich.",
"sup_cmp_th_us": "Padelnomics Growth",
"sup_cmp_th_tradeshow": "Messepräsenz",
"sup_cmp_th_ads": "Google Ads",
@@ -1012,7 +1012,7 @@
"sup_cmp_t4": "Nie",
"sup_cmp_m1": "Nach Kategorie gefiltert",
"sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €2080 CPC für Padel-Baukeywords bei 510 Klicks/Tag.",
"sup_proof_h2": "Vertrauen von Führungsunternehmen der Padel-Branche",
"sup_proof_h2": "Vertrauen von führenden Unternehmen der Padel-Branche",
"sup_proof_stat1": "erstellte Businesspläne",
"sup_proof_stat2": "Anbieter",
"sup_proof_stat3": "Länder",
@@ -1023,7 +1023,7 @@
"sup_faq_h2": "Anbieter-FAQ",
"sup_faq_q1": "Wie werde ich gelistet?",
"sup_faq_a1_pre": "Finde dein Unternehmen in unserem",
"sup_faq_a1_post": "und klicke auf „Ist das dein Unternehmen?“ Wir überprüfen deine Identität und geben dir Zugang, um einen Plan auszuwählen und dein Profil zu aktualisieren.",
"sup_faq_a1_post": "und klicke auf „Ist das Dein Unternehmen?“ Wir prüfen Deine Identität und geben Dir Zugang, um einen Plan auszuwählen und Dein Profil zu aktualisieren.",
"sup_faq_dir_link": "Verzeichnis",
"sup_faq_q2": "Wie viel kostet es?",
"sup_faq_a2": "Wir bieten drei Pläne an: Basic (€39/Monat) für einen verifizierten Verzeichniseintrag mit Kontaktformular; Growth (€199/Monat, 30 Credits) mit vollem Lead-Zugang und Prioritätsplatzierung; und Pro (€499/Monat, 100 Credits) für maximale Sichtbarkeit und Lead-Volumen. Jährliche Abrechnung spart bis zu 26 % Basic bei €349/Jahr, Growth bei €1.799/Jahr, Pro bei €4.499/Jahr. Optionale Boost-Add-Ons sind zusätzlich erhältlich.",
@@ -1046,7 +1046,7 @@
"sup_faq_a10_pre": "Schreib uns eine E-Mail an",
"sup_faq_a10_post": "mit deinen Unternehmensdetails und wir fügen dich innerhalb von 48 Stunden dem Verzeichnis hinzu.",
"sup_cta_h2": "Dein nächster Kunde erstellt gerade einen Businessplan",
"sup_cta_p": "Er hat den ROI modelliert. Er kennt sein Budget. Er sucht einen Anbieter wie dich.",
"sup_cta_p": "Er hat den ROI berechnet. Er kennt sein Budget. Er sucht einen Anbieter wie Dich.",
"scenario_cta_try_numbers": "Mit eigenen Zahlen testen →",
"scenario_payback_label": "Amortisation",
"scenario_months_unit": "Monate",
@@ -1155,10 +1155,10 @@
"about_meta_desc": "Padelnomics ist eine kostenlose Finanzplanungsplattform für Padel-Unternehmer. Modelliere deine Investition, finde Anbieter und plane dein Padel-Business mit professionellen Tools.",
"about_og_desc": "Entwickelt für Padel-Unternehmer, die professionelle Finanztools ohne Beratungskosten benötigen. Kostenloser Planer, 60+ Variablen, Anbieterverzeichnis und mehr.",
"about_h1": "Über Padelnomics",
"about_body_p1": "Padel ist eine der am schnellsten wachsenden Sportarten weltweit, doch für die meisten Unternehmer ist die Eröffnung einer Paddelhalle immer noch ein Sprung ins Ungewisse. Die Finanzen sind komplex: Der CAPEX variiert stark je nach Anlagentyp, der Standort bestimmt die Auslastung, und der Unterschied zwischen 60 % und 75 % Belegung kann über Erfolg oder Misserfolg einer Investition entscheiden.",
"about_body_p2": "Wir haben Padelnomics gebaut, weil wir kein ausreichend gutes Finanzplanungstool gefunden haben. Vorhandene Rechner sind entweder zu simpel (5 Eingaben, ein Ergebnis) oder hinter teuren Beratungsmandaten verborgen. Wir wollten etwas mit der Tiefe eines professionellen Finanzmodells, aber der Zugänglichkeit einer Web-App.",
"about_body_p1": "Padel ist eine der am schnellsten wachsenden Sportarten weltweit, doch für die meisten Unternehmer ist die Eröffnung einer Padel-Anlage noch immer ein Sprung ins Ungewisse. Die Finanzen sind komplex: Der CAPEX variiert stark je nach Anlagentyp, der Standort bestimmt die Auslastung, und der Unterschied zwischen 60 % und 75 % Belegung kann über Erfolg oder Misserfolg einer Investition entscheiden.",
"about_body_p2": "Wir haben Padelnomics gebaut, weil wir kein ausreichend gutes Finanzplanungstool gefunden haben. Vorhandene Rechner sind entweder zu simpel (5 Eingaben, ein Ergebnis) oder hinter teuren Beratungsmandaten versteckt. Wir wollten etwas mit der Tiefe eines professionellen Finanzmodells, aber der Zugänglichkeit einer Web-App.",
"about_body_p3": "Das Ergebnis ist ein kostenloser Finanzplaner mit 60+ anpassbaren Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und den professionellen Kennzahlen, die Banken und Investoren sehen müssen. Jede Annahme ist transparent und anpassbar. Keine Blackboxen.",
"about_why_p": "Der Planer ist kostenlos, weil wir glauben, dass bessere Planung zu besseren Padelanlagen führt — und das ist gut für die gesamte Branche. Wir verdienen Geld, indem wir Unternehmer mit Platz-Anbietern und Finanzierungspartnern verbinden, wenn sie bereit sind, von der Planung zum Bau überzugehen.",
"about_why_p": "Der Planer ist kostenlos, weil wir glauben, dass bessere Planung zu besseren Padelanlagen führt — und das ist gut für die gesamte Branche. Wir verdienen Geld, indem wir Unternehmer mit Platz-Anbietern und Finanzierungspartnern verbinden, wenn sie bereit sind, von der Planung in den Bau zu wechseln.",
"about_next_p": "Padelnomics baut die Infrastruktur für Padel-Unternehmertum auf. Nach der Planung kommen Finanzierung, Bau und Betrieb. Wir arbeiten an Marktintelligenz auf Basis realer Buchungsdaten, einem Anbietermarktplatz für Platzausstattung und Analyse-Tools für Betreiber.",
"features_title_prefix": "Funktionen - Padel-Kostenrechner & Finanzplaner",
"features_meta_desc": "60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für deine Padelplatz-Investition.",
@@ -1175,11 +1175,11 @@
"landing_page_title": "Padelnomics - Padel-Kostenrechner & Finanzplaner",
"landing_meta_desc": "Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.",
"landing_og_desc": "Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.",
"landing_hero_desc": "Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Dann wirst du mit verifizierten Anbietern zusammengebracht.",
"landing_hero_desc": "Modelliere Deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Danach wirst Du mit verifizierten Anbietern zusammengebracht.",
"landing_journey_01_desc": "Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.",
"landing_journey_02_desc": "Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.",
"landing_journey_03_desc": "Kontakte zu Banken und Investoren herstellen. Dein Finanzplan wird zum Businesscase.",
"landing_journey_04_desc": "Über {total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu deinen Anforderungen vermittelt.",
"landing_journey_04_desc": "{total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu Deinen Anforderungen vermittelt.",
"landing_journey_05_desc": "Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für deinen Betrieb.",
"landing_feature_1_body": "Jede Annahme ist anpassbar: Platzbaukosten, Miete, Preisgestaltung, Auslastung, Finanzierungskonditionen, Exit-Szenarien. Nichts ist fest vorgegeben.",
"landing_feature_2_body": "Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen — jeder Tab mit interaktiven Diagrammen.",
@@ -1196,9 +1196,9 @@
"landing_faq_a3": "Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten.",
"landing_faq_a4": "Das Durchsuchen des Verzeichnisses ist für alle kostenlos. Anbieter erhalten standardmäßig einen Basiseintrag. Kostenpflichtige Pläne (Basic ab 39 €/Monat, Growth ab 199 €/Monat, Pro ab 499 €/Monat) schalten Anfrageformulare, vollständige Beschreibungen, Logos, verifizierte Badges und Prioritätsplatzierung frei.",
"landing_faq_a5": "Das Modell verwendet reale Standardwerte auf Basis globaler Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.",
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Paddelhalle zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.",
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob du als Unternehmer deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Paddelhalle bewertest — Padelnomics gibt dir die finanzielle Klarheit für fundierte Entscheidungen.",
"landing_final_cta_sub": "Modelliere deine Investition und lass dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.",
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot in Märkten von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Padel-Anlage zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.",
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Du als Unternehmer Deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Padel-Anlage bewertest — Padelnomics gibt Dir die finanzielle Klarheit für fundierte Entscheidungen.",
"landing_final_cta_sub": "Modelliere Deine Investition und lass Dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.",
"landing_jsonld_org_desc": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer.",
"plan_basic_f1": "Verifiziert-Badge",
"plan_basic_f2": "Firmenlogo",
@@ -1259,7 +1259,7 @@
"billing_pricing_og_title": "Kostenloser Padel-Finanzplaner",
"billing_pricing_og_desc": "Plane deine Padel-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Keine Anmeldung erforderlich. Vollständig kostenlos.",
"billing_pricing_h1": "100% kostenlos. Kein Haken.",
"billing_pricing_subtitle": "Der ausgefeilteste Padel-Finanzplaner auf dem Markt — vollständig kostenlos. Plane deine Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen.",
"billing_pricing_subtitle": "Der ausgefeilteste Padel-Finanzplaner am Markt — vollständig kostenlos. Plane Deine Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen.",
"billing_planner_card": "Finanzplaner",
"billing_planner_free": "Kostenlos",
"billing_planner_forever": "— für immer",
@@ -1281,7 +1281,7 @@
"billing_signup": "Jetzt registrieren",
"billing_success_title": "Willkommen",
"billing_success_h1": "Willkommen bei Padelnomics!",
"billing_success_body": "Dein Konto ist bereit. Starte jetzt mit dem Planen deiner Padel-Investition.",
"billing_success_body": "Dein Konto ist bereit. Fang jetzt an, Deine Padel-Investition mit unserem Finanzplaner zu planen.",
"billing_success_btn": "Planer öffnen",
"billing_no_subscription": "Kein aktives Abonnement gefunden.",
"sd_page_title": "Anbieter-Dashboard",
@@ -1303,11 +1303,11 @@
"sd_ov_credits_balance": "Credits-Guthaben",
"sd_ov_directory_rank": "Verzeichnis-Rang",
"sd_ov_basic_plan_label": "Basic-Tarif",
"sd_ov_basic_plan_desc": "Du hast einen verifizierten Eintrag mit Kontaktformular. Wechsle zu Growth, um auf qualifizierte Projekt-Leads zuzugreifen.",
"sd_ov_basic_plan_desc": "Du hast einen verifizierten Eintrag mit Kontaktformular. Wechsle zu Growth, um Zugang zu qualifizierten Projekt-Leads zu erhalten.",
"sd_ov_upgrade_growth": "Auf Growth upgraden",
"sd_ov_recent_activity": "Letzte Aktivitäten",
"sd_ov_credits": "Credits",
"sd_ov_no_activity": "Noch keine Aktivitäten. Schalte deinen ersten Lead frei.",
"sd_ov_no_activity": "Noch keine Aktivitäten. Schalte Deinen ersten Lead frei.",
"sd_bst_current_plan": "Aktueller Tarif",
"sd_bst_credits_month": "Credits/Monat",
"sd_bst_per_mo": "/Monat",
@@ -1462,16 +1462,15 @@
"sd_boost_verified_name": "Verifiziert-Badge",
"sd_boost_verified_desc": "Verifiziertes Häkchen-Badge",
"sd_boost_card_color_name": "Individuelle Kartenfarbe",
"sd_boost_card_color_desc": "Hebe dich mit einer individuellen Randfarbe in deinem Verzeichniseintrag ab",
"sd_boost_card_color_desc": "Heb Dich mit einer individuellen Rahmenfarbe in Deinem Verzeichniseintrag ab",
"sd_billing_yearly": "jährlich abgerechnet zu €{price}/Jahr",
"sd_billing_monthly": "monatlich abgerechnet",
"sd_flash_signin": "Bitte melde dich an, um fortzufahren.",
"sd_flash_signin": "Bitte melde Dich an, um fortzufahren.",
"sd_flash_active_plan": "Du benötigst einen aktiven Anbieter-Tarif, um auf diese Seite zuzugreifen.",
"sd_flash_lead_access": "Lead-Zugang erfordert einen Growth- oder Pro-Tarif.",
"sd_flash_valid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
"sd_flash_claim_error": "Dieser Eintrag wurde bereits beansprucht oder existiert nicht.",
"sd_flash_listing_saved": "Eintrag erfolgreich gespeichert.",
"bp_indoor": "Indoor",
"bp_outdoor": "Outdoor",
"bp_own": "Kauf",
@@ -1480,49 +1479,41 @@
"bp_payback_not_reached": "Nicht in 60 Monaten erreicht",
"bp_months": "{n} Monate",
"bp_years": "{n} Jahre",
"bp_exec_paragraph": "Dieser Businessplan modelliert eine <strong>{facility_type}</strong>-Padel-Anlage mit <strong>{courts} Pl\u00e4tzen</strong> ({sqm} m\u00b2). Die Gesamtinvestition betr\u00e4gt {total_capex}, finanziert mit {equity} Eigenkapital und {loan} Fremdkapital. Die prognostizierte IRR betr\u00e4gt {irr} bei einer Amortisationszeit von {payback}.",
"bp_exec_paragraph": "Dieser Businessplan modelliert eine <strong>{facility_type}</strong>-Padel-Anlage mit <strong>{courts} Plätzen</strong> ({sqm} m²). Die Gesamtinvestition beträgt {total_capex}, finanziert mit {equity} Eigenkapital und {loan} Fremdkapital. Die prognostizierte IRR beträgt {irr} bei einer Amortisationszeit von {payback}.",
"bp_lbl_scenario": "Szenario",
"bp_lbl_generated_by": "Erstellt von Padelnomics \u2014 padelnomics.io",
"bp_lbl_generated_by": "Erstellt von Padelnomics padelnomics.io",
"bp_lbl_total_investment": "Gesamtinvestition",
"bp_lbl_equity_required": "Eigenkapitalbedarf",
"bp_lbl_year3_ebitda": "EBITDA Jahr 3",
"bp_lbl_irr": "IRR",
"bp_lbl_payback_period": "Amortisationszeit",
"bp_lbl_year1_revenue": "Umsatz Jahr 1",
"bp_lbl_item": "Position",
"bp_lbl_amount": "Betrag",
"bp_lbl_notes": "Hinweise",
"bp_lbl_total_capex": "Gesamt-CAPEX",
"bp_lbl_capex_stats": "CAPEX je Platz: {per_court} \u2022 CAPEX je m\u00b2: {per_sqm}",
"bp_lbl_capex_stats": "CAPEX je Platz: {per_court} CAPEX je m²: {per_sqm}",
"bp_lbl_equity": "Eigenkapital",
"bp_lbl_loan": "Darlehen",
"bp_lbl_interest_rate": "Zinssatz",
"bp_lbl_loan_term": "Darlehenslaufzeit",
"bp_lbl_monthly_payment": "Monatliche Rate",
"bp_lbl_annual_debt_service": "J\u00e4hrlicher Schuldendienst",
"bp_lbl_annual_debt_service": "Jährlicher Schuldendienst",
"bp_lbl_ltv": "Beleihungsauslauf",
"bp_lbl_monthly": "Monatlich",
"bp_lbl_total_monthly_opex": "Monatlicher OPEX gesamt",
"bp_lbl_annual_opex": "Jahres-OPEX",
"bp_lbl_weighted_hourly_rate": "Gewichteter Stundensatz",
"bp_lbl_target_utilization": "Zielauslastung",
"bp_lbl_gross_monthly_revenue": "Monatlicher Bruttoumsatz",
"bp_lbl_net_monthly_revenue": "Monatlicher Nettoumsatz",
"bp_lbl_monthly_ebitda": "Monatliches EBITDA",
"bp_lbl_monthly_net_cf": "Monatlicher Netto-Cashflow",
"bp_lbl_year": "Jahr",
"bp_lbl_revenue": "Umsatz",
"bp_lbl_ebitda": "EBITDA",
"bp_lbl_debt_service": "Schuldendienst",
"bp_lbl_net_cf": "Netto-CF",
"bp_lbl_moic": "MOIC",
"bp_lbl_cash_on_cash": "Cash-on-Cash (J3)",
"bp_lbl_payback": "Amortisation",
@@ -1530,116 +1521,179 @@
"bp_lbl_ebitda_margin": "EBITDA-Marge",
"bp_lbl_dscr_y3": "DSCR (J3)",
"bp_lbl_yield_on_cost": "Rendite auf Kosten",
"bp_lbl_month": "Monat",
"bp_lbl_opex": "OPEX",
"bp_lbl_debt": "Schulden",
"bp_lbl_cumulative": "Kumulativ",
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io",
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Schätzungen und stellen keine Finanzberatung dar. Die tatsächlichen Ergebnisse können je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. © Padelnomics — padelnomics.io",
"email_magic_link_heading": "Bei {app_name} anmelden",
"email_magic_link_body": "Hier ist dein Anmeldelink. Er l\u00e4uft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden \u2192",
"email_magic_link_body": "Hier ist dein Anmeldelink. Er läuft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden ",
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_magic_link_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_magic_link_subject": "Dein Anmeldelink f\u00fcr {app_name}",
"email_magic_link_preheader": "Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab",
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Angebote",
"email_magic_link_ignore": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail ignorieren.",
"email_magic_link_subject": "Dein Anmeldelink für {app_name}",
"email_magic_link_preheader": "Dieser Link läuft in {expiry_minutes} Minuten ab",
"email_quote_verify_heading": "Bestätige deine E-Mail für Angebote",
"email_quote_verify_greeting": "Hallo {first_name},",
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage. Best\u00e4tige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
"email_quote_verify_body": "Danke für deine Angebotsanfrage. Bestätige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
"email_quote_verify_project_label": "Dein Projekt:",
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt behandelt.",
"email_quote_verify_btn": "Best\u00e4tigen & Aktivieren \u2192",
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt bearbeitet.",
"email_quote_verify_btn": "Bestätigen & Aktivieren ",
"email_quote_verify_expires": "Dieser Link läuft in 60 Minuten ab.",
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail \u2014 Anbieter sind bereit f\u00fcr Angebote",
"email_quote_verify_ignore": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail ignorieren.",
"email_quote_verify_subject": "Bestätige deine E-Mail Anbieter sind bereit für Angebote",
"email_quote_verify_preheader": "Ein Klick, um deine Angebotsanfrage zu aktivieren",
"email_quote_verify_preheader_courts": "Ein Klick, um dein {court_count}-Court-Projekt zu aktivieren",
"email_welcome_heading": "Willkommen bei {app_name}",
"email_welcome_greeting": "Hallo {first_name},",
"email_welcome_body": "Du hast jetzt Zugang zum Finanzplaner, Marktdaten und dem Anbieterverzeichnis \u2014 alles, was du f\u00fcr die Planung deines Padel-Gesch\u00e4fts brauchst.",
"email_welcome_body": "Du hast jetzt Zugang zum Finanzplaner, Marktdaten und dem Anbieterverzeichnis alles, was du für die Planung deines Padel-Geschäfts brauchst.",
"email_welcome_quickstart_heading": "Schnellstart:",
"email_welcome_link_planner": "Finanzplaner \u2014 modelliere deine Investition",
"email_welcome_link_markets": "Marktdaten \u2014 erkunde die Padel-Nachfrage nach Stadt",
"email_welcome_link_quotes": "Angebote einholen \u2014 verbinde dich mit verifizierten Anbietern",
"email_welcome_btn": "Jetzt planen \u2192",
"email_welcome_subject": "Du bist dabei \u2014 so f\u00e4ngst du an",
"email_welcome_link_planner": "Finanzplaner modelliere deine Investition",
"email_welcome_link_markets": "Marktdaten erkunde die Padel-Nachfrage nach Stadt",
"email_welcome_link_quotes": "Angebote einholen verbinde dich mit verifizierten Anbietern",
"email_welcome_btn": "Jetzt planen ",
"email_welcome_subject": "Du bist dabei — so fängst Du an",
"email_welcome_preheader": "Dein Padel-Planungstoolkit ist bereit",
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen eine Plattform, die dich mit qualifizierten Leads von Padel-Unternehmern verbindet, die aktiv Projekte planen.",
"email_waitlist_supplier_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
"email_waitlist_supplier_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
"email_waitlist_supplier_body": "Danke für dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen eine Plattform, die dich mit qualifizierten Leads von Padel-Unternehmern verbindet, die aktiv Projekte planen.",
"email_waitlist_supplier_perks_intro": "Als frühes Wartelisten-Mitglied erhältst du:",
"email_waitlist_supplier_perk_1": "Frühen Zugang vor dem öffentlichen Launch",
"email_waitlist_supplier_perk_2": "Exklusive Launch-Preise (gesichert)",
"email_waitlist_supplier_perk_3": "Pers\u00f6nliches Onboarding-Gespr\u00e4ch",
"email_waitlist_supplier_perk_3": "Persönliches Onboarding-Gespräch",
"email_waitlist_supplier_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
"email_waitlist_supplier_subject": "Du bist dabei \u2014 {plan_name} fr\u00fcher Zugang kommt",
"email_waitlist_supplier_link_planner": "Finanzplanungstool plane deine Padel-Anlage",
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis verifizierte Anbieter durchsuchen",
"email_waitlist_supplier_subject": "Du bist dabei {plan_name} früher Zugang kommt",
"email_waitlist_supplier_preheader": "Exklusive Launch-Preise + bevorzugtes Onboarding",
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
"email_waitlist_general_body": "Danke f\u00fcr deine Anmeldung. Wir bauen die Planungsplattform f\u00fcr Padel-Unternehmer \u2014 Finanzmodellierung, Marktdaten und Anbietervernetzung an einem Ort.",
"email_waitlist_general_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
"email_waitlist_general_body": "Danke für deine Anmeldung. Wir bauen die Planungsplattform für Padel-Unternehmer Finanzmodellierung, Marktdaten und Anbietervernetzung an einem Ort.",
"email_waitlist_general_perks_intro": "Als frühes Wartelisten-Mitglied erhältst du:",
"email_waitlist_general_perk_1": "Frühen Zugang vor dem öffentlichen Launch",
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
"email_waitlist_general_outro": "Wir melden uns bald.",
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 wir benachrichtigen dich zum Launch",
"email_waitlist_general_preheader": "Fr\u00fcher Zugang + exklusive Launch-Preise",
"email_waitlist_general_perk_3": "Prioritäts-Onboarding und Support",
"email_waitlist_general_outro": "Wir melden uns in Kürze.",
"email_waitlist_general_subject": "Du stehst auf der Liste wir benachrichtigen dich zum Launch",
"email_waitlist_general_preheader": "Früher Zugang + exklusive Launch-Preise",
"email_lead_forward_heading": "Neues Projekt-Lead",
"email_lead_forward_urgency": "Dieses Lead wurde gerade freigeschaltet. Anbieter, die innerhalb von 24 Stunden antworten, gewinnen 3x h\u00e4ufiger das Projekt.",
"email_lead_forward_urgency": "Dieses Lead wurde gerade freigeschaltet. Anbieter, die innerhalb von 24 Stunden antworten, gewinnen das Projekt 3× häufiger.",
"email_lead_forward_section_brief": "Projektbeschreibung",
"email_lead_forward_section_contact": "Kontakt",
"email_lead_forward_lbl_facility": "Anlage",
"email_lead_forward_lbl_courts": "Pl\u00e4tze",
"email_lead_forward_lbl_courts": "Plätze",
"email_lead_forward_lbl_location": "Standort",
"email_lead_forward_lbl_timeline": "Zeitplan",
"email_lead_forward_lbl_phase": "Phase",
"email_lead_forward_lbl_services": "Leistungen",
"email_lead_forward_lbl_additional": "Zus\u00e4tzlich",
"email_lead_forward_lbl_additional": "Zusätzlich",
"email_lead_forward_lbl_name": "Name",
"email_lead_forward_lbl_email": "E-Mail",
"email_lead_forward_lbl_phone": "Telefon",
"email_lead_forward_lbl_company": "Unternehmen",
"email_lead_forward_lbl_role": "Rolle",
"email_lead_forward_btn": "Im Lead-Feed ansehen \u2192",
"email_lead_forward_btn": "Im Lead-Feed ansehen ",
"email_lead_forward_reply_direct": "oder <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">direkt an {contact_email} antworten</a>",
"email_lead_forward_preheader_suffix": "Kontaktdaten enthalten",
"email_lead_matched_heading": "Ein Anbieter m\u00f6chte dein Projekt besprechen",
"email_lead_matched_heading": "Ein Anbieter möchte dein Projekt besprechen",
"email_lead_matched_greeting": "Hallo {first_name},",
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und Kontaktdaten.",
"email_lead_matched_context": "Du hast eine Angebotsanfrage f\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
"email_lead_matched_next_heading": "Was passiert als N\u00e4chstes",
"email_lead_matched_next_body": "Der Anbieter hat deine Projektbeschreibung und Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 24\u201348 Stunden per E-Mail oder Telefon.",
"email_lead_matched_tip": "Tipp: Schnelles Reagieren auf Anbieter-Kontaktaufnahmen erh\u00f6ht deine Chance auf wettbewerbsf\u00e4hige Angebote.",
"email_lead_matched_btn": "Zum Dashboard \u2192",
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
"email_lead_matched_subject": "{first_name}, ein Anbieter m\u00f6chte dein Projekt besprechen",
"email_lead_matched_preheader": "Der Anbieter wird sich direkt bei dir melden \u2014 das erwartet dich",
"email_lead_matched_body": "Gute Neuigkeit — ein verifizierter Anbieter wurde mit Deinem Padel-Projekt abgeglichen. Er hat Dein Projektbriefing und Deine Kontaktdaten.",
"email_lead_matched_context": "Du hast eine Angebotsanfrage für eine {facility_type}-Anlage mit {court_count} Plätzen in {country} eingereicht.",
"email_lead_matched_next_heading": "Was passiert als Nächstes",
"email_lead_matched_next_body": "Der Anbieter hat Dein Projektbriefing und Deine Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 2448 Stunden per E-Mail oder Telefon.",
"email_lead_matched_tip": "Tipp: Wer schnell auf Anbieter-Kontaktaufnahmen reagiert, erhöht seine Chancen auf wettbewerbsfähige Angebote.",
"email_lead_matched_btn": "Zum Dashboard ",
"email_lead_matched_note": "Du erhältst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter Deine Projektdetails freischaltet.",
"email_lead_matched_subject": "{first_name}, ein Anbieter möchte dein Projekt besprechen",
"email_lead_matched_preheader": "Der Anbieter meldet sich direkt bei Dir das erwartet Dich",
"email_enquiry_heading": "Neue Anfrage von {contact_name}",
"email_enquiry_body": "Du hast eine neue Anfrage \u00fcber deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
"email_enquiry_body": "Du hast eine neue Anfrage über deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
"email_enquiry_lbl_from": "Von",
"email_enquiry_lbl_message": "Nachricht",
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden f\u00fcr den besten Eindruck.",
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden für den besten ersten Eindruck.",
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a>.",
"email_enquiry_subject": "Neue Anfrage von {contact_name} \u00fcber deinen Verzeichniseintrag",
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu treten",
"email_enquiry_subject": "Neue Anfrage von {contact_name} über deinen Verzeichniseintrag",
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu kommen",
"email_business_plan_heading": "Dein Businessplan ist fertig",
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
"email_business_plan_includes": "Dein Plan enth\u00e4lt Investitions\u00fcbersicht, Umsatzprognosen und Break-Even-Analyse.",
"email_business_plan_btn": "PDF herunterladen \u2192",
"email_business_plan_quote_cta": "Bereit f\u00fcr den n\u00e4chsten Schritt? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Angebote von Anbietern einholen \u2192</a>",
"email_business_plan_includes": "Dein Plan enthält Investitionsübersicht, Umsatzprognosen und Break-Even-Analyse.",
"email_business_plan_btn": "PDF herunterladen ",
"email_business_plan_quote_cta": "Bereit für den nächsten Schritt? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Angebote von Anbietern einholen </a>",
"email_business_plan_subject": "Dein Businessplan-PDF steht zum Download bereit",
"email_business_plan_preheader": "Professioneller Padel-Finanzplan \u2014 jetzt herunterladen",
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast."
"email_business_plan_preheader": "Professioneller Padel-Finanzplan jetzt herunterladen",
"email_footer_tagline": "Die Planungsplattform für Padel-Unternehmer",
"email_footer_copyright": "© {year} {app_name}. Du erhältst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.",
"footer_market_score": "Market Score",
"mscore_page_title": "Der padelnomics Market Score — So messen wir Marktpotenzial",
"mscore_meta_desc": "Der padelnomics Market Score bewertet Städte von 0 bis 100 nach ihrem Potenzial für Padel-Investitionen. Erfahre, wie Demografie, Wirtschaftskraft, Nachfragesignale und Datenabdeckung einfließen.",
"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_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",
"mscore_cat_econ_p": "Regionale Kaufkraft und Einkommensindikatoren. In Märkten mit höherem verfügbarem Einkommen ist die Nachfrage nach Freizeitsportarten wie Padel tendenziell stärker.",
"mscore_cat_demand_h3": "Nachfrageindikatoren",
"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": "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",
"mscore_band_mid_p": "Gute Grundlagen mit Wachstumspotenzial. Genug Daten für fundierte Planung, aber weniger Wettbewerb als in den Top-Städten. Häufig der Sweet Spot für Neueinsteiger.",
"mscore_band_low_label": "Unter 45: Früher Markt",
"mscore_band_low_p": "Weniger validierte Daten oder kleinere Bevölkerung. Das heißt nicht, dass die Stadt unattraktiv ist — es kann weniger Wettbewerb und bessere Konditionen für Früheinsteiger bedeuten. Rechne mit mehr eigener Recherche vor Ort.",
"mscore_read_note": "Ein niedriger Score bedeutet nicht automatisch eine schlechte Investition. Er kann auf begrenzte Datenlage oder einen noch jungen Markt hinweisen — weniger Wettbewerb und günstigere Einstiegsbedingungen sind möglich.",
"mscore_sources_h2": "Datenquellen",
"mscore_sources_p": "Der Market Score basiert auf Daten europäischer Statistikämter (Bevölkerung und Wirtschaftsindikatoren), Buchungsplattformen für Padelanlagen (Standortanzahl, Preise, Auslastung) und geografischen Datenbanken (Standortdaten). Die Daten werden monatlich aktualisiert.",
"mscore_limits_h2": "Einschränkungen",
"mscore_limits_p1": "Der Score bildet die verfügbare Datenlage ab, nicht die absolute Marktwahrheit. Städte, in denen weniger Anlagen auf Buchungsplattformen erfasst sind, können bei den Nachfrageindikatoren niedrigere Werte zeigen — selbst wenn die lokale Nachfrage hoch ist.",
"mscore_limits_p2": "Der Score berücksichtigt keine lokalen Faktoren wie Immobilienkosten, Genehmigungszeiträume, Wettbewerbsdynamik oder regulatorische Rahmenbedingungen. Diese Aspekte sind entscheidend und erfordern Recherche vor Ort.",
"mscore_limits_p3": "Nutze den Market Score als Ausgangspunkt für die Priorisierung, nicht als finale Investitionsentscheidung. Im Finanzplaner kannst Du Dein konkretes Szenario durchrechnen.",
"mscore_cta_markets": "Stadtbewertungen ansehen",
"mscore_cta_planner": "Investment modellieren",
"mscore_faq_h2": "Häufig gestellte Fragen",
"mscore_faq_q1": "Was ist der padelnomics Market Score?",
"mscore_faq_a1": "Ein Komposit-Index von 0 bis 100, der die Attraktivität einer Stadt für Padelanlagen-Investitionen misst. Er kombiniert Demografie, Wirtschaftskraft, Nachfrageindikatoren und Datenqualität in einer vergleichbaren Kennzahl.",
"mscore_faq_q2": "Wie oft wird der Score aktualisiert?",
"mscore_faq_a2": "Monatlich. Neue Daten aus Statistikämtern, Buchungsplattformen und Standortdatenbanken werden regelmäßig extrahiert und verarbeitet. Der Score spiegelt immer die aktuellsten verfügbaren Daten wider.",
"mscore_faq_q3": "Warum hat meine Stadt einen niedrigen Score?",
"mscore_faq_a3": "Meist wegen begrenzter Datenabdeckung oder geringerer Bevölkerung. Ein niedriger Score bedeutet nicht, dass die Stadt unattraktiv ist — sondern dass uns weniger Daten zur Quantifizierung der Chance vorliegen. Eigene Recherche kann die Lücken schließen.",
"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_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,15 +1613,87 @@
"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 — 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 (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_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 — 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 — 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": "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 — 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 — 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",
"mscore_cta_planner": "Model your investment",
"mscore_faq_h2": "Frequently Asked Questions",
"mscore_faq_q1": "What is the padelnomics Market Score?",
"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 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_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

@@ -34,12 +34,15 @@ Design decisions
"""
import importlib
import logging
import os
import re
import sqlite3
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from dotenv import load_dotenv
@@ -89,7 +92,7 @@ def migrate(db_path=None):
if pending:
for name in pending:
print(f" Applying {name}...")
logger.info("Applying %s...", name)
mod = importlib.import_module(
f"padelnomics.migrations.versions.{name}"
)
@@ -98,9 +101,9 @@ def migrate(db_path=None):
"INSERT INTO _migrations (name) VALUES (?)", (name,)
)
conn.commit()
print(f"Applied {len(pending)} migration(s): {db_path}")
logger.info("Applied %s migration(s): %s", len(pending), db_path)
else:
print(f"All migrations already applied: {db_path}")
logger.info("All migrations already applied: %s", db_path)
# Show tables (excluding internal sqlite/fts tables)
cursor = conn.execute(
@@ -109,10 +112,11 @@ def migrate(db_path=None):
" ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
print(f" Tables: {', '.join(tables)}")
logger.info("Tables: %s", ", ".join(tables))
conn.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
migrate()

View File

@@ -0,0 +1,81 @@
"""Change articles unique constraint from url_path alone to (url_path, language).
Previously url_path was declared UNIQUE, which prevented multiple languages
from sharing the same url_path (e.g. /markets/germany/berlin for both de and en).
"""
def up(conn) -> None:
# ── 1. Drop FTS triggers + virtual table ──────────────────────────────────
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
conn.execute("DROP TRIGGER IF EXISTS articles_au")
conn.execute("DROP TABLE IF EXISTS articles_fts")
# ── 2. Recreate articles with UNIQUE(url_path, language) ──────────────────
conn.execute("""
CREATE TABLE articles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_slug TEXT,
language TEXT NOT NULL DEFAULT 'en',
date_modified TEXT,
seo_head TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT,
UNIQUE(url_path, language)
)
""")
conn.execute("""
INSERT INTO articles_new
(id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, template_slug, language,
date_modified, seo_head, created_at, updated_at)
SELECT id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, template_slug, language,
date_modified, seo_head, created_at, updated_at
FROM articles
""")
conn.execute("DROP TABLE articles")
conn.execute("ALTER TABLE articles_new RENAME TO articles")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
# ── 3. Recreate FTS + triggers ─────────────────────────────────────────────
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
)
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")

View File

@@ -0,0 +1,41 @@
"""Add missing indexes identified in performance review.
Columns used in WHERE clauses across admin and worker routes that
lacked indexes and would cause full table scans as the tables grow.
"""
def up(conn) -> None:
# lead_requests: filtered by lead_type, status, verified_at, country
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_type"
" ON lead_requests(lead_type)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_lead_requests_type_status"
" ON lead_requests(lead_type, status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_lead_requests_type_verified"
" ON lead_requests(lead_type, verified_at)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_lead_requests_country"
" ON lead_requests(country)"
)
# suppliers: filtered by tier and claimed_by in admin list + worker refill
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_suppliers_tier"
" ON suppliers(tier)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_suppliers_claimed_by"
" ON suppliers(claimed_by)"
)
# credit_ledger: balance queries sum delta grouped by supplier_id
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_credit_ledger_supplier"
" ON credit_ledger(supplier_id)"
)

View File

@@ -0,0 +1,18 @@
"""Add progress tracking columns to the tasks table.
Enables the pSEO Engine dashboard to show live progress during article
generation jobs: a progress bar (current/total) and an error log for
per-article failures without aborting the whole run.
"""
def up(conn) -> None:
conn.execute(
"ALTER TABLE tasks ADD COLUMN progress_current INTEGER NOT NULL DEFAULT 0"
)
conn.execute(
"ALTER TABLE tasks ADD COLUMN progress_total INTEGER NOT NULL DEFAULT 0"
)
conn.execute(
"ALTER TABLE tasks ADD COLUMN error_log TEXT NOT NULL DEFAULT '[]'"
)

View File

@@ -0,0 +1,12 @@
"""Migration 0022: Add response tracking columns to lead_forwards."""
FORWARD_STATUSES = ["sent", "viewed", "contacted", "quoted", "won", "lost", "no_response"]
def up(conn) -> None:
conn.execute("ALTER TABLE lead_forwards ADD COLUMN status_updated_at TEXT")
conn.execute("ALTER TABLE lead_forwards ADD COLUMN supplier_note TEXT")
conn.execute("ALTER TABLE lead_forwards ADD COLUMN cta_token TEXT")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lead_forwards_cta_token ON lead_forwards(cta_token) WHERE cta_token IS NOT NULL"
)

View File

@@ -0,0 +1,9 @@
"""Migration 0023: Add visible_from to lead_requests for 2-hour admin review window."""
def up(conn) -> None:
conn.execute("ALTER TABLE lead_requests ADD COLUMN visible_from TEXT")
# Backfill: existing verified leads are already past review — make them visible immediately
conn.execute(
"UPDATE lead_requests SET visible_from = created_at WHERE status = 'new' AND verified_at IS NOT NULL"
)

View File

@@ -3,10 +3,12 @@ Planner domain: padel court financial planner + scenario management.
"""
import json
import logging
import math
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
from quart import Blueprint, Response, g, jsonify, render_template, request
from ..auth.routes import login_required
@@ -18,6 +20,7 @@ from ..core import (
fetch_all,
fetch_one,
get_paddle_price,
utcnow_iso,
)
from ..i18n import get_translations
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
@@ -502,7 +505,7 @@ async def save_scenario():
location = form.get("location", "")
scenario_id = form.get("scenario_id")
now = datetime.utcnow().isoformat()
now = utcnow_iso()
is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0
@@ -533,7 +536,7 @@ async def save_scenario():
}
)
except Exception as e:
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
logger.warning("Failed to add %s to nurture audience: %s", g.user["email"], e)
lang = g.get("lang", "en")
t = get_translations(lang)
@@ -563,7 +566,7 @@ async def get_scenario(scenario_id: int):
@login_required
@csrf_protect
async def delete_scenario(scenario_id: int):
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(now, scenario_id, g.user["id"]),

View File

@@ -71,6 +71,7 @@
<button type="submit" class="btn" style="width:100%;margin-top:0.5rem" id="export-buy-btn">
{{ t.export_btn }}
</button>
<div id="export-error" style="display:none;margin-top:0.75rem;padding:0.75rem 1rem;background:#FEE2E2;color:#991B1B;border-radius:8px;font-size:0.875rem"></div>
</form>
</div>
@@ -106,9 +107,16 @@
{% block scripts %}
<script>
function showExportError(msg) {
var el = document.getElementById('export-error');
el.textContent = msg;
el.style.display = 'block';
}
document.getElementById('export-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('export-buy-btn');
document.getElementById('export-error').style.display = 'none';
btn.disabled = true;
btn.textContent = '{{ t.export_generating }}';
@@ -120,7 +128,7 @@ document.getElementById('export-form').addEventListener('submit', async function
});
const data = await resp.json();
if (data.error) {
alert(data.error);
showExportError(data.error);
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
return;
@@ -133,7 +141,7 @@ document.getElementById('export-form').addEventListener('submit', async function
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
} catch (err) {
alert('{{ t.export_failed }}');
showExportError('{{ t.export_failed }}');
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
}

View File

@@ -59,6 +59,11 @@ async def about():
return await render_template("about.html")
@bp.route("/market-score")
async def market_score():
return await render_template("market_score.html")
@bp.route("/imprint")
async def imprint():
lang = g.get("lang", "en")

View File

@@ -0,0 +1,265 @@
{% extends "base.html" %}
{% block title %}{{ t.mscore_page_title }}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.mscore_meta_desc }}">
<meta property="og:title" content="{{ t.mscore_page_title }}">
<meta property="og:description" content="{{ t.mscore_og_desc }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"name": "{{ t.mscore_page_title }}",
"description": "{{ t.mscore_meta_desc }}",
"url": "{{ config.BASE_URL }}/{{ lang }}/market-score",
"inLanguage": "{{ lang }}",
"isPartOf": {
"@type": "WebSite",
"name": "Padelnomics",
"url": "{{ config.BASE_URL }}"
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
{"@type": "ListItem", "position": 2, "name": "Market Score", "item": "{{ config.BASE_URL }}/{{ lang }}/market-score"}
]
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "{{ t.mscore_faq_q1 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a1 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q2 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a2 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q3 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a3 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q4 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a4 }}"}
},
{
"@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 }}"}
}
]
}
]
}
</script>
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="max-w-3xl mx-auto">
<!-- Hero -->
<header class="text-center mb-12">
<h1 class="text-3xl mb-2">
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
Market Score
</h1>
<p class="text-lg text-slate">{{ t.mscore_subtitle }}</p>
</header>
<!-- 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"><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">
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f465;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_demo_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_demo_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_cat_econ_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_econ_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f4c8;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_demand_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_demand_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f50d;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_data_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_data_p }}</p>
</div>
</div>
</section>
<!-- Marktreife-Score: How To Read -->
<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_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_band_high_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_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_band_mid_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_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_band_low_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_low_p }}</p>
</div>
</div>
<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>
<p class="text-slate-dark leading-relaxed">{{ t.mscore_sources_p }}</p>
</section>
<!-- Limitations -->
<section class="card mb-8">
<h2 class="text-xl mb-4">{{ t.mscore_limits_h2 }}</h2>
<div class="space-y-3 text-slate-dark leading-relaxed">
<p>{{ t.mscore_limits_p1 }}</p>
<p>{{ t.mscore_limits_p2 }}</p>
<p>{{ t.mscore_limits_p3 }}</p>
</div>
</section>
<!-- CTA -->
<div class="text-center my-12">
<a href="{{ url_for('content.markets') }}" class="btn" style="margin-right:0.75rem">{{ t.mscore_cta_markets }}</a>
<a href="{{ url_for('planner.index') }}" class="btn-secondary" style="display:inline-block;padding:0.625rem 1.25rem;border-radius:6px;font-weight:600;font-size:0.875rem;text-decoration:none">{{ t.mscore_cta_planner }}</a>
</div>
<!-- FAQ -->
<section>
<h2 class="text-xl mb-4">{{ t.mscore_faq_h2 }}</h2>
<div class="space-y-4">
{% 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>
</details>
{% endfor %}
</div>
</section>
</div>
</main>
{% endblock %}

View File

@@ -34,12 +34,15 @@ Fields mapped (DuckDB → data_json camelCase key):
import argparse
import json
import logging
import os
import sqlite3
from pathlib import Path
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
@@ -67,13 +70,13 @@ def _load_analytics(city_slugs: list[str]) -> dict[str, dict]:
"""
path = Path(DUCKDB_PATH)
if not path.exists():
print(f" [analytics] DuckDB not found at {path} — skipping analytics refresh.")
logger.warning("DuckDB not found at %s — skipping analytics refresh.", path)
return {}
try:
import duckdb
except ImportError:
print(" [analytics] duckdb not installed — skipping analytics refresh.")
logger.warning("duckdb not installed — skipping analytics refresh.")
return {}
result: dict[str, dict] = {}
@@ -98,7 +101,7 @@ def _load_analytics(city_slugs: list[str]) -> dict[str, dict]:
result[slug] = overrides
except Exception as exc:
print(f" [analytics] DuckDB query failed: {exc}")
logger.error("DuckDB query failed: %s", exc)
return result
@@ -124,13 +127,13 @@ def refresh(dry_run: bool = False) -> int:
city_slug_to_ids.setdefault(slug, []).append(row["id"])
if not city_slug_to_ids:
print("No template_data rows with city_slug found.")
logger.info("No template_data rows with city_slug found.")
conn.close()
return 0
analytics = _load_analytics(list(city_slug_to_ids.keys()))
if not analytics:
print("No analytics data found — nothing to update.")
logger.info("No analytics data found — nothing to update.")
conn.close()
return 0
@@ -154,13 +157,13 @@ def refresh(dry_run: bool = False) -> int:
data.update(overrides)
if dry_run:
print(f" [dry-run] id={row_id} city_slug={slug}: {changed}")
logger.info("[dry-run] id=%s city_slug=%s: %s", row_id, slug, changed)
else:
conn.execute(
"UPDATE template_data SET data_json = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(data), row_id),
)
print(f" Updated id={row_id} city_slug={slug}: {list(changed.keys())}")
logger.info("Updated id=%s city_slug=%s: %s", row_id, slug, list(changed.keys()))
updated += 1
if not dry_run:
@@ -184,7 +187,7 @@ def _trigger_generation() -> None:
headers={"X-Admin-Key": admin_key},
)
with urllib.request.urlopen(req, timeout=120) as resp:
print(f" Generation triggered: HTTP {resp.status}")
logger.info("Generation triggered: HTTP %s", resp.status)
def main() -> None:
@@ -195,14 +198,17 @@ def main() -> None:
help="Trigger article re-generation after updating")
args = parser.parse_args()
print(f"{'[DRY RUN] ' if args.dry_run else ''}Refreshing template_data from DuckDB…")
prefix = "[DRY RUN] " if args.dry_run else ""
logger.info("%sRefreshing template_data from DuckDB...", prefix)
count = refresh(dry_run=args.dry_run)
print(f"{'Would update' if args.dry_run else 'Updated'} {count} rows.")
action = "Would update" if args.dry_run else "Updated"
logger.info("%s %s rows.", action, count)
if args.generate and count > 0 and not args.dry_run:
print("Triggering article generation")
logger.info("Triggering article generation...")
_trigger_generation()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

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