merge: bring worktree up to master before pipeline console work
This commit is contained in:
104
.env.dev.sops
104
.env.dev.sops
@@ -1,72 +1,76 @@
|
|||||||
#ENC[AES256_GCM,data:rfm9xw==,iv:yWV+DjVlLNdDXw8brZZ98NGMr5pF88Oy14laCyF9XSk=,tag:EKvfFOCjrJD8NTQ/gOym7A==,type:comment]
|
#ENC[AES256_GCM,data:9JNa0g==,iv:KnGl3/4KQWkVnFXn9iKU5z5Ys6KXWOnSEoE/Jjks2pw=,tag:ZD3nrOQmhUjPkZiwtV330g==,type:comment]
|
||||||
APP_NAME=ENC[AES256_GCM,data:H4Ho9hHoL4Fo+4c=,iv:hBnuls1xYBtHMxU/womw+Om3JR0yrKXp7+VeiLcZiyM=,tag:oKJiE3VekDwMjpF+evoumQ==,type:str]
|
APP_NAME=ENC[AES256_GCM,data:Vic/MJYoxZo8JAI=,iv:n1SEGQaGeZtYMtLmDRFiljDBbNKFvCzZPNtaFBNauYY=,tag:Smsd20Ba56QZKVFpRmhRPQ==,type:str]
|
||||||
SECRET_KEY=ENC[AES256_GCM,data:+5bv1jlS+1DnmKxVxebcdJ7+ADJjvgk3hOqUM5LnB/0=,iv:Y9obfg6ttf12J6L3hgVJr1S4tJoayFHpp2zfUgT1Vek=,tag:RWN2OMZqpNm0uY4YqV9FZg==,type:str]
|
SECRET_KEY=ENC[AES256_GCM,data:a3Bhj3gSQaE3llRWBYzpjoFDhhhSsNee67jXJs7+qn4=,iv:yvrx78X5Ut4DBSlmBnIn09ESVc/tuDiwiV4njmjcvko=,tag:cbFUTAEpX+isQD9FCVllsw==,type:str]
|
||||||
BASE_URL=ENC[AES256_GCM,data:DSgPTAuGfA9/ntDJ5JT34zVbJush,iv:Lh6vcDVfPfPBi0Bwd34h2CQX+D8bxqWF8O47Oid8EHg=,tag:PoP8DUX+GsAmc7Ntweeing==,type:str]
|
BASE_URL=ENC[AES256_GCM,data:LcbPDZf9Pwcuv7RxN9xhNfa9Tufi,iv:cOdjW9nNe+BuDXh+dL4b5LFQL2mKBiKV0FaEsDGMAQc=,tag:3uAn3AIwsztIfGpkQLD5Fg==,type:str]
|
||||||
DEBUG=ENC[AES256_GCM,data:ibm6FA==,iv:nhDzB8x3pe6ehhU69S1ZN+cNN7Cchj7MK+8NUea3Zug=,tag:kcL4esX+uPNdifenuW0TMQ==,type:str]
|
DEBUG=ENC[AES256_GCM,data:qrEGkA==,iv:bCyEDWiEzolHo4vabiyYTsqM0eUaBmNbXYYu4wCsaeE=,tag:80gnDNbdZHRWVEYtuA1M2Q==,type:str]
|
||||||
#ENC[AES256_GCM,data:LdfNHhD4n53JP3blJVGX7lfg2DuaUsFp0p0mI0SPjCOlgJI661jGXjSpvtZP+3Fh7g09KzUNjlhFuugR7082u5WRiQx6ks2BKA==,iv:lHqw7UTr68hZLYyYYbJuB/Mqfyds87NOPl0yE5x4eyU=,tag:rDrOSOqyBIPFq1BC4Ktw8A==,type:comment]
|
#ENC[AES256_GCM,data:YmlGAWpXxRCqam3oTWtGxHDXC+svEXI4HyUxrm/8OcKTuJsYPcL1WcnYqrP5Mf5lU5qPezEXUrrgZy8vjVW6qAbb0IA2PMM4Kg==,iv:dx6Dn99dJgjwyvUp8NAygXjRQ50yKYFeC73Oqt9WvmY=,tag:6JLF2ixSAv39VkKt6+cecQ==,type:comment]
|
||||||
ADMIN_EMAILS=ENC[AES256_GCM,data:0jOhhL5ncjyx7c3hGg==,iv:UdU6U7Qz50KL+Aa1UPo1Vvo0Rhb5aT0MdxN2sFW/sMc=,tag:3LhGEXuXTu2mtMJbnFFsSw==,type:str]
|
ADMIN_EMAILS=ENC[AES256_GCM,data:hlG8b32WlD4ems3VKQ==,iv:wWO08dmX4oLhHulXg4HUG0PjRnFiX19RUTkTvjqIw5I=,tag:KMjXsBt7aE/KqlCfV+fdMg==,type:str]
|
||||||
#ENC[AES256_GCM,data:AyuwH3wRLrh7,iv:4A/7vGSqb7CVLePYrgKqGJIz1hqJwC0v5ikKtOhMLUM=,tag:ADfmbAXEZIF8HObsTM3DEg==,type:comment]
|
#ENC[AES256_GCM,data:b2wQxnL8Q2Bp,iv:q8ep3yUPzCumpZpljoVL2jbcPdsI5c2piiZ0x5k10Mw=,tag:IbjkT0Mjgu9n+6FGiPVihg==,type:comment]
|
||||||
DATABASE_PATH=ENC[AES256_GCM,data:zLXck4opQIMGFqc=,iv:mqX3ONrD/hph6teavhSh9m30FAR3hIxQdeeJb4SnOR4=,tag:LgT7C816/wQlYHECZ+1gww==,type:str]
|
DATABASE_PATH=ENC[AES256_GCM,data:pEpMUrL7ZHAzMT4=,iv:eDGudDVsW5vF0sENri7gQrFlCEdoWYP6hT5ZeXXs3Zg=,tag:Gl91C6uRdCiJ7Jo1Z/MQsg==,type:str]
|
||||||
#ENC[AES256_GCM,data:wiAYWd0=,iv:ngyBfrG1QEBh5TXulXlCKSuzRccFhxYs8GPozCz5Uqo=,tag:kdwHR1nQm0bmTbbTrEUqDg==,type:comment]
|
#ENC[AES256_GCM,data:xVzlko4=,iv:glHTshoRIkIaJNpn4onyAxPOtXTsNh/JohXJyyu4Ars=,tag:fQ/53HdxYXs2JTMx6O8rrA==,type:comment]
|
||||||
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:Tvc=,iv:eV62HtqgApJXdiHTWLeWj+ESCK3GK4OyHmSgNd6gsxw=,tag:TJAH5lUx/XWKtDu2Vt/mBw==,type:str]
|
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:3gk=,iv:gtuxbN4TPF6kuEr3W8WVxH9cDYl4KyYqFpUrIPCjalo=,tag:QE3OBTJoQfG2GcD/UXBVBw==,type:str]
|
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:/gk=,iv:kKWy+FaoLp8kAWpZzpoUHX8nVFRaA4yuTTVzN2TSYTs=,tag:QypZTVmTo4lXd7PKTWrBdA==,type:str]
|
||||||
#ENC[AES256_GCM,data:dEwIOVzHW+whu48/e81j,iv:v9Du6iruCsArD8F/JCf/xy9xxzdV23wCnfHCkuIgPY4=,tag:w7RVK4a0pxmkNDwLTn3mRA==,type:comment]
|
#ENC[AES256_GCM,data:8HLEkeUESRt3bOxIQsma,iv:kzt8+SFNJw2r3LqwwQPzs9bCdacYSfHWPzIvTxARI4k=,tag:n+F13eILUiJCZ3NtQdo26g==,type:comment]
|
||||||
#ENC[AES256_GCM,data:+aUQXMWkNNHvFcINaDzgi85NLBsSHISWr3oAkB5qJwqKv+uC+MtqkQ2rVaDbfBAfKUKU6r64ShcWiFc/p7ikNEIaeVQYNK8JQ+Tn+A==,iv:w7G2AF3FPh7Qe++HhFuuLQjZB4vrcrG0uI37L2rXuDA=,tag:zU0czMUSD0qKIT8sLKviWA==,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:icBc0Zv+oedobh8DOTwV2Fc+N0C9CqjZvLciC1dmEIygr/P5oOBLH9Bnhf7XW3X+fiLUtLPQPUIh9CjX+wVeX+MpUZj8ksS0meoz1O2kSBs=,iv:oWreqF6QxaFZn2r35uqY7yy/nItwy3k3VuXAcLyqMbI=,tag:ezQO+m6qkQSEwe17vYtYcw==,type:comment]
|
||||||
#ENC[AES256_GCM,data:ua/erKnlXzB+Wae1Jwdr4Xg0Cy3xIiaEpPJefBq1eEOm54ZTNeqZGFfYS7LuijgueuOdq/Bmacq56sfNbiEEbUeehf5f,iv:GWuX4YPyG/9KBdK5RNrH+hFB5QXc+Ep8Ao2mYm6/EFU=,tag:dTPTsWaloxADlplot5SNfw==,type:comment]
|
#ENC[AES256_GCM,data:BmlWl0f3aiOrEVglJisqHb507/ipmyRCUvkygs2jBfh2gw3BJgrCAAqoK+DekvIls2myRn7RynqWTMZKGXtJMHu5SEA8,iv:QjPoSzyNl6CBYmJAd2OfFEEoXO3jz0LL2VNegP0mY8Q=,tag:miTvj9PK1a00BKaAjUTICw==,type:comment]
|
||||||
#ENC[AES256_GCM,data:K5FoYsnz7dZBR/HZh4KX93WipQDQpvUP+o3xDUNALCm2zZbtQ8pPmTwbug7NpJnrm75MGkI=,iv:pxm+cMZhJOzI5Uys25JRL2g2allAHG5v6VFqNpyvtrI=,tag:GTy5/FtmT/sqADxoX3Yg1g==,type:comment]
|
#ENC[AES256_GCM,data:kzSYQCopgU9wXpw61WGfYpRtOjV3iEVvZ09RP4OqVl+Rqnd2wCKREKKrB7F15bp4BB3OzMo=,iv:TFFpROfYKaUlWQm3ISYkyYdZCarSJbqHItLMUplYiXc=,tag:Xbk3ii3DYE4yPe8cJOt+Tw==,type:comment]
|
||||||
#ENC[AES256_GCM,data:Apk0ukZWg1ZguSFLaRmjDq4hw+RQc1CnSqcJAybC7m+SDW0nEkMY953nVkqY7oDvKeuhEIUySg8=,iv:3QOosRhjJHve9xw8y3rD4guZ5cv9B9uCJaMHqgkyO1A=,tag:fETCJefFt1G5fIonehAlXQ==,type:comment]
|
#ENC[AES256_GCM,data:qbh0Mnu6wEbDaBideJQCZa74G/DqSWuoiy22zCGoKqKZ2YVQEf0QxRCO/DgOD8rdp1Neqp8u4oE=,iv:dKV76b8sT1ghlyEadeAqTjtNTXrBp9n5ZbGMGi2/GyU=,tag:1HsI5iDekTqxeYmcEpL3HA==,type:comment]
|
||||||
#ENC[AES256_GCM,data:PsEk9JIsPZJ8sHjVLugtfCiKx1ulPXjjtz2+zS1fKrlAgDTUJ7Pe0vCuSgemfCZG5hlfK/DN8ows5oaeb28=,iv:d8S9eXEM+U5vBiDj8nCv949qFiec1deN348QUDZMDII=,tag:tJAn2b2U5OmaWSWBJRmfAw==,type:comment]
|
#ENC[AES256_GCM,data:kTLnLnwPVVDFKYncBbFjGmnbxmNfpPXSpKyZu5ZQx6PeVs5s6hpDa55zRXxAetyBAHsmV99ZV2q1NTDXF6Q=,iv:m/ulRFQcGl15vi2ohMwVeYBmcRtp274ROiKXPsyJkfQ=,tag:T0E5p4d/inHyuupbg7bZHQ==,type:comment]
|
||||||
#ENC[AES256_GCM,data:sCb3wLMhMX592Si7cIPgvB2hfl94qWNWifpDVgpkdMyF5y15PS+SZ1ouetU7Gi7UEVzwWxuct80=,iv:LpSZ+QZG/VqK1cUxVakdIi0bRjwBPMCLNEPr7D5xIu8=,tag:XPwk5T66WmViYf0faBsm9A==,type:comment]
|
#ENC[AES256_GCM,data:aziyEFGCGxbc+q2ma2QN4MvdhQ6bnYuZA7Cgqr6p3zGjPG3oybTWwILejJqD2lHmULXh0UN0qco=,iv:XZwwEUOAEXIUyXiiHFS/bdL91bWKIhZ5IzcXWXAR63Y=,tag:JLsDM4s2yh4aBFQtxWLhDA==,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: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=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM=ENC[AES256_GCM,data:8D4sqeCDj0dw1Kh0sHi9h3q4ckg=,iv:GlWgA3OzZUgMbg5MQwpiiWpn20at/tkgxbpR16io5qo=,tag:Qpq6B2tjzPXBiMzvNgjSaQ==,type:str]
|
EMAIL_FROM=ENC[AES256_GCM,data:QX6duq5wx3z98o39nRXTrPpNXwI=,iv:ikpykHOeHRay+k3B4MvHn2SOuHNGOIuvjetOt+cjTrQ=,tag:8ryM56ogWySp9RAv0/ABTg==,type:str]
|
||||||
LEADS_EMAIL=ENC[AES256_GCM,data:Pj74LSKvkjJ48RLqUuAPpOzrgLI=,iv:iZinDeQbqL1DfbqYu0Duux5GQNRBYG2JhTXdjXQgpOA=,tag:ZWXSf4WlSL0wykOVPFjf3A==,type:str]
|
LEADS_EMAIL=ENC[AES256_GCM,data:aVFgeh0Yx7W/88noeURvf8rirv8=,iv:5KjsCMAsu1Ywz4BI2JjB1KmQ6QM94U1zlNGJ3BKl7Uo=,tag:voi0kjdhz0SsOQHqtMID4Q==,type:str]
|
||||||
RESEND_WEBHOOK_SECRET=
|
RESEND_WEBHOOK_SECRET=
|
||||||
#ENC[AES256_GCM,data:gjvHsdCmiGT0hw/lvUuu7yMfXWMBjvwAvvwTl0RpZxptLiG7Wz4s6A5saBnZCZbnvrHpXoJJ2lyPqWdt7XsnpRBAQQ==,iv:jMWd+hNbwtB4DCUM+pjTihrRSSCVr+qNuoAT4pZp7QQ=,tag:ncxbxmCRgiR1VNuGZTu0mg==,type:comment]
|
#ENC[AES256_GCM,data:1HqXvAspvNIUNpCxJwge3mEsyO0Y/EWvD3vbLxkgGqIex0hABcupX/Nzk15u8iOY5JWvvEuAO414MNt6mFvnWBDpEw==,iv:N7gCzTNJAR/ljx5gGsX+ieZctya8vQbCIb3hw49OhXg=,tag:PJKNyzhrit5VgIXl+cNlbQ==,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:do6DZ/1Osc5y4xseG8Q8bDX84JBHLzvmVbHiqxP7ChlicmzYBkZ85g43BuM7V0KInFTFgvaC8xmFic+2d37Holuf1ywdAjbLkRhg,iv:qrNmhPbmFDr2ynIF5EdOLZl3FI5f68WDrxuHMkAzuuU=,tag:761gYOlEdNM+e1//1MbCHg==,type:comment]
|
||||||
#ENC[AES256_GCM,data:NU2hol5nqs8ffhhDqAXZg48eFzPTw14gO4zuyJhlO075bC18EIMi+2xz0Hg7CC5aa+AuywdjSXeO92j1CvM4YKfng63biI4b/NIdXQ==,iv:IYA4mY+V8jQD/jElsgbwa5fRQ336XSzYv3q9OlZ/DG0=,tag:OXZwhCSmSADMxDotptCKvA==,type:comment]
|
#ENC[AES256_GCM,data:dseLIQiUEU20xJqoq2dkFho9SnKyoyQ8pStjvfxwnj8v18/ua0TH/PDx/qwIp9z5kEIvbsz5ycJesFfKPhLA5juGcdCbi5zBmZRWYg==,iv:7JUmRnohJt0H5yoJXVD3IauuJkpPHDPyY02OWHWb9Nw=,tag:KcM6JGT01Aa1kTx+U30UKQ==,type:comment]
|
||||||
#ENC[AES256_GCM,data:xRM7eWDm3yrN4gdmWR6nlafMlL5F+0CbNa+45c6dU53fwlf5eFnpKF2700/8XwWe5h6s,iv:v5/tdyxozElqXLjC4Dr1HzHVPwI7e9DgK53nB77pArs=,tag:md4YiR0gkSaTiEMhrgV/4w==,type:comment]
|
#ENC[AES256_GCM,data:GgXo4zkhJsxXEk8F5a/+wdbvBUGN00MUAutZYLDEqqN4T1rZu92fioOLx7MEoC0b8i61,iv:f1hUBoZpmnzXNcikf/anVNdRSHNwVmmjdIcba3eiRI4=,tag:uWpF40uuiXyWqKrYGyLVng==,type:comment]
|
||||||
PADDLE_API_KEY=
|
PADDLE_API_KEY=
|
||||||
PADDLE_CLIENT_TOKEN=
|
PADDLE_CLIENT_TOKEN=
|
||||||
PADDLE_WEBHOOK_SECRET=
|
PADDLE_WEBHOOK_SECRET=
|
||||||
PADDLE_NOTIFICATION_SETTING_ID=
|
PADDLE_NOTIFICATION_SETTING_ID=
|
||||||
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:YzbXeOJr4Q==,iv:eZ0lAAfjVTtHHEkBR80fZACE6VTXrow4bnogAz8VI48=,tag:Uxs9oEZZI5jBloSSlOPoLg==,type:str]
|
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:KIGNxEaodA==,iv:SRebaYRpVJR0LpfalBZJLTE8qBGwWZB/Fx3IokQF99Q=,tag:lcC56e4FjVkCiyaq41vxcQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:JZ+dTFncUwrhh5kdBeKbHkPk4HNOu5Ka7l8IhPnkcpbC4+opxuviWV8QXG/lcOlg9SN004FQ83kOPjrI,iv:3txict0Am1Gp/qNFgB5d7d46nVLtyBBixXdJjGiRoVo=,tag:Y1D5nzSveG0c3Qoyvy59lQ==,type:comment]
|
#ENC[AES256_GCM,data:2Hs7ds2ppeRqKB7EiAAbWqlainKdZ+eTYZSvPloirT4Hlsuf+zTwtJTA6RzHNCuK4em//jhOx8R2k80I,iv:1N6CNPqYWp3z8lm5e2Vp6OlpgHdMOiD7dsEYp23nMtA=,tag:ulWP/BFFoLljLMVCrsgizw==,type:comment]
|
||||||
UMAMI_API_URL=ENC[AES256_GCM,data:VLov17JIMAAmiv0Rq8TR637k1ablVBtJ9GKgWQ==,iv:MqV0T/4xqWit/vZm+sMu0LNTzCH3ILFCivQnD8LTpXA=,tag:5VdbDoubaaFy67+y4u0EQQ==,type:str]
|
UMAMI_API_URL=ENC[AES256_GCM,data:oX/m95YB+S2ziUKoxDhsDzMhGZfxppw+w603tQ==,iv:GAj7ccF6seiCfLAh2XIjUi13RpgNA3GONMtINcG+KMw=,tag:mUfRlvaEWrw2QWFydtnbNA==,type:str]
|
||||||
UMAMI_API_TOKEN=
|
UMAMI_API_TOKEN=
|
||||||
#ENC[AES256_GCM,data:hxmk761Ynp57ssLcCIM=,iv:ApzwBN4h8ZU7XvJEG3V8Jr+OH3yiTxq2hx0ts+1MP0M=,tag:msUwMv/h8Z5pyrKrYzyjHw==,type:comment]
|
#ENC[AES256_GCM,data:HTG/nKNl9NMicZVt5nU=,iv:MfRqX6tzdl6SC61xjRxTrVRpTWGmmqslL/Vdy88Jtyo=,tag:NhOgm3+qJelmQaAAnITFKA==,type:comment]
|
||||||
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:p0XT,iv:FXMjZ/Vi0O3ZvvgT9P12fYV57ksWkIKKHsXTFAtJ1BQ=,tag:oHLBpOp6WIGxxtkEdJyutA==,type:str]
|
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:+6s=,iv:vwMf5cyfkwxSB4mA8/OJabURcGHHQNS5I9jIA+CP74I=,tag:HwT077P9h6+YkIwVJe9HTg==,type:str]
|
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:vE8=,iv:lS6cQX3VzHeVrlYHQTXYGgib1rYI9G4XoW/f5YSjVWs=,tag:3Bn8PIktDxD7HvUTHw6mnw==,type:str]
|
||||||
#ENC[AES256_GCM,data:DWa+fY4uRmAYEQyxQUepVUiZ23Kw10IqUdiZvqqo5lKn83IPOqqutM7TO//QSeCCGtExATPXx4WumfsWWfAnyfH5W/LndJ1AlwpVOoVtpWI=,iv:Uj80Naei97O7hGyEcxfr6iFzgERUCEkRu1iKH4DuJX4=,tag:xcz9zPrEEauLyRtoVffsjQ==,type:comment]
|
#ENC[AES256_GCM,data:KRlMK35PPFBTe7FOkbanuskbA4oFj51Fg290lRtwyHKoJxi7fHg7cueojwCiRSJestRguwV8g9UP4MC9bKzWssdFqvfdr7XEUuA3a+WWD9I=,iv:RZhJJS6tNZHecxn/862nnl8dg8OwsVYB/R0yPxYMXgw=,tag:dqXgcU8OSyJzOPJp+7Z+cA==,type:comment]
|
||||||
LITESTREAM_R2_BUCKET=
|
LITESTREAM_R2_BUCKET=
|
||||||
LITESTREAM_R2_ACCESS_KEY_ID=
|
LITESTREAM_R2_ACCESS_KEY_ID=
|
||||||
LITESTREAM_R2_SECRET_ACCESS_KEY=
|
LITESTREAM_R2_SECRET_ACCESS_KEY=
|
||||||
LITESTREAM_R2_ENDPOINT=
|
LITESTREAM_R2_ENDPOINT=
|
||||||
#ENC[AES256_GCM,data:Wh8wbI1ONGwz7YmTh3g=,iv:hsgNVrb1ZmItszvWSW5XozSTSoORc48ePg/L7wk3k0E=,tag:ryrs1Myp6JMNO4PppsEy5A==,type:comment]
|
#ENC[AES256_GCM,data:4To0MRZIt3HxO7qjh4E=,iv:/caczOlTPECDF6mA1PKO8Xm4NeR1RZjgpt2Vuq+rfkQ=,tag:S/UGMqHZQX/Q20N+Ah30WQ==,type:comment]
|
||||||
DUCKDB_PATH=ENC[AES256_GCM,data:MA6E5KnIZxOd1rOA5cLGk0oXoTk5,iv:Q+xoCHnf6x4ismgOqXSqePEV2T5RV8J2KIyD+Pdidbs=,tag:80GfhXAZ4AeXQ4HEz72K9g==,type:str]
|
DUCKDB_PATH=ENC[AES256_GCM,data:sql4dtOLeX1aY/kdaxAzCk47hm3t,iv:S63x40+5blcF8qYxMjqUhs2moukuy2yEQRPbUvXZSYo=,tag:lTLYjtyZNiv06o/hm6Grxg==,type:str]
|
||||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:ubhnX43J3bEw1g2xJDhQWJiLNYrd,iv:Z9ltDGDYhTl98Pg18wCmU+Qxco8+PKleZ/SkhD9XGCs=,tag:7BtwbKiHcM17DpvnwLSB/Q==,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:e+ZJClS0YWbTOgVo,iv:Mh13edgTjG/cW/0hsdvM32uQOlBJwVpC2nju67+n84Q=,tag:S6/50MMQOmxMmpCC7XRavQ==,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:hrtFixymQ0XR1t288qEETWAajvEe13tZlSAmnwpaEGr11wzr+b6rd7QHc+enb2/lkSOcnIKRxCOZtu6y+tEAGuZImpijf5+Lza8=,iv:CLniQ1Jf6JcmzgHpzbCn9WFTJOPLGTFXu/5jVCdIrtQ=,tag:vv5Hwu2TGQeGRejEOwa/dg==,type:comment]
|
#ENC[AES256_GCM,data:bsiiPYvTz0LtdIgopkPEtcgmtDzZU0W6uton/sqm++5UymV33B0m47LIpdH9xQurQtmoZwMCBkAe0FCqqz62D1dAIH1Q6lzzLqg=,iv:rr7aShvtJtAnBzcbr/O0wOONpDBzwbR/Wbx/YPPsKpM=,tag:YH1wdokUuudFvagnPuT8aA==,type:comment]
|
||||||
REPO_DIR=ENC[AES256_GCM,data:ZA==,iv:TH+5LUPD7fKSj+kgtFCmsxEbG0sO5gtNPBi0k5yuiAc=,tag:pVy/nn7YalVV44zELdMIyg==,type:str]
|
REPO_DIR=ENC[AES256_GCM,data:vg==,iv:TNMZ6lrajWy6C9q89/AbRkBawBc2YaGsn2elbO8V2Wk=,tag:va77fkt8VDpPG8pZu490uw==,type:str]
|
||||||
WORKFLOWS_PATH=ENC[AES256_GCM,data:KcdUD1rSa1VBKzktiuHGA+a/cI7m/GWXkrKr50NhgQ==,iv:VgF8+wZmg61+sVoHeL2U7PJuTQ5UuOeonaTPX7mdHBA=,tag:CIRYpkEFnbCFN8+zvRZVag==,type:str]
|
WORKFLOWS_PATH=ENC[AES256_GCM,data:PehxEUMb1K3F1557BY3IqKD7sbJcoaIjnQvboBRJ1g==,iv:WfniguOksC3onCSyDlBpfKC8bE9DAt7evoeYX0K0lvE=,tag:sdRWDqkk9dtuESvfbRBfCQ==,type:str]
|
||||||
ALERT_WEBHOOK_URL=
|
ALERT_WEBHOOK_URL=
|
||||||
NTFY_TOKEN=
|
NTFY_TOKEN=
|
||||||
#ENC[AES256_GCM,data:407y6mp/tJLef0I=,iv:661eXVnxobVG8pWCYq3MZ6WO9yYzdMBskwtReeiVe+Y=,tag:+6W6h8EGU68KZ7Vz8QozNw==,type:comment]
|
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
|
||||||
PROXY_URLS=
|
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]
|
||||||
EXTRACT_WORKERS=ENC[AES256_GCM,data:Cg==,iv:w6JWrCAfBJuUS7Kwc4JsvCCbYGU4FIc18JTd7C6kiak=,tag:yJ53uTn9cs+GtghTj9tjxA==,type:str]
|
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
|
||||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:/3Q=,iv:xe8I/VBCGK49qTDY2Ehci9jrY4j1gPOzbx39mAJjf0w=,tag:Ekx3G4cs60CvCehCJuyl9Q==,type:str]
|
PROXY_URLS_FALLBACK=
|
||||||
#ENC[AES256_GCM,data:K8tsERccx4RgTurYruu6tctL6+sHz471+pAWRDld9sLTBFWD1HgTd2MtGqybuEJyU6lp4/fYPXZKOc6sff9EaqiK52dGtqbe,iv:2hlV+RsPEBOx90ZnBx4Hb2tPdrJqyri4Ic/cxReiV2o=,tag:lHT4nLUsb//ncn/E4irfWg==,type:comment]
|
CIRCUIT_BREAKER_THRESHOLD=
|
||||||
|
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
|
||||||
GSC_SERVICE_ACCOUNT_PATH=
|
GSC_SERVICE_ACCOUNT_PATH=
|
||||||
GSC_SITE_URL=
|
GSC_SITE_URL=
|
||||||
BING_WEBMASTER_API_KEY=
|
BING_WEBMASTER_API_KEY=
|
||||||
BING_SITE_URL=
|
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_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_lastmodified=2026-02-23T21:01:40Z
|
sops_lastmodified=2026-02-24T21:48:18Z
|
||||||
sops_mac=ENC[AES256_GCM,data:xehhYZcf8o/AWztlWOM/QGUl/SGf2ZXXJHl0GOiZ5s/VfItoXGx0elcV13wWnlMLOb4oRnFzblt8J0IgqCINDdKsh4JHDqKAEVjBm0cTulA6ZmKELB4hopPZve3c9FwU0AAO7jKWJpNzg0ymIxNvF05JwZKL3ILr+55s9Tun7BE=,iv:VcMqkoaLgn5P8ds/oRfObnf6uDnULBSJMJgrozDyw78=,tag:84UvqnHen+qe7rS/8HffFQ==,type:str]
|
sops_mac=ENC[AES256_GCM,data:RmSB5aS5Avl1jzeSmZPdDS6u+QPKDVD/1A55slXXdht96Knbh7IjaRsqggql9uixQO0/6WWkXsxhcKDWhsbYb0el2ATrLWXHaV6GQqfLq7RUynagcGTNHj8ipizQ93MqaDlXnI92ZOEHNcgvJzRuvRLJYhMErSyzwbUxtbaGMNM=,iv:o5wY+9uurzsTOMgmblGi0xcyYMsYGMfICmt4dSBlt2w=,tag:UKhqs3pedmvP/HjGJb0y4Q==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
114
.env.prod.sops
114
.env.prod.sops
@@ -1,59 +1,61 @@
|
|||||||
#ENC[AES256_GCM,data:ZjUQFQ==,iv:c2XVlmYBh7jYljDODjjt4NiaRJYn7sE5Ye+0Sa5PdwY=,tag:TrNGa/stAfnOPANvN538Pw==,type:comment]
|
#ENC[AES256_GCM,data:8qKvOA==,iv:Xci2F8lcBpT7dmhzaDe6sfrtQi+yQD7e2CQsYLAdCnY=,tag:3duziYwr7PoGQILUuY8nBA==,type:comment]
|
||||||
APP_NAME=ENC[AES256_GCM,data:6N2K/nexamI5tW0=,iv:MAyi9CtenGEaDSuu/XXto0JccUOvWm32aFhNqqxeMek=,tag:nWITmcO5ak1+4QWbAyvdOg==,type:str]
|
APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvzO4kSiWho2oUo=,tag:qzrr57sTPX8HPyDVwVL4sw==,type:str]
|
||||||
SECRET_KEY=ENC[AES256_GCM,data:yY53YEmhispB,iv:Js6rZBwp1Hu3a2ij8xIEYE8r+lnIyDDKFDwtZx1Yi6g=,tag:ZKHwm0NE4sYN9ixgFPaOYA==,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:jZCiKr580eylJqzQ+ohYo+oK5HE1Rg==,iv:QgKFr7R/dGRDlcunQN8v3fCP9yi51iLuk1r60o/QcpA=,tag:Ibo+yQAIq+1kBo8RBO1ytQ==,type:str]
|
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
|
||||||
DEBUG=ENC[AES256_GCM,data:YWuRfkw=,iv:Txn3+phhL9l/s7gPcJK9ZQfrYgIsklxY2Hx9btiyxIc=,tag:Up2qxaMnLwLO0dyGnkSPlw==,type:str]
|
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:MS+spGHJ8vKuuHr8gw==,iv:wMAVcwT8006PkODxO3oUT0G9rXtYDhOeNT3tjnuDKRg=,tag:oY71/NbHVT7m6oE3vrVQwA==,type:comment]
|
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
|
||||||
ADMIN_EMAILS=ENC[AES256_GCM,data:bkauU7Z1bt7U,iv:wLa9z+xmXRjGZPuvbz+zI2KjnzqvVSXqReYX/TBwS/Y=,tag:SejgSaLD+Mus8+NR5LQYog==,type:str]
|
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:CZZP6QlJe1f8,iv:Sat1+y2L02M44Z1nHpO06KxaAXh/ZWuPtkAcc8c4h38=,tag:mv9xlGSIWmzpquGqoUQCdA==,type:comment]
|
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
|
||||||
DATABASE_PATH=ENC[AES256_GCM,data:UZYij1jzKBzw95Q=,iv:/XgtdEyGf5iY/yntPzBYj2K0h0NMuwaK21r2flCd8pk=,tag:17hXzWz43M6vTmL8qV6x1A==,type:str]
|
DATABASE_PATH=ENC[AES256_GCM,data:qxQs7dG0RWMA1rs=,iv:5ZUyk02hCPQESr2vFz3mfnUhUF74LbO6YK5+HFBbxUQ=,tag:daQxiWAhzCB2cScjzjYwaA==,type:str]
|
||||||
#ENC[AES256_GCM,data:/slU6rs=,iv:BNJ9v2nhfOzvnGbtvBvF60IfNMf/A/CnL4zWdC8tu+A=,tag:K3smkVA6WYvJX7M4aI/4yQ==,type:comment]
|
#ENC[AES256_GCM,data:aWgKm9Y=,iv:8iT6GHSzWhM+fRX9PIY9wAs7lXj/ADS6eZK9BBSEdaQ=,tag:aSLsj52ybnod7Qfmx9BLQA==,type:comment]
|
||||||
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:Or4=,iv:t23GAb1vCFu/iq+uADbG5dX2K21JiaUJiBI6/xRrOqg=,tag:u/6WgNh2daEPYNpVrH5Dww==,type:str]
|
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:YSE=,iv:GYm1EWku7+OG+fCIbUHWsfYbnEQVNhlBmWBC1OCV1NA=,tag:L2kdm7tMWOO/cf+VDd+OdQ==,type:str]
|
||||||
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:niI=,iv:VRcJUJeRqcZkbBMmmIFsXZg1ugSCzvrOEcpSmQvtgMk=,tag:pveDNeZzodrZqWjXvemUuA==,type:str]
|
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:9Og=,iv:3nStZVZVB24aAtNrtLXZ0oIehTDyu2IzdXoMH59t+3o=,tag:+FQ4n1XeSS12zUGXt/1RKQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:5QyAfIN1bLQKeAQVpXsY,iv:2rW8pJYfmBtiAo1DhkQjd6tAV/5zu7Qq3KLgVHMnVg4=,tag:x3VwNSfAa6THxFhAw3+5Mg==,type:comment]
|
#ENC[AES256_GCM,data:mtqp/c5zZxlcB4HrOrfi,iv:eJaN+ZnAIaNHF5iovcz0QynILq9GjqVcwoyN2ZhLmpI=,tag:WyXU7ho5T/CE609id9dOzA==,type:comment]
|
||||||
RESEND_API_KEY=ENC[AES256_GCM,data:MJ2ibfHlV/0x,iv:HYYfVxpRZJ30AjFi9OrlCWZwywZtyHUEFmSTfPMsj1g=,tag:t4mOE8Vzz0plAGh+ss71+A==,type:str]
|
RESEND_API_KEY=ENC[AES256_GCM,data:U5aEnItbJ/Af,iv:7BTFimeMbPtK6ANXMr7VwO5TJ7IaRk+HAOZy+TEXMVI=,tag:sDhW5icVloSck1iafu3H0A==,type:str]
|
||||||
EMAIL_FROM=ENC[AES256_GCM,data:zKFSK8lMy2gMBBi0ZAWusF/qzAThevfM0DO/EjtSqyueCw==,iv:iCasIUwSaIfPlCuJYja6DwGN/O4zmx76xedoP6XiZJQ=,tag:C5uobbrKCnPreCvVvrxuqQ==,type:str]
|
EMAIL_FROM=ENC[AES256_GCM,data:BTGeWUjG9qCBvRQr9kK5sfdzQ1CfuNgpkU/AL3Qu6GJ2ng==,iv:0XjqD8hCqleSJR2FrDajlnUul8o4GkK0f1MOP96MRkw=,tag:0PwZwxuBbUFYdiRYTlDffg==,type:str]
|
||||||
LEADS_EMAIL=ENC[AES256_GCM,data:XWshE217juO6YY3wMVPB46yvVALrEx4HT3VwTKuVf+I9qA==,iv:SktCHkHpWzuLaRvJctlsjB7RhSlvLxEwThSk8NOKUYY=,tag:afT7QoS75ARqNr2mQL307g==,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:jFajN959/lUP,iv:FQI8P33AWTYZXdPyPhiAo1cyjHF2FTpKt5azG68HY+8=,tag:xwkAQeSKGfrRObpXdwcJ1A==,type:str]
|
RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:EQpvkWFyt8H7,iv:6QiZIDo5Ps39vf9MKkiqSJir7BH9zhoLREJ425y3FIs=,tag:kjO4dczb2E5FKfO6OVaQvw==,type:str]
|
||||||
#ENC[AES256_GCM,data:IYaHe5F1CQ==,iv:c1zcalp6STJPSe0F5jfPi4SQyCNMxA9l/L6QmwfJpjo=,tag:CxEPk/FjPsVi8JOdS3Z6iA==,type:comment]
|
#ENC[AES256_GCM,data:HW8JOkd7Hw==,iv:Qfwm2ZHT8TKANrLrRQqHnceQVUTiuzT2hSjLN8hSq5Q=,tag:hvVLmGGUBRlsm2qy9jxIvA==,type:comment]
|
||||||
PADDLE_API_KEY=ENC[AES256_GCM,data:MoOAgw17UtRV,iv:7hF5tzgfNjo0VvbVnsDTD2BHuxsAUR6qQIB+C0a2pRA=,tag:M7gx3OupL+AcG4gHmNLFog==,type:str]
|
PADDLE_API_KEY=ENC[AES256_GCM,data:d3rKjWFrFepp,iv:TGjG9VTC4pZFgnp5daE+jBrRCUJddqgRaV7rQ61llhU=,tag:KKaYPfUgLC58zhC8s3B4cQ==,type:str]
|
||||||
PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:JZ+hIKDieB8R,iv:Q58f/JgMdbtV3dlYTillF2dFgUaeU2os+oIfvGM4uvE=,tag:IFTfIEg0hOnUssvXF07W6A==,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:ljXlE2DUgFHq,iv:bjmH1MzR+TFIrx7BhRkjhd0IkU+2dyTe/uoAmcH5JC0=,tag:AY224RaptRz7y1neJnFlJg==,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:kcpe0WKz2hVK,iv:dF+7n5EeVtCZ5hd/xdbSpEWaJR8GGy1gU4Hsl9xBgsY=,tag:37/NFJZ2bQ4NWWG6Q+UKNw==,type:str]
|
PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:igRsm8JOO1SP,iv:vQgOZcMHt6YoE+U2d6tT8sILOwsTx3glHVBBatR6Sk8=,tag:1tApDyZmZNiwd3bVm0uZGw==,type:str]
|
||||||
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:hukHtXdIxV6xFg==,iv:YjmfvQ8Av2nc+zKW3M4hm9AdezLEeaTAhvBdS2clqdI=,tag:W8ohvtQqE1JeQ3s3/xw7eA==,type:str]
|
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:A1qXlv+9hjdIug==,iv:nu9kRQZgGLFXXT2I5GaRzp13YgQxU2ucr9azEA4XTUQ=,tag:RBxwE2j9v/RCiEMIa+6ICw==,type:str]
|
||||||
#ENC[AES256_GCM,data:ILpRggrP,iv:uPBoVraAAUXEVHW8LygwdVFDhD594WV1olUoGtomcXE=,tag:QbkQ6ZDl45VkK1Ffg6TFSQ==,type:comment]
|
#ENC[AES256_GCM,data:F3dSfSGV,iv:Zjzmp9Vb+LBkqV6xBIMF2cK8ON9crH3fHcOog4+LOpo=,tag:7V8E9ChwYY9ceTaYdg3Lbw==,type:comment]
|
||||||
UMAMI_API_URL=ENC[AES256_GCM,data:Yn1FsBNI+UkjSwWfe4Ut+nyZ1yIsb8D5RWza7g==,iv:1xtdyYh1qR8CZtJY9EyzvTPBXCmYllWWOc9j9G/hq5Q=,tag:wxxQg79wzTO0vZZYHm5G7g==,type:str]
|
UMAMI_API_URL=ENC[AES256_GCM,data:4nJZc/opX4rsqAxO6XxD1Es5ySMh7nUtcGt6Kg==,iv:DcmhRe1IJKS0tOFgdJQQv2A1kO5K8VVT8aW0Vq5hVlY=,tag:Sglu4nnAiLIzr+ovJ/hEKQ==,type:str]
|
||||||
UMAMI_API_TOKEN=ENC[AES256_GCM,data:CZf8Bw1gbSJH,iv:kAAZRzXxFKZhLwuAwoXuFearqbz3U0wKed3EQSeZDOQ=,tag:EtYevUn+5lOoRQ49PejUlg==,type:str]
|
UMAMI_API_TOKEN=ENC[AES256_GCM,data:Xv1eTWtiJ6PL,iv:9sYsI2dJaQt6gpC/ev0b2dSk48PzuojTg18xXnBSWvk=,tag:DAMDHk0b9IG7T9MpkpzAkQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:gs9EfxbmFH+rbRAgTiE=,iv:7fA8XrLrojg2RLLv95C6f2eHOwf/KGYozpJtktPmhH0=,tag:NmOi8coTH8Fiw32Qu1bIpQ==,type:comment]
|
#ENC[AES256_GCM,data:wAePRqqMZL2oCJB812A=,iv:jaLmjd0GW2dnEQ3KgWcvAs7Q7aDwlCexM9W7pH27kss=,tag:h7/yIdc13+3pmqyCc0OPkg==,type:comment]
|
||||||
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:M0ps,iv:w+OCXLFYqeEhJ4gxQWgTd7H5G92PBY40POagqXEFNfU=,tag:cEv0FUsoEWuyt6RItoNxzA==,type:str]
|
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:W3Nt,iv:ycMAxrPq44S6qezQIa50rc7GDplo1YvAO6VUERGQUxA=,tag:uzendLuSVbmSPcVPEgLiqQ==,type:str]
|
||||||
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:VMk=,iv:yMMQEWN0bYGv4ZeGwMR4nPAGoTABoDoGla4s597WoeM=,tag:d0hcbkVq7jrd4EgzUnoD3A==,type:str]
|
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:r8o=,iv:m5uKo3N8mb7FWI70SgaaHSyC3CNeD8XxjEx8ENit9uI=,tag:gKXEXsIwtBr3sm7xqLRHIw==,type:str]
|
||||||
#ENC[AES256_GCM,data:QLjNwnLVX0bNEbGS6zedQAIGTJcj,iv:R0EQWvvLxnnvgv12NO5IYt4K4slpht234mfI+byVKTg=,tag:pIzqHizkZNfz+VMy8ddLow==,type:comment]
|
#ENC[AES256_GCM,data:E6JgKjxuqFdPtVEv6Xiz1kqcT4ar,iv:hL7P7/X7nEqFwnlf72QEeHhViQ17HZbsCP/M4gcTJiA=,tag:FjCPSvrBboCWjfIS/fab0A==,type:comment]
|
||||||
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:nx/nxBllzhmH,iv:OvqX73tZpssCC0rv8nJAc6VUAC61ih4i/MKcmwkZuZM=,tag:fBiJTSGW+1a1cKsd/pimQw==,type:str]
|
LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:opg8kQY3PKnZ,iv:lPHUBDwHgBulOyt9WWgZhBQae8t2WKYvLHSFQrG3N/w=,tag:qtyIz4fbh40aLp7ZawBJiA==,type:str]
|
||||||
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:r9c3J+sr7evZ,iv:BHwoGkIVcR9IHF8AplitLhWKgAyiROZ8wj/aW3/wHo0=,tag:w49xM8ePVD+YsHs6wv5LkQ==,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:yB/fNzAIEiWh,iv:TwH4en1Q/FMSjn/BwCSMehjYNT8sL7DoijGbVTqk+r0=,tag:ekLUWbNR0Y0lqKsLeqyGrg==,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:jBsfgwFe0sLU,iv:LgfhrVQOVXbSuFO0iDKqePMGPsUvQuUaqku1D7yNUGo=,tag:XNz7j3qZ7eTzrC7qus8RHQ==,type:str]
|
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:hLneNsFmgQ6+,iv:RNefJ3QbviHPURxcK2xYJU7qWpMfWInCxYQ/4xDIwfw=,tag:FhMiHGrNcsXaSmdG4NXgfQ==,type:str]
|
||||||
#ENC[AES256_GCM,data:bxHsA0764qxNXkWUOd8=,iv:eEcLDrWLA1NiVum8oQ5riecnl586mvBj8cztksGw044=,tag:N8wCDF/FG1NK/XOEuLr2Lw==,type:comment]
|
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
||||||
DUCKDB_PATH=ENC[AES256_GCM,data:ge37CFFFCX6MjvIZRBbWAfTxmbR5,iv:mI0g55JyJh4qb4xw9PJQ58EtXcrM5SqSSj9tY2vCDGA=,tag:KuRoUKtqI+kb531x6TGPWA==,type:str]
|
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
||||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:g6C/8qOnu48PMaa/nL74D1xBqSIl,iv:0p41A1/00MAdDh4NhK0QKyotMz2lBi5lnUaP2+c1Y40=,tag:pDceR9Pt8YmfmDakv+c8gQ==,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:5UAYASdESljR+ifW,iv:RShksSTuI+X4ukqtnTjI57/18A/ghrBYDeRhcTJkAuI=,tag:1OhvbyRPZ9XaGj0E+go1Yw==,type:str]
|
LANDING_DIR=ENC[AES256_GCM,data:NkEmV8LOwEiN9Sal,iv:mQHBVT6lNoEEEVbl7a5bNN5qoF/LvTyWXQvvkv/z/B0=,tag:IgA5A1nfF91fOBdYxEN71g==,type:str]
|
||||||
#ENC[AES256_GCM,data:WhulcR23ZoF8J9Y=,iv:PtQjlgmRZhUpVSxmUmc/O95zsSXZvtE5Znsnh0wSTsg=,tag:PZXTE01GE9qzR5zirLZkeg==,type:comment]
|
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
||||||
REPO_DIR=ENC[AES256_GCM,data:DQ1l258RxzVUfLJKs1LhAQ==,iv:oMOB0a14uPi/U/j6U26Eog+LQn49faO5UC2Q1z5cQ6A=,tag:YojmlgaxBORDsls9e5uU/g==,type:str]
|
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
||||||
WORKFLOWS_PATH=ENC[AES256_GCM,data:SYkoj7KFg80L3kCyrBa3qtE/TiQwSlLX2fl+Yls73Q==,iv:TDhucw5ayQcvB6wnhGNbul7OvbRtsZbQUAJl8ZNCwdo=,tag:UVHlCQirYR1JnvI0S07uXA==,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:u2mmj8SmlGLpxgNfc3Hy74pYu9gN+YjXWr4pwhmM4No=,iv:hJ8uMLgIa9kAcTUb8LZWH3ULy93lQc6JKrhf+v7emxg=,tag:XpEZNjqzjRshm1GJ+q1hWA==,type:str]
|
ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:4sXQk8zklruC525J279TUUatdDJQ43qweuoPhtpI82Y=,iv:1NT5IsslsZjo/0xU9OGFf717G56FnSkKSZ2L1+U3peU=,tag:bhZ67zlDiq7VaY47LFWOVw==,type:str]
|
||||||
NTFY_TOKEN=ENC[AES256_GCM,data:GaRg5+e8tbLfyRVi4nXiblcM9DVnTjSxOfzvIDSJkKM=,iv:apxXu6E6ByHovFb4XHBr0aqtTOIAUw0pVOT4I/r8eNA=,tag:+fYTxaZ5BVOCTI4yKM8c6Q==,type:str]
|
NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str]
|
||||||
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:wQ==,iv:A39MWQK65yzbR4lYEHD165qcgvjOReDf8q5docutiFw=,tag:cDPbd/wAgckJ01fLuI0xsw==,type:str]
|
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHzMjE+4W/W+s4=,tag:czFaOqhHG8nqrQ8AZ8QiGw==,type:str]
|
||||||
#ENC[AES256_GCM,data:YnG+eVA2/fv2V7Q=,iv:oqbk1+gpa+Octk9/1tYdMcf/e3Rk0FDalgvebrwqOyY=,tag:LpQWy13O2y8sP+bRNrnWzA==,type:comment]
|
#ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment]
|
||||||
PROXY_URLS=ENC[AES256_GCM,data:Ow+6WYbSj6WB,iv:PIjPvv76MPkE0cpLy8gYWmKsVPmgrld6He5bIJiG8EA=,tag:TuYb7R8BWDj9gQWq2sDLLw==,type:str]
|
PROXY_URLS=ENC[AES256_GCM,data:L2Oobpi6Pq8m,iv:14mXi+8mLv2e20IKVL0VlxZiHW/1BmeQP4a6ns5930g=,tag:pVJasNjv6N/UApVm+KD+XA==,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:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
|
||||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:A+Y=,iv:c+5XMViCtqBRc50rIl2LsEV3Le0VtmJVOxBnN/ecLAI=,tag:JQo5ZgF4O3TGeODwZsbv6Q==,type:str]
|
#ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment]
|
||||||
#ENC[AES256_GCM,data:lmFfKv52m2Zb72zgfSCByso=,iv:iOtHNLO/DBWD/3QtiPuPM+37czqWcZAhgkuctAZYvbg=,tag:SvSyuJ9NvkGFH/Db8hlkXA==,type:comment]
|
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
|
||||||
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:hgYixpJbCCwZ,iv:p0y06H1nai1qoqy2Mr/XVM+brsv3XNv67+2JGgLtFJM=,tag:5yI6U+M6Awwd+XbOJE/1Lw==,type:str]
|
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
|
||||||
GSC_SITE_URL=ENC[AES256_GCM,data:+8j1vufryYHreeZOO+hx23SngX2hTA==,iv:c/9Ero+t0TuvYgegIbsNIaihI117BmliRUxy9Vhuniw=,tag:8UeNRoUFH+DAoyyRN9uuqw==,type:str]
|
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHTwBr/bNnCeqsTrUqAAYM5Vs=,tag:4jBxqgpyomzMLwiC9XpfVQ==,type:str]
|
||||||
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:/Q46VCqGYisn,iv:CHOArnTNYfl3/2aigvkRLQJRTan+5y3YWzRvi17U4P8=,tag:88gdL9ftVG4iyxuHqhltvw==,type:str]
|
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
|
||||||
BING_SITE_URL=ENC[AES256_GCM,data:Suwqnq/GzA9KHF40q9/80b4n0Etjkg==,iv:SRuEdcVgOCchSKlDrNNOPV6JycelH8N1BldQ4banU+Q=,tag:8xPLf0+vLHcHHRowfFD8hQ==,type:str]
|
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
|
||||||
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
|
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_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_lastmodified=2026-02-23T21:02:13Z
|
sops_lastmodified=2026-02-24T21:37:45Z
|
||||||
sops_mac=ENC[AES256_GCM,data:KQefQTETh9w/SgTk37g+SU/fw+SZB2Mya0JTSENM2SLCS43hRUrTpZA/QCGuLbwRacgdkMp646BhYBX6JoEArW6Y/Jq1y5O66V10HliHLVfOEJ7DxaApPnczr9FM/nceYUOVWeYq2IXTmOtfNUhtCwpdXJJzEDRqJ0padGGH2+w=,iv:8Be6A0LThJX2fF3y9/4Hy82BPNb4NJOajrGF5kTaPAE=,tag:TK7qBGDVaA2DfxOmkuqCww==,type:str]
|
sops_mac=ENC[AES256_GCM,data:FdIU0UvGEc/P7ETNOxYHqfsGMNCdBVqbxHVIrR1v4hAnTWYHelawJqifQOOArTyNGjfsIRGajct7CLADkGE/qVm6vSQO4m6w+veSGEO39Wvlfz6BrVSYMqWMjGuJsTj/TJGSZDBnyC//Jzf3pTTgXrcjM86aoLbqhT/Qbb0JIiE=,iv:fgP4Ro0Cd6u1n9G07UsMkQNDk3fCQPe5hixA3KXhcAk=,tag:2PEKkltbD5TICzZ3WgvXQA==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
creation_rules:
|
creation_rules:
|
||||||
- path_regex: \.env\..+\.sops$
|
- path_regex: \.env\..+\.sops$
|
||||||
age: age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
age: age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a,age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||||
|
|||||||
121
CHANGELOG.md
121
CHANGELOG.md
@@ -6,7 +6,128 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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
|
- **Double language prefix in article URLs** — articles were served at
|
||||||
`/en/en/markets/italy` (double prefix) because `generate_articles()` stored
|
`/en/en/markets/italy` (double prefix) because `generate_articles()` stored
|
||||||
`url_path` with the lang prefix baked in, but the blueprint is already mounted
|
`url_path` with the lang prefix baked in, but the blueprint is already mounted
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ USER appuser
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV DATABASE_PATH=/app/data/app.db
|
ENV DATABASE_PATH=/app/data/app.db
|
||||||
EXPOSE 5000
|
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"]
|
||||||
|
|||||||
27
PROJECT.md
27
PROJECT.md
@@ -1,7 +1,7 @@
|
|||||||
# Padelnomics — Project Tracker
|
# Padelnomics — Project Tracker
|
||||||
|
|
||||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
> 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] `dim_venues` (OSM + Playtomic deduped), `dim_cities` (Eurostat population)
|
||||||
- [x] `city_market_profile` (market score OBT), `planner_defaults` (per-city calculator pre-fill)
|
- [x] `city_market_profile` (market score OBT), `planner_defaults` (per-city calculator pre-fill)
|
||||||
- [x] DuckDB analytics reader in app lifecycle
|
- [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
|
### i18n
|
||||||
- [x] Full i18n across entire app (EN + DE)
|
- [x] Full i18n across entire app (EN + DE)
|
||||||
@@ -107,6 +110,12 @@
|
|||||||
- [x] Task queue management (list, retry, delete)
|
- [x] Task queue management (list, retry, delete)
|
||||||
- [x] Lead funnel stats on admin dashboard
|
- [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 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
|
### SEO & Legal
|
||||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
- [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] Cookie consent banner (functional/A/B categories, 1-year cookie)
|
||||||
- [x] Virtual office address on imprint
|
- [x] Virtual office address on imprint
|
||||||
- [x] SEO/GEO admin hub — GSC + Bing + Umami sync, search/funnel/scorecard views, daily background sync
|
- [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
|
### 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
|
- [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 📋
|
## 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) |
|
| 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 | |
|
| 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 1–2 — First Revenue
|
### Week 1–2 — First Revenue
|
||||||
|
|
||||||
| 🛠 Tech | 📣 Business |
|
| 🛠 Tech | 📣 Business |
|
||||||
@@ -195,6 +208,9 @@ _Move here when you start working on it._
|
|||||||
- [ ] Padel Hall Accelerator (€999 — report + call + supplier intros)
|
- [ ] Padel Hall Accelerator (€999 — report + call + supplier intros)
|
||||||
|
|
||||||
### Data & Intelligence
|
### 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)
|
- [ ] Multi-source data aggregation (add booking platforms beyond Playtomic)
|
||||||
- [ ] Google Maps signals (reviews, ratings)
|
- [ ] Google Maps signals (reviews, ratings)
|
||||||
- [ ] Weather + demographic overlays
|
- [ ] 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 | 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 | 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-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
208
docs/ADMIN.md
Normal 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.
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 4 | Entrepreneur notified | `send_lead_matched_notification` task — notifies entrepreneur a supplier was matched |
|
||||||
|
|
||||||
**Auth required:** Yes — `@_lead_tier_required`
|
**Auth required:** Yes — `@_lead_tier_required`
|
||||||
**Credit check:** Server-side check; if 0 credits → redirect to boosts tab
|
**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")`)
|
**Entry:** `/admin/` (requires `@role_required("admin")`)
|
||||||
|
|
||||||
| Area | URL | What you can do |
|
| Area | URL | What you can do |
|
||||||
|------|-----|-----------------|
|
|------|-----|-----------------|
|
||||||
| Dashboard | `GET /admin/` | Stats overview |
|
| 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 |
|
| 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 |
|
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
|
||||||
| Feedback | `GET /admin/feedback` | View all submitted feedback |
|
| 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 |
|
| 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 |
|
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
|
||||||
| Task Queue | `GET /admin/tasks` | View worker tasks, retry/delete failed |
|
| 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`
|
**Dev shortcut:** `/auth/dev-login?email=<admin-email>` where email is in `config.ADMIN_EMAILS`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ Purpose: Identify and track data sources feeding the Padelnomics DuckDB analytic
|
|||||||
|
|
||||||
| Source | Category | Status | Score | Credentials | Pipeline refs |
|
| 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 — 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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) |
|
| 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 |
|
| 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 |
|
| PadelAPI.org | Tournament data | 🔲 Planned | 3 | Free-tier token | 50k req/mo |
|
||||||
|
|||||||
735
docs/proxy-provider-inventory.md
Normal file
735
docs/proxy-provider-inventory.md
Normal 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 (1–8 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: ~1–5 GB/month estimated)
|
||||||
|
- Budget: €20–100/month target
|
||||||
|
|
||||||
|
**Bandwidth estimate:** A Playtomic availability API response for one venue is roughly 5–30 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 1–2 GB/month; at the high end (large venue JSON, retries) up to 10 GB/month. Budget for 5–10 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 | 10–60 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 | 45–72M+ | $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 | 5–10M+ | $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 (1–5):** fit for Playtomic extraction — rotating residential, pay-per-GB, no large minimum, city/country targeting, low total cost at 5–10 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 — 50–100x the actual data cost at 5–10 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 (1–1,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 (1–60 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 5–10 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 5–10 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 5–10 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 | 45–72M+ residential IPs (P2P network) |
|
||||||
|
| Countries | 102–195 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 6–10 GB/month, cost is $6–10/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 6–10 GB/month: $6–10/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 $5–10 credit load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Evomi
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| URL | https://evomi.com |
|
||||||
|
| Types | Rotating residential, mobile, datacenter |
|
||||||
|
| Pool Size | 5–10M+ 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 6–10 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 6–10 GB/month: $6–10/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 5–10M 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 | $35–48/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 30–60x 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 5–10x 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 ($6–10/month at $0.99/GB PAYG)
|
||||||
|
- Evomi <95%, DataImpulse >98% → DataImpulse primary ($6–10/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: $6–10/month for 6–10 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 (0–999)
|
||||||
|
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 | $6–10/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** | **$6–10/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 | $10–18/month | $1.75/GB; 7-day sessions; well-established |
|
||||||
|
| PAYG mid-tier | PacketStream | $6–10/month | $1.00/GB; $50 deposit; P2P quality variance |
|
||||||
|
| PAYG mid-tier | Geonode PAYG | $18–30/month | $3.00/GB; 24-hr sticky; test latency first |
|
||||||
|
| High-reliability | SOAX PAYG | $24–40/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 €20–100/month, DataImpulse at $6–10/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 | 1–30 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 — 2–3x the bandwidth needed for ~6–10 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 | 10–60 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); 340–800+ 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 (90–3,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 340–800+ 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 | ~91–92% 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, $1–2/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 6–10 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)
|
||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
extract = "padelnomics_extract.all:main"
|
extract = "padelnomics_extract.all:main"
|
||||||
extract-overpass = "padelnomics_extract.overpass:main"
|
extract-overpass = "padelnomics_extract.overpass:main"
|
||||||
|
extract-overpass-tennis = "padelnomics_extract.overpass_tennis:main"
|
||||||
extract-eurostat = "padelnomics_extract.eurostat:main"
|
extract-eurostat = "padelnomics_extract.eurostat:main"
|
||||||
extract-playtomic-tenants = "padelnomics_extract.playtomic_tenants:main"
|
extract-playtomic-tenants = "padelnomics_extract.playtomic_tenants:main"
|
||||||
extract-playtomic-availability = "padelnomics_extract.playtomic_availability:main"
|
extract-playtomic-availability = "padelnomics_extract.playtomic_availability:main"
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ def setup_logging(name: str) -> logging.Logger:
|
|||||||
def run_extractor(
|
def run_extractor(
|
||||||
extractor_name: str,
|
extractor_name: str,
|
||||||
func,
|
func,
|
||||||
|
proxy_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Boilerplate wrapper: open state DB, start run, call func, end run.
|
"""Boilerplate wrapper: open state DB, start run, call func, end run.
|
||||||
|
|
||||||
func signature: func(landing_dir, year_month, conn, session) -> dict
|
func signature: func(landing_dir, year_month, conn, session) -> dict
|
||||||
The dict must contain: files_written, files_skipped, bytes_written.
|
The dict must contain: files_written, files_skipped, bytes_written.
|
||||||
Optional: cursor_value.
|
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)
|
LANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
conn = open_state_db(LANDING_DIR)
|
conn = open_state_db(LANDING_DIR)
|
||||||
@@ -58,6 +63,8 @@ def run_extractor(
|
|||||||
try:
|
try:
|
||||||
with niquests.Session() as session:
|
with niquests.Session() as session:
|
||||||
session.headers["User-Agent"] = USER_AGENT
|
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)
|
result = func(LANDING_DIR, year_month, conn, session)
|
||||||
|
|
||||||
assert isinstance(result, dict), f"extractor must return a dict, got {type(result)}"
|
assert isinstance(result, dict), f"extractor must return a dict, got {type(result)}"
|
||||||
|
|||||||
@@ -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.
|
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 ._shared import run_extractor, setup_logging
|
||||||
from .census_usa import EXTRACTOR_NAME as CENSUS_USA_NAME
|
from .census_usa import EXTRACTOR_NAME as CENSUS_USA_NAME
|
||||||
from .census_usa import extract as extract_census_usa
|
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 .ons_uk import extract as extract_ons_uk
|
||||||
from .overpass import EXTRACTOR_NAME as OVERPASS_NAME
|
from .overpass import EXTRACTOR_NAME as OVERPASS_NAME
|
||||||
from .overpass import extract as extract_overpass
|
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 EXTRACTOR_NAME as AVAILABILITY_NAME
|
||||||
from .playtomic_availability import extract as extract_availability
|
from .playtomic_availability import extract as extract_availability
|
||||||
from .playtomic_tenants import EXTRACTOR_NAME as TENANTS_NAME
|
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")
|
logger = setup_logging("padelnomics.extract")
|
||||||
|
|
||||||
EXTRACTORS = [
|
# Declarative: name → (func, [dependency names])
|
||||||
(OVERPASS_NAME, extract_overpass),
|
# Add new extractors here; the scheduler handles ordering and parallelism.
|
||||||
(EUROSTAT_NAME, extract_eurostat),
|
EXTRACTORS: dict[str, tuple] = {
|
||||||
(EUROSTAT_CITY_LABELS_NAME, extract_eurostat_city_labels),
|
OVERPASS_NAME: (extract_overpass, []),
|
||||||
(CENSUS_USA_NAME, extract_census_usa),
|
OVERPASS_TENNIS_NAME: (extract_overpass_tennis, []),
|
||||||
(ONS_UK_NAME, extract_ons_uk),
|
EUROSTAT_NAME: (extract_eurostat, []),
|
||||||
(GEONAMES_NAME, extract_geonames),
|
EUROSTAT_CITY_LABELS_NAME: (extract_eurostat_city_labels, []),
|
||||||
(TENANTS_NAME, extract_tenants),
|
CENSUS_USA_NAME: (extract_census_usa, []),
|
||||||
(AVAILABILITY_NAME, extract_availability),
|
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:
|
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))
|
logger.info("Running %d extractors", len(EXTRACTORS))
|
||||||
|
|
||||||
for i, (name, func) in enumerate(EXTRACTORS, 1):
|
graph = {name: set(deps) for name, (_, deps) in EXTRACTORS.items()}
|
||||||
logger.info("[%d/%d] %s", i, len(EXTRACTORS), name)
|
ts = TopologicalSorter(graph)
|
||||||
try:
|
ts.prepare()
|
||||||
run_extractor(name, func)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Extractor %s failed — continuing with next", name)
|
|
||||||
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ def extract(
|
|||||||
session: niquests.Session,
|
session: niquests.Session,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fetch all Eurostat datasets. Returns run metrics."""
|
"""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("/")
|
year, month = year_month.split("/")
|
||||||
files_written = 0
|
files_written = 0
|
||||||
files_skipped = 0
|
files_skipped = 0
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
"""GeoNames global city population extractor.
|
"""GeoNames global city population extractor.
|
||||||
|
|
||||||
Downloads the cities15000.zip bulk file (~1.5MB compressed, ~26K entries) from
|
Downloads the cities1000.zip bulk file (~30MB compressed, ~140K entries) from
|
||||||
GeoNames and filters to cities with population ≥ 50,000 and feature codes in
|
GeoNames. Includes all populated places with population ≥ 1,000 and feature codes
|
||||||
{PPLA, PPLA2, PPLC, PPL} (populated places, avoiding parks, airports, etc.).
|
in {PPLA, PPLA2, PPLA3, PPLA4, PPLA5, PPLC, PPL}.
|
||||||
|
|
||||||
Used as the global fallback for population when Eurostat/Census/ONS don't cover
|
This broader coverage (vs. the old cities15000 with ≥50K filter) supports
|
||||||
a country. Padel is expanding globally so this catches UAE, Australia, Argentina, etc.
|
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)
|
Requires: GEONAMES_USERNAME env var (free registration at geonames.org)
|
||||||
|
|
||||||
Landing: {LANDING_DIR}/geonames/{year}/{month}/cities_global.json.gz
|
Landing: {LANDING_DIR}/geonames/{year}/{month}/cities_global.jsonl.gz
|
||||||
Output: {"rows": [{"geoname_id": 2950159, "city_name": "Berlin",
|
Output: one JSON object per line, e.g.:
|
||||||
"country_code": "DE", "population": 3644826,
|
{"geoname_id": 2950159, "city_name": "Berlin", "country_code": "DE",
|
||||||
"ref_year": 2024}], "count": N}
|
"population": 3644826, "lat": 52.524, "lon": 13.411,
|
||||||
|
"admin1_code": "16", "admin2_code": "00", "ref_year": 2024}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import gzip
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -25,30 +28,39 @@ from pathlib import Path
|
|||||||
import niquests
|
import niquests
|
||||||
|
|
||||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
|
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")
|
logger = setup_logging("padelnomics.extract.geonames")
|
||||||
|
|
||||||
EXTRACTOR_NAME = "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.
|
# Only populated place feature codes — excludes airports, parks, admin areas, etc.
|
||||||
# PPLC = capital of a political entity
|
# PPLC = capital of a political entity
|
||||||
# PPLA = seat of a first-order administrative division
|
# PPLA = seat of a first-order administrative division
|
||||||
# PPLA2 = seat of a second-order admin 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
|
# 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
|
# https://download.geonames.org/export/dump/readme.txt
|
||||||
COL_GEONAME_ID = 0
|
COL_GEONAME_ID = 0
|
||||||
COL_NAME = 1
|
COL_NAME = 1
|
||||||
COL_ASCIINAME = 2
|
COL_ASCIINAME = 2
|
||||||
COL_COUNTRY_CODE = 8
|
COL_LAT = 4
|
||||||
|
COL_LON = 5
|
||||||
COL_FEATURE_CODE = 7
|
COL_FEATURE_CODE = 7
|
||||||
|
COL_COUNTRY_CODE = 8
|
||||||
|
COL_ADMIN1_CODE = 10
|
||||||
|
COL_ADMIN2_CODE = 11
|
||||||
COL_POPULATION = 14
|
COL_POPULATION = 14
|
||||||
COL_MODIFICATION_DATE = 18
|
COL_MODIFICATION_DATE = 18
|
||||||
|
|
||||||
@@ -86,10 +98,21 @@ def _parse_cities_txt(content: bytes) -> list[dict]:
|
|||||||
country_code = parts[COL_COUNTRY_CODE].strip().upper()
|
country_code = parts[COL_COUNTRY_CODE].strip().upper()
|
||||||
if not city_name or not country_code:
|
if not city_name or not country_code:
|
||||||
continue
|
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({
|
rows.append({
|
||||||
"geoname_id": geoname_id,
|
"geoname_id": geoname_id,
|
||||||
"city_name": city_name,
|
"city_name": city_name,
|
||||||
"country_code": country_code,
|
"country_code": country_code,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"admin1_code": admin1_code or None,
|
||||||
|
"admin2_code": admin2_code or None,
|
||||||
"population": population,
|
"population": population,
|
||||||
"ref_year": REF_YEAR,
|
"ref_year": REF_YEAR,
|
||||||
})
|
})
|
||||||
@@ -102,15 +125,18 @@ def extract(
|
|||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
session: niquests.Session,
|
session: niquests.Session,
|
||||||
) -> dict:
|
) -> 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()
|
username = os.environ.get("GEONAMES_USERNAME", "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
logger.warning("GEONAMES_USERNAME not set — writing empty placeholder so SQLMesh models can run")
|
logger.warning("GEONAMES_USERNAME not set — writing empty placeholder so SQLMesh models can run")
|
||||||
year, month = year_month.split("/")
|
year, month = year_month.split("/")
|
||||||
dest_dir = landing_path(landing_dir, "geonames", year, month)
|
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():
|
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}
|
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||||
|
|
||||||
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
|
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
|
||||||
@@ -120,30 +146,33 @@ def extract(
|
|||||||
|
|
||||||
year, month = year_month.split("/")
|
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.
|
# but the username signals acceptance of their terms of use and helps their monitoring.
|
||||||
url = f"{DOWNLOAD_URL}?username={username}"
|
url = f"{DOWNLOAD_URL}?username={username}"
|
||||||
logger.info("GET cities15000.zip (~1.5MB compressed)")
|
logger.info("GET cities1000.zip (~30MB compressed, ~140K locations)")
|
||||||
resp = session.get(url, timeout=HTTP_TIMEOUT_SECONDS * 4)
|
resp = session.get(url, timeout=HTTP_TIMEOUT_SECONDS * 10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
assert len(resp.content) > 100_000, (
|
assert len(resp.content) > 1_000_000, (
|
||||||
f"cities15000.zip too small ({len(resp.content)} bytes) — download may have failed"
|
f"cities1000.zip too small ({len(resp.content)} bytes) — download may have failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
||||||
txt_name = next((n for n in zf.namelist() if n.endswith(".txt")), None)
|
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)
|
txt_content = zf.read(txt_name)
|
||||||
|
|
||||||
rows = _parse_cities_txt(txt_content)
|
rows = _parse_cities_txt(txt_content)
|
||||||
assert len(rows) > 5_000, f"Expected >5000 global cities ≥50K pop, got {len(rows)}"
|
assert len(rows) > 100_000, f"Expected >100K global locations (pop ≥1K), got {len(rows)}"
|
||||||
logger.info("parsed %d global cities with population ≥%d", len(rows), MIN_POPULATION)
|
logger.info("parsed %d global locations (pop ≥1K)", len(rows))
|
||||||
|
|
||||||
dest_dir = landing_path(landing_dir, "geonames", year, month)
|
dest_dir = landing_path(landing_dir, "geonames", year, month)
|
||||||
dest = dest_dir / "cities_global.json.gz"
|
dest = dest_dir / "cities_global.jsonl.gz"
|
||||||
payload = json.dumps({"rows": rows, "count": len(rows)}).encode()
|
working_path = dest.with_suffix(".working.jsonl")
|
||||||
bytes_written = write_gzip_atomic(dest, payload)
|
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:,}")
|
logger.info("written %s bytes compressed", f"{bytes_written:,}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
This is the highest-value source: daily snapshots enable occupancy rate
|
||||||
estimation, pricing benchmarking, and demand signal detection.
|
estimation, pricing benchmarking, and demand signal detection.
|
||||||
|
|
||||||
Parallel mode: set EXTRACT_WORKERS=N and PROXY_URLS=... to fetch N venues
|
Parallel mode: worker count is derived from PROXY_URLS (one worker per proxy).
|
||||||
concurrently (one proxy per worker). Without proxies, runs single-threaded.
|
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.
|
Recheck mode: re-queries venues with slots starting within the next 90 minutes.
|
||||||
Writes a separate recheck file for more accurate occupancy measurement.
|
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
|
Recheck: {LANDING_DIR}/playtomic/{year}/{month}/availability_{date}_recheck_{HH}.json.gz
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -28,8 +33,14 @@ from pathlib import Path
|
|||||||
import niquests
|
import niquests
|
||||||
|
|
||||||
from ._shared import HTTP_TIMEOUT_SECONDS, USER_AGENT, run_extractor, setup_logging
|
from ._shared import HTTP_TIMEOUT_SECONDS, USER_AGENT, run_extractor, setup_logging
|
||||||
from .proxy import load_proxy_urls, make_round_robin_cycler
|
from .proxy import load_fallback_proxy_urls, load_proxy_urls, make_tiered_cycler
|
||||||
from .utils import get_last_cursor, landing_path, write_gzip_atomic
|
from .utils import (
|
||||||
|
compress_jsonl_atomic,
|
||||||
|
flush_partial_batch,
|
||||||
|
landing_path,
|
||||||
|
load_partial_results,
|
||||||
|
write_gzip_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
logger = setup_logging("padelnomics.extract.playtomic_availability")
|
logger = setup_logging("padelnomics.extract.playtomic_availability")
|
||||||
|
|
||||||
@@ -40,8 +51,16 @@ AVAILABILITY_URL = "https://api.playtomic.io/v1/availability"
|
|||||||
THROTTLE_SECONDS = 1
|
THROTTLE_SECONDS = 1
|
||||||
MAX_VENUES_PER_RUN = 20_000
|
MAX_VENUES_PER_RUN = 20_000
|
||||||
MAX_RETRIES_PER_VENUE = 2
|
MAX_RETRIES_PER_VENUE = 2
|
||||||
MAX_WORKERS = int(os.environ.get("EXTRACT_WORKERS", "1"))
|
RECHECK_WINDOW_MINUTES = int(os.environ.get("RECHECK_WINDOW_MINUTES", "30"))
|
||||||
RECHECK_WINDOW_MINUTES = int(os.environ.get("RECHECK_WINDOW_MINUTES", "90"))
|
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 storage for per-worker sessions
|
||||||
_thread_local = threading.local()
|
_thread_local = threading.local()
|
||||||
@@ -52,48 +71,49 @@ _thread_local = threading.local()
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _load_tenant_ids(landing_dir: Path) -> list[str]:
|
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"
|
playtomic_dir = landing_dir / "playtomic"
|
||||||
if not playtomic_dir.exists():
|
if not playtomic_dir.exists():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
tenant_files = sorted(playtomic_dir.glob("*/*/tenants.json.gz"), reverse=True)
|
# 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:
|
if not tenant_files:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
latest = tenant_files[0]
|
latest = tenant_files[0]
|
||||||
logger.info("Loading tenant IDs from %s", latest)
|
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 = []
|
ids = []
|
||||||
for t in tenants:
|
|
||||||
tid = t.get("tenant_id") or t.get("id")
|
with gzip.open(latest, "rt") as f:
|
||||||
if tid:
|
if latest.name.endswith(".jsonl.gz"):
|
||||||
ids.append(tid)
|
# 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)
|
||||||
|
|
||||||
logger.info("Loaded %d tenant IDs", len(ids))
|
logger.info("Loaded %d tenant IDs", len(ids))
|
||||||
return ids
|
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)
|
# Per-venue fetch (used by both serial and parallel modes)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -143,7 +163,8 @@ def _fetch_venue_availability(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
time.sleep(THROTTLE_SECONDS)
|
if not proxy_url:
|
||||||
|
time.sleep(THROTTLE_SECONDS)
|
||||||
return {"tenant_id": tenant_id, "slots": resp.json()}
|
return {"tenant_id": tenant_id, "slots": resp.json()}
|
||||||
|
|
||||||
except niquests.exceptions.RequestException as e:
|
except niquests.exceptions.RequestException as e:
|
||||||
@@ -169,10 +190,19 @@ def _fetch_venues_parallel(
|
|||||||
start_min_str: str,
|
start_min_str: str,
|
||||||
start_max_str: str,
|
start_max_str: str,
|
||||||
worker_count: int,
|
worker_count: int,
|
||||||
proxy_cycler,
|
cycler: dict,
|
||||||
|
fallback_urls: list[str],
|
||||||
|
on_result=None,
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Fetch availability for multiple venues in parallel.
|
"""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).
|
Returns (venues_data, venues_errored).
|
||||||
"""
|
"""
|
||||||
venues_data: list[dict] = []
|
venues_data: list[dict] = []
|
||||||
@@ -181,26 +211,40 @@ def _fetch_venues_parallel(
|
|||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
|
||||||
def _worker(tenant_id: str) -> dict | None:
|
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)
|
return _fetch_venue_availability(tenant_id, start_min_str, start_max_str, proxy_url)
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=worker_count) as pool:
|
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]
|
||||||
result = future.result()
|
batch_futures = {pool.submit(_worker, tid): tid for tid in batch}
|
||||||
with lock:
|
|
||||||
completed_count += 1
|
|
||||||
if result is not None:
|
|
||||||
venues_data.append(result)
|
|
||||||
else:
|
|
||||||
venues_errored += 1
|
|
||||||
|
|
||||||
if completed_count % 500 == 0:
|
for future in as_completed(batch_futures):
|
||||||
logger.info(
|
result = future.result()
|
||||||
"Progress: %d/%d venues (%d errors, %d workers)",
|
with lock:
|
||||||
completed_count, len(tenant_ids), venues_errored, worker_count,
|
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(
|
||||||
|
"Progress: %d/%d venues (%d errors, %d workers)",
|
||||||
|
completed_count, len(tenant_ids), venues_errored, worker_count,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Parallel fetch complete: %d/%d venues (%d errors, %d workers)",
|
"Parallel fetch complete: %d/%d venues (%d errors, %d workers)",
|
||||||
@@ -220,6 +264,8 @@ def extract(
|
|||||||
session: niquests.Session,
|
session: niquests.Session,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fetch next-day availability for all known Playtomic venues."""
|
"""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)
|
tenant_ids = _load_tenant_ids(landing_dir)
|
||||||
if not tenant_ids:
|
if not tenant_ids:
|
||||||
logger.warning("No tenant IDs found — run extract-playtomic-tenants first")
|
logger.warning("No tenant IDs found — run extract-playtomic-tenants first")
|
||||||
@@ -233,48 +279,74 @@ def extract(
|
|||||||
|
|
||||||
year, month = year_month.split("/")
|
year, month = year_month.split("/")
|
||||||
dest_dir = landing_path(landing_dir, "playtomic", year, month)
|
dest_dir = landing_path(landing_dir, "playtomic", year, month)
|
||||||
dest = dest_dir / f"availability_{target_date}.json.gz"
|
dest = dest_dir / f"availability_{target_date}.jsonl.gz"
|
||||||
|
old_blob = dest_dir / f"availability_{target_date}.json.gz"
|
||||||
if dest.exists():
|
if dest.exists() or old_blob.exists():
|
||||||
logger.info("Already have %s — skipping", dest)
|
logger.info("Already have availability for %s — skipping", target_date)
|
||||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||||
|
|
||||||
# Resume from cursor if crashed mid-run
|
# Crash resumption: load already-fetched venues from working file
|
||||||
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
|
partial_path = dest_dir / f"availability_{target_date}.working.jsonl"
|
||||||
resume_index = _parse_resume_cursor(last_cursor, target_date)
|
prior_results, already_done = load_partial_results(partial_path, id_key="tenant_id")
|
||||||
if resume_index > 0:
|
if already_done:
|
||||||
logger.info("Resuming from index %d (cursor: %s)", resume_index, last_cursor)
|
logger.info("Resuming: %d venues already fetched from partial file", len(already_done))
|
||||||
|
|
||||||
venues_to_process = tenant_ids[:MAX_VENUES_PER_RUN]
|
all_venues_to_process = tenant_ids[:MAX_VENUES_PER_RUN]
|
||||||
if resume_index > 0:
|
venues_to_process = [tid for tid in all_venues_to_process if tid not in already_done]
|
||||||
venues_to_process = venues_to_process[resume_index:]
|
|
||||||
|
|
||||||
# Determine parallelism
|
# Set up tiered proxy cycler with circuit breaker
|
||||||
proxy_urls = load_proxy_urls()
|
proxy_urls = load_proxy_urls()
|
||||||
worker_count = min(MAX_WORKERS, len(proxy_urls)) if proxy_urls else 1
|
fallback_urls = load_fallback_proxy_urls()
|
||||||
proxy_cycler = make_round_robin_cycler(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_min_str = start_min.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
start_max_str = start_max.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:
|
if worker_count > 1:
|
||||||
logger.info("Parallel mode: %d workers, %d proxies", worker_count, len(proxy_urls))
|
logger.info("Parallel mode: %d workers, %d proxies", worker_count, len(proxy_urls))
|
||||||
venues_data, venues_errored = _fetch_venues_parallel(
|
new_venues_data, venues_errored = _fetch_venues_parallel(
|
||||||
venues_to_process, start_min_str, start_max_str, worker_count, proxy_cycler,
|
venues_to_process, start_min_str, start_max_str, worker_count, cycler, fallback_urls,
|
||||||
|
on_result=_on_result,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Serial mode — same as before but uses shared fetch function
|
|
||||||
logger.info("Serial mode: 1 worker, %d venues", len(venues_to_process))
|
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):
|
for i, tenant_id in enumerate(venues_to_process):
|
||||||
result = _fetch_venue_availability(
|
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:
|
if result is not None:
|
||||||
venues_data.append(result)
|
new_venues_data.append(result)
|
||||||
|
cycler["record_success"]()
|
||||||
|
_on_result(result)
|
||||||
else:
|
else:
|
||||||
venues_errored += 1
|
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:
|
if (i + 1) % 100 == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -282,27 +354,26 @@ def extract(
|
|||||||
i + 1, len(venues_to_process), venues_errored,
|
i + 1, len(venues_to_process), venues_errored,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write consolidated file
|
# Final flush of any remaining partial batch
|
||||||
captured_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
if pending_batch:
|
||||||
payload = json.dumps({
|
flush_partial_batch(partial_file, partial_lock, pending_batch)
|
||||||
"date": target_date,
|
pending_batch.clear()
|
||||||
"captured_at_utc": captured_at,
|
partial_file.close()
|
||||||
"venue_count": len(venues_data),
|
|
||||||
"venues_errored": venues_errored,
|
# Working file IS the output — compress atomically (deletes source).
|
||||||
"venues": venues_data,
|
total_venues = len(prior_results) + len(new_venues_data)
|
||||||
}).encode()
|
bytes_written = compress_jsonl_atomic(partial_path, dest)
|
||||||
|
|
||||||
bytes_written = write_gzip_atomic(dest, payload)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"%d venues scraped (%d errors) -> %s (%s bytes)",
|
"%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 {
|
return {
|
||||||
"files_written": 1,
|
"files_written": 1,
|
||||||
"files_skipped": 0,
|
"files_skipped": 0,
|
||||||
"bytes_written": bytes_written,
|
"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
|
# 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:
|
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"
|
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"))
|
matches = list(playtomic_dir.glob(f"*/*/availability_{target_date}.json.gz"))
|
||||||
if not matches:
|
if not matches:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with gzip.open(matches[0], "rb") as f:
|
with gzip.open(matches[0], "rb") as f:
|
||||||
return json.loads(f.read())
|
return json.loads(f.read())
|
||||||
|
|
||||||
@@ -357,6 +450,8 @@ def extract_recheck(
|
|||||||
session: niquests.Session,
|
session: niquests.Session,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Re-query venues with slots starting soon for accurate occupancy data."""
|
"""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)
|
now = datetime.now(UTC)
|
||||||
target_date = now.strftime("%Y-%m-%d")
|
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_min_str = window_start.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
start_max_str = window_end.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()
|
proxy_urls = load_proxy_urls()
|
||||||
worker_count = min(MAX_WORKERS, len(proxy_urls)) if proxy_urls else 1
|
fallback_urls = load_fallback_proxy_urls()
|
||||||
proxy_cycler = make_round_robin_cycler(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:
|
if worker_count > 1 and len(venues_to_recheck) > 10:
|
||||||
venues_data, venues_errored = _fetch_venues_parallel(
|
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:
|
else:
|
||||||
venues_data = []
|
venues_data = []
|
||||||
venues_errored = 0
|
venues_errored = 0
|
||||||
for tid in venues_to_recheck:
|
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:
|
if result is not None:
|
||||||
venues_data.append(result)
|
venues_data.append(result)
|
||||||
|
cycler["record_success"]()
|
||||||
else:
|
else:
|
||||||
venues_errored += 1
|
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
|
# Write recheck file
|
||||||
recheck_hour = now.hour
|
recheck_hour = now.hour
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Playtomic tenants extractor — venue listings via unauthenticated API.
|
"""Playtomic tenants extractor — venue listings via unauthenticated API.
|
||||||
|
|
||||||
Paginates through the global tenant list (sorted by UUID) using the `page`
|
Paginates through the global tenant list (sorted by UUID) using the `page`
|
||||||
parameter. Deduplicates on tenant_id and writes a single consolidated JSON
|
parameter. Deduplicates on tenant_id and writes a gzipped JSONL file to the
|
||||||
to the landing zone.
|
landing zone (one tenant object per line).
|
||||||
|
|
||||||
API notes (discovered 2026-02):
|
API notes (discovered 2026-02):
|
||||||
- bbox params (min_latitude etc.) are silently ignored by the API
|
- 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
|
- `size=100` is the maximum effective page size
|
||||||
- ~14K venues globally as of Feb 2026
|
- ~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 json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import niquests
|
import niquests
|
||||||
|
|
||||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
|
from ._shared import HTTP_TIMEOUT_SECONDS, USER_AGENT, run_extractor, setup_logging
|
||||||
from .utils import landing_path, write_gzip_atomic
|
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")
|
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
|
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(
|
def extract(
|
||||||
landing_dir: Path,
|
landing_dir: Path,
|
||||||
year_month: str,
|
year_month: str,
|
||||||
@@ -44,48 +76,74 @@ def extract(
|
|||||||
"""Fetch all Playtomic venues via global pagination. Returns run metrics."""
|
"""Fetch all Playtomic venues via global pagination. Returns run metrics."""
|
||||||
year, month = year_month.split("/")
|
year, month = year_month.split("/")
|
||||||
dest_dir = landing_path(landing_dir, "playtomic", year, month)
|
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] = []
|
all_tenants: list[dict] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
page = 0
|
||||||
|
done = False
|
||||||
|
|
||||||
for page in range(MAX_PAGES):
|
while not done and page < MAX_PAGES:
|
||||||
params = {
|
batch_end = min(page + batch_size, MAX_PAGES)
|
||||||
"sport_ids": "PADEL",
|
pages_to_fetch = list(range(page, batch_end))
|
||||||
"size": PAGE_SIZE,
|
|
||||||
"page": page,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("GET page=%d (total so far: %d)", page, len(all_tenants))
|
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)]
|
||||||
|
|
||||||
resp = session.get(PLAYTOMIC_TENANTS_URL, params=params, timeout=HTTP_TIMEOUT_SECONDS)
|
# Process pages in order so the done-detection on < PAGE_SIZE is deterministic
|
||||||
resp.raise_for_status()
|
for p, tenants in sorted(results):
|
||||||
|
new_count = 0
|
||||||
|
for tenant in tenants:
|
||||||
|
tid = tenant.get("tenant_id") or tenant.get("id")
|
||||||
|
if tid and tid not in seen_ids:
|
||||||
|
seen_ids.add(tid)
|
||||||
|
all_tenants.append(tenant)
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
tenants = resp.json()
|
logger.info(
|
||||||
assert isinstance(tenants, list), (
|
"page=%d got=%d new=%d total=%d", p, len(tenants), new_count, len(all_tenants),
|
||||||
f"Expected list from Playtomic API, got {type(tenants)}"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
new_count = 0
|
# Last page — fewer than PAGE_SIZE results means we've exhausted the list
|
||||||
for tenant in tenants:
|
if len(tenants) < PAGE_SIZE:
|
||||||
tid = tenant.get("tenant_id") or tenant.get("id")
|
done = True
|
||||||
if tid and tid not in seen_ids:
|
break
|
||||||
seen_ids.add(tid)
|
|
||||||
all_tenants.append(tenant)
|
|
||||||
new_count += 1
|
|
||||||
|
|
||||||
logger.info(
|
page = batch_end
|
||||||
"page=%d got=%d new=%d total=%d", page, len(tenants), new_count, len(all_tenants)
|
if not next_proxy:
|
||||||
)
|
time.sleep(THROTTLE_SECONDS)
|
||||||
|
|
||||||
# Last page — fewer than PAGE_SIZE results means we've exhausted the list
|
# Write each tenant as a JSONL line, then compress atomically
|
||||||
if len(tenants) < PAGE_SIZE:
|
working_path = dest.with_suffix(".working.jsonl")
|
||||||
break
|
with open(working_path, "w") as f:
|
||||||
|
for tenant in all_tenants:
|
||||||
time.sleep(THROTTLE_SECONDS)
|
f.write(json.dumps(tenant, separators=(",", ":")) + "\n")
|
||||||
|
bytes_written = compress_jsonl_atomic(working_path, dest)
|
||||||
payload = json.dumps({"tenants": all_tenants, "count": len(all_tenants)}).encode()
|
|
||||||
bytes_written = write_gzip_atomic(dest, payload)
|
|
||||||
logger.info("%d unique venues -> %s", len(all_tenants), dest)
|
logger.info("%d unique venues -> %s", len(all_tenants), dest)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
Proxies are configured via the PROXY_URLS environment variable (comma-separated).
|
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.
|
When unset, all functions return None/no-op — extractors fall back to direct requests.
|
||||||
|
|
||||||
Two routing modes:
|
Tiered proxy with circuit breaker:
|
||||||
round-robin — distribute requests evenly across proxies (default)
|
Primary tier (PROXY_URLS) is used by default — typically cheap datacenter proxies.
|
||||||
sticky — same key always maps to same proxy (for session-tracked sites)
|
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 itertools
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_proxy_urls() -> list[str]:
|
def load_proxy_urls() -> list[str]:
|
||||||
"""Read PROXY_URLS env var (comma-separated). Returns [] if unset.
|
"""Read PROXY_URLS env var (comma-separated). Returns [] if unset.
|
||||||
@@ -23,6 +27,17 @@ def load_proxy_urls() -> list[str]:
|
|||||||
return urls
|
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]):
|
def make_round_robin_cycler(proxy_urls: list[str]):
|
||||||
"""Thread-safe round-robin proxy cycler.
|
"""Thread-safe round-robin proxy cycler.
|
||||||
|
|
||||||
@@ -42,16 +57,83 @@ def make_round_robin_cycler(proxy_urls: list[str]):
|
|||||||
return next_proxy
|
return next_proxy
|
||||||
|
|
||||||
|
|
||||||
def make_sticky_selector(proxy_urls: list[str]):
|
def make_tiered_cycler(
|
||||||
"""Consistent-hash proxy selector — same key always maps to same proxy.
|
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).
|
Uses primary_urls until consecutive failures >= threshold, then switches
|
||||||
Returns a callable: select_proxy(key: str) -> str | None
|
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:
|
assert threshold > 0, f"threshold must be positive, got {threshold}"
|
||||||
return lambda key: None
|
|
||||||
|
|
||||||
def select_proxy(key: str) -> str:
|
lock = threading.Lock()
|
||||||
return proxy_urls[hash(key) % len(proxy_urls)]
|
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
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ if you add multiple data sources, extract them to a shared workspace package.
|
|||||||
|
|
||||||
import gzip
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
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]
|
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:
|
def write_gzip_atomic(path: Path, data: bytes) -> int:
|
||||||
"""Gzip compress data and write to path atomically via .tmp sibling.
|
"""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.write_bytes(compressed)
|
||||||
tmp.rename(path)
|
tmp.rename(path)
|
||||||
return len(compressed)
|
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
|
||||||
|
|||||||
@@ -29,5 +29,5 @@ depends_on = ["playtomic_tenants"]
|
|||||||
[playtomic_recheck]
|
[playtomic_recheck]
|
||||||
module = "padelnomics_extract.playtomic_availability"
|
module = "padelnomics_extract.playtomic_availability"
|
||||||
entry = "main_recheck"
|
entry = "main_recheck"
|
||||||
schedule = "0 6-23 * * *"
|
schedule = "0,30 6-23 * * *"
|
||||||
depends_on = ["playtomic_availability"]
|
depends_on = ["playtomic_availability"]
|
||||||
|
|||||||
101
scripts/init_landing_seeds.py
Normal file
101
scripts/init_landing_seeds.py
Normal 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()
|
||||||
@@ -24,8 +24,12 @@ Usage:
|
|||||||
uv run python -m padelnomics.export_serving
|
uv run python -m padelnomics.export_serving
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
||||||
@@ -44,6 +48,8 @@ def export_serving() -> None:
|
|||||||
# (rename across filesystems is not atomic on Linux).
|
# (rename across filesystems is not atomic on Linux).
|
||||||
tmp_path = os.path.join(os.path.dirname(os.path.abspath(serving_path)), "_export.duckdb")
|
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)
|
src = duckdb.connect(pipeline_path, read_only=True)
|
||||||
try:
|
try:
|
||||||
# SQLMesh creates serving views that reference "local".sqlmesh__serving.*
|
# 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:
|
for view_name, view_sql in view_rows:
|
||||||
# Pattern: ... FROM "local".sqlmesh__serving.serving__name__hash;
|
# Pattern: ... FROM "local".sqlmesh__serving.serving__name__hash;
|
||||||
# Strip the "local". prefix to get schema.table
|
# Strip the "local". prefix to get schema.table
|
||||||
import re
|
|
||||||
match = re.search(r'FROM\s+"local"\.(sqlmesh__serving\.\S+)', view_sql)
|
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]}"
|
assert match, f"Cannot parse view definition for {view_name}: {view_sql[:200]}"
|
||||||
physical_tables.append((view_name, match.group(1)))
|
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.execute(f"CREATE OR REPLACE TABLE serving.{logical_name} AS SELECT * FROM _src")
|
||||||
dst.unregister("_src")
|
dst.unregister("_src")
|
||||||
row_count = dst.sql(f"SELECT count(*) FROM serving.{logical_name}").fetchone()[0]
|
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")
|
logger.info(f" serving.{logical_name}: {row_count:,} rows")
|
||||||
finally:
|
finally:
|
||||||
dst.close()
|
dst.close()
|
||||||
@@ -91,6 +97,16 @@ def export_serving() -> None:
|
|||||||
os.rename(tmp_path, serving_path)
|
os.rename(tmp_path, serving_path)
|
||||||
logger.info(f"Serving DB atomically updated: {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__":
|
if __name__ == "__main__":
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
|||||||
@@ -55,15 +55,16 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
|
|||||||
| Dimension | Grain | Used by |
|
| Dimension | Grain | Used by |
|
||||||
|-----------|-------|---------|
|
|-----------|-------|---------|
|
||||||
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
|
| `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` |
|
| `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
|
||||||
|
|
||||||
## Source integration map
|
## Source integration map
|
||||||
|
|
||||||
```
|
```
|
||||||
stg_playtomic_venues ─┐
|
stg_playtomic_venues ─┐
|
||||||
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ─→ city_market_profile
|
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile
|
||||||
stg_padel_courts ─┘ └→ dim_venue_capacity
|
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score)
|
||||||
↓
|
↓
|
||||||
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
|
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_population ──→ dim_cities ─────────────────────────────┘
|
||||||
stg_income ──→ 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
|
## Common pitfalls
|
||||||
|
|
||||||
- **Don't add business logic to staging.** Even a CASE statement renaming values = business
|
- **Don't add business logic to staging.** Even a CASE statement renaming values = business
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ gateways:
|
|||||||
type: duckdb
|
type: duckdb
|
||||||
catalogs:
|
catalogs:
|
||||||
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
|
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
|
||||||
|
extensions:
|
||||||
|
- spatial
|
||||||
|
|
||||||
default_gateway: duckdb
|
default_gateway: duckdb
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
-- One Big Table: per-city padel market intelligence.
|
-- One Big Table: per-city padel market intelligence.
|
||||||
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
|
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
|
||||||
--
|
--
|
||||||
-- Market score v2 (0–100):
|
-- Padelnomics Marktreife-Score v2 (0–100):
|
||||||
-- 30 pts population — log-scaled to 1M+ city ceiling (was 40pts/500K)
|
-- 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)
|
-- 25 pts income PPS — normalised to 200 ceiling (covers CH/NO/LU outliers)
|
||||||
-- 30 pts demand — observed occupancy if available, else venue density
|
-- 30 pts demand — observed occupancy if available, else venue density
|
||||||
-- 15 pts data quality — completeness discount, not a market signal
|
-- 15 pts data quality — completeness discount, not a market signal
|
||||||
|
-- ×0.85 saturation — discount when venues_per_100k > 8 (oversupplied market)
|
||||||
|
|
||||||
MODEL (
|
MODEL (
|
||||||
name serving.city_market_profile,
|
name serving.city_market_profile,
|
||||||
@@ -73,7 +78,11 @@ scored AS (
|
|||||||
-- Data quality (15 pts): measures completeness, not market quality.
|
-- Data quality (15 pts): measures completeness, not market quality.
|
||||||
-- Reduced from 20pts — kept as confidence discount, not market signal.
|
-- Reduced from 20pts — kept as confidence discount, not market signal.
|
||||||
+ 15.0 * data_confidence
|
+ 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
|
FROM base
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- Per-location padel investment opportunity intelligence.
|
||||||
|
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
|
||||||
|
--
|
||||||
|
-- Padelnomics Marktpotenzial-Score (0–100):
|
||||||
|
-- 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
|
||||||
@@ -3,12 +3,17 @@
|
|||||||
-- "Available" = the slot was NOT booked at capture time. Missing slots = booked.
|
-- "Available" = the slot was NOT booked at capture time. Missing slots = booked.
|
||||||
--
|
--
|
||||||
-- Reads BOTH morning snapshots and recheck files:
|
-- Reads BOTH morning snapshots and recheck files:
|
||||||
-- Morning: availability_{date}.json.gz → snapshot_type = 'morning'
|
-- Morning (new): availability_{date}.jsonl.gz → snapshot_type = 'morning'
|
||||||
-- Recheck: availability_{date}_recheck_{HH}.json.gz → snapshot_type = 'recheck'
|
-- 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).
|
-- Only 60-min duration slots are kept (canonical hourly rate + occupancy unit).
|
||||||
-- Price parsed from strings like "14.56 EUR" or "48 GBP".
|
-- 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.
|
-- Requires: at least one availability file in the landing zone.
|
||||||
-- A seed file (data/landing/playtomic/1970/01/availability_1970-01-01.json.gz)
|
-- 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.
|
-- with empty venues[] ensures this model runs before real data arrives.
|
||||||
@@ -20,77 +25,105 @@ MODEL (
|
|||||||
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
|
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Morning snapshots (filename does NOT contain '_recheck_')
|
WITH
|
||||||
WITH morning_files AS (
|
-- New format: one venue per JSONL line — no outer UNNEST needed
|
||||||
|
morning_jsonl AS (
|
||||||
SELECT
|
SELECT
|
||||||
*,
|
date AS snapshot_date,
|
||||||
'morning' AS snapshot_type,
|
captured_at_utc,
|
||||||
NULL::INTEGER AS recheck_hour
|
'morning' AS snapshot_type,
|
||||||
|
NULL::INTEGER AS recheck_hour,
|
||||||
|
tenant_id,
|
||||||
|
slots AS slots_json
|
||||||
FROM read_json(
|
FROM read_json(
|
||||||
@LANDING_DIR || '/playtomic/*/*/availability_*.json.gz',
|
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
|
||||||
format = 'auto',
|
format = 'newline_delimited',
|
||||||
columns = {
|
columns = {
|
||||||
date: 'VARCHAR',
|
date: 'VARCHAR',
|
||||||
captured_at_utc: 'VARCHAR',
|
captured_at_utc: 'VARCHAR',
|
||||||
venues: 'JSON[]'
|
tenant_id: 'VARCHAR',
|
||||||
|
slots: 'JSON'
|
||||||
},
|
},
|
||||||
filename = true,
|
filename = true
|
||||||
maximum_object_size = 134217728 -- 128 MB; daily files grow with venue count
|
|
||||||
)
|
)
|
||||||
WHERE filename NOT LIKE '%_recheck_%'
|
WHERE filename NOT LIKE '%_recheck_%'
|
||||||
AND venues IS NOT NULL
|
AND tenant_id IS NOT NULL
|
||||||
AND json_array_length(venues) > 0
|
|
||||||
),
|
),
|
||||||
-- Recheck snapshots (filename contains '_recheck_')
|
-- Old format: {"date":..., "venues": [...]} blob — kept for transition
|
||||||
-- Use TRY_CAST on a regex-extracted hour to get the recheck_hour.
|
morning_blob AS (
|
||||||
-- If no recheck files exist yet, this CTE produces zero rows (safe).
|
|
||||||
recheck_files AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
*,
|
af.date AS snapshot_date,
|
||||||
'recheck' AS snapshot_type,
|
|
||||||
TRY_CAST(
|
|
||||||
regexp_extract(filename, '_recheck_(\d+)', 1) AS INTEGER
|
|
||||||
) AS recheck_hour
|
|
||||||
FROM read_json(
|
|
||||||
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.json.gz',
|
|
||||||
format = 'auto',
|
|
||||||
columns = {
|
|
||||||
date: 'VARCHAR',
|
|
||||||
captured_at_utc: 'VARCHAR',
|
|
||||||
venues: 'JSON[]'
|
|
||||||
},
|
|
||||||
filename = true,
|
|
||||||
maximum_object_size = 134217728 -- 128 MB; matches morning snapshot limit
|
|
||||||
)
|
|
||||||
WHERE venues IS NOT NULL
|
|
||||||
AND json_array_length(venues) > 0
|
|
||||||
),
|
|
||||||
all_files AS (
|
|
||||||
SELECT date, captured_at_utc, venues, snapshot_type, recheck_hour FROM morning_files
|
|
||||||
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.captured_at_utc,
|
||||||
af.snapshot_type,
|
'morning' AS snapshot_type,
|
||||||
af.recheck_hour,
|
NULL::INTEGER AS recheck_hour,
|
||||||
venue_json
|
venue_json ->> 'tenant_id' AS tenant_id,
|
||||||
FROM all_files af,
|
venue_json -> 'slots' AS slots_json
|
||||||
|
FROM (
|
||||||
|
SELECT date, captured_at_utc, venues
|
||||||
|
FROM read_json(
|
||||||
|
@LANDING_DIR || '/playtomic/*/*/availability_*.json.gz',
|
||||||
|
format = 'auto',
|
||||||
|
columns = {
|
||||||
|
date: 'VARCHAR',
|
||||||
|
captured_at_utc: 'VARCHAR',
|
||||||
|
venues: 'JSON[]'
|
||||||
|
},
|
||||||
|
filename = true,
|
||||||
|
maximum_object_size = 134217728 -- 128 MB; daily files grow with venue count
|
||||||
|
)
|
||||||
|
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)
|
LATERAL UNNEST(af.venues) AS t(venue_json)
|
||||||
),
|
),
|
||||||
|
-- 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(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',
|
||||||
|
columns = {
|
||||||
|
date: 'VARCHAR',
|
||||||
|
captured_at_utc: 'VARCHAR',
|
||||||
|
venues: 'JSON[]'
|
||||||
|
},
|
||||||
|
filename = true,
|
||||||
|
maximum_object_size = 134217728 -- 128 MB; matches morning snapshot limit
|
||||||
|
)
|
||||||
|
WHERE venues IS NOT NULL
|
||||||
|
AND json_array_length(venues) > 0
|
||||||
|
) rf,
|
||||||
|
LATERAL UNNEST(rf.venues) AS t(venue_json)
|
||||||
|
),
|
||||||
|
all_venues AS (
|
||||||
|
SELECT * FROM morning_jsonl
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM morning_blob
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM recheck_blob
|
||||||
|
),
|
||||||
raw_resources AS (
|
raw_resources AS (
|
||||||
SELECT
|
SELECT
|
||||||
rv.snapshot_date,
|
av.snapshot_date,
|
||||||
rv.captured_at_utc,
|
av.captured_at_utc,
|
||||||
rv.snapshot_type,
|
av.snapshot_type,
|
||||||
rv.recheck_hour,
|
av.recheck_hour,
|
||||||
rv.venue_json ->> 'tenant_id' AS tenant_id,
|
av.tenant_id,
|
||||||
resource_json
|
resource_json
|
||||||
FROM raw_venues rv,
|
FROM all_venues av,
|
||||||
LATERAL UNNEST(
|
LATERAL UNNEST(
|
||||||
from_json(rv.venue_json -> 'slots', '["JSON"]')
|
from_json(av.slots_json, '["JSON"]')
|
||||||
) AS t(resource_json)
|
) AS t(resource_json)
|
||||||
),
|
),
|
||||||
raw_slots AS (
|
raw_slots AS (
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
-- DuckDB auto-infers opening_hours as STRUCT, so we access each day by literal
|
-- DuckDB auto-infers opening_hours as STRUCT, so we access each day by literal
|
||||||
-- key (no dynamic access) and UNION ALL to unpivot.
|
-- key (no dynamic access) and UNION ALL to unpivot.
|
||||||
--
|
--
|
||||||
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
|
-- Supports two landing formats (UNION ALL during migration):
|
||||||
-- Each tenant has opening_hours: {MONDAY: {opening_time, closing_time}, ...}
|
-- 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 (
|
MODEL (
|
||||||
name staging.stg_playtomic_opening_hours,
|
name staging.stg_playtomic_opening_hours,
|
||||||
@@ -15,7 +18,22 @@ MODEL (
|
|||||||
grain (tenant_id, day_of_week)
|
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
|
SELECT
|
||||||
tenant ->> 'tenant_id' AS tenant_id,
|
tenant ->> 'tenant_id' AS tenant_id,
|
||||||
tenant -> 'opening_hours' AS oh
|
tenant -> 'opening_hours' AS oh
|
||||||
@@ -30,6 +48,11 @@ WITH venues AS (
|
|||||||
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
||||||
AND (tenant -> 'opening_hours') 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
|
-- Unpivot by UNION ALL — 7 literal key accesses
|
||||||
unpivoted AS (
|
unpivoted AS (
|
||||||
SELECT tenant_id, 'MONDAY' AS day_of_week, 1 AS day_number,
|
SELECT tenant_id, 'MONDAY' AS day_of_week, 1 AS day_number,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
-- Individual court (resource) records from Playtomic venues.
|
-- Individual court (resource) records from Playtomic venues.
|
||||||
-- Reads resources array from the landing zone JSON directly (double UNNEST:
|
-- Reads resources array from the landing zone to extract court type, size,
|
||||||
-- tenants → resources) to extract court type, size, surface, and booking config.
|
-- surface, and booking config.
|
||||||
--
|
--
|
||||||
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
|
-- Supports two landing formats (UNION ALL during migration):
|
||||||
-- Each tenant has a resources[] array of court objects.
|
-- 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 (
|
MODEL (
|
||||||
name staging.stg_playtomic_resources,
|
name staging.stg_playtomic_resources,
|
||||||
@@ -12,36 +15,56 @@ MODEL (
|
|||||||
grain (tenant_id, resource_id)
|
grain (tenant_id, resource_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
WITH raw AS (
|
WITH
|
||||||
SELECT UNNEST(tenants) AS tenant
|
-- New format: one tenant per JSONL line — single UNNEST for resources
|
||||||
FROM read_json(
|
jsonl_unnested AS (
|
||||||
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
|
|
||||||
format = 'auto',
|
|
||||||
maximum_object_size = 134217728
|
|
||||||
)
|
|
||||||
),
|
|
||||||
unnested AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
tenant ->> 'tenant_id' AS tenant_id,
|
tenant_id,
|
||||||
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
|
UPPER(address ->> 'country_code') AS country_code,
|
||||||
UNNEST(from_json(tenant -> 'resources', '["JSON"]')) AS resource_json
|
UNNEST(from_json(resources, '["JSON"]')) AS resource_json
|
||||||
FROM raw
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
||||||
AND (tenant -> 'resources') IS NOT NULL
|
AND (tenant -> 'resources') IS NOT NULL
|
||||||
|
),
|
||||||
|
unnested AS (
|
||||||
|
SELECT * FROM jsonl_unnested
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM blob_unnested
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
tenant_id,
|
tenant_id,
|
||||||
resource_json ->> 'resource_id' AS resource_id,
|
resource_json ->> 'resource_id' AS resource_id,
|
||||||
country_code,
|
country_code,
|
||||||
NULLIF(TRIM(resource_json ->> 'name'), '') AS resource_name,
|
NULLIF(TRIM(resource_json ->> 'name'), '') AS resource_name,
|
||||||
resource_json ->> 'sport_id' AS sport_id,
|
resource_json ->> 'sport_id' AS sport_id,
|
||||||
CASE WHEN LOWER(resource_json ->> 'is_active') IN ('true', '1')
|
CASE WHEN LOWER(resource_json ->> 'is_active') IN ('true', '1')
|
||||||
THEN TRUE ELSE FALSE END AS is_active,
|
THEN TRUE ELSE FALSE END AS is_active,
|
||||||
LOWER(resource_json -> 'properties' ->> 'resource_type') AS resource_type,
|
LOWER(resource_json -> 'properties' ->> 'resource_type') AS resource_type,
|
||||||
LOWER(resource_json -> 'properties' ->> 'resource_size') AS resource_size,
|
LOWER(resource_json -> 'properties' ->> 'resource_size') AS resource_size,
|
||||||
LOWER(resource_json -> 'properties' ->> 'resource_feature') AS resource_feature,
|
LOWER(resource_json -> 'properties' ->> 'resource_feature') AS resource_feature,
|
||||||
CASE WHEN LOWER(resource_json -> 'booking_settings' ->> 'is_bookable_online') IN ('true', '1')
|
CASE WHEN LOWER(resource_json -> 'booking_settings' ->> 'is_bookable_online') IN ('true', '1')
|
||||||
THEN TRUE ELSE FALSE END AS is_bookable_online
|
THEN TRUE ELSE FALSE END AS is_bookable_online
|
||||||
FROM unnested
|
FROM unnested
|
||||||
WHERE (resource_json ->> 'resource_id') IS NOT NULL
|
WHERE (resource_json ->> 'resource_id') IS NOT NULL
|
||||||
AND (resource_json ->> 'sport_id') = 'PADEL'
|
AND (resource_json ->> 'sport_id') = 'PADEL'
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
-- Playtomic padel venue records — full metadata extraction.
|
-- 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.
|
-- including address, opening hours, court resources, VAT rate, and facilities.
|
||||||
-- Deduplicates on tenant_id (keeps most recent extraction).
|
-- Deduplicates on tenant_id (keeps most recent extraction).
|
||||||
--
|
--
|
||||||
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
|
-- Supports two landing formats (UNION ALL during migration):
|
||||||
-- Format: {"tenants": [{tenant_id, tenant_name, address, resources, opening_hours, ...}]}
|
-- 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 (
|
MODEL (
|
||||||
name staging.stg_playtomic_venues,
|
name staging.stg_playtomic_venues,
|
||||||
@@ -13,9 +16,52 @@ MODEL (
|
|||||||
grain tenant_id
|
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
|
SELECT
|
||||||
-- Identity
|
|
||||||
tenant ->> 'tenant_id' AS tenant_id,
|
tenant ->> 'tenant_id' AS tenant_id,
|
||||||
tenant ->> 'tenant_name' AS tenant_name,
|
tenant ->> 'tenant_name' AS tenant_name,
|
||||||
tenant ->> 'slug' AS slug,
|
tenant ->> 'slug' AS slug,
|
||||||
@@ -23,8 +69,6 @@ WITH parsed AS (
|
|||||||
tenant ->> 'tenant_status' AS tenant_status,
|
tenant ->> 'tenant_status' AS tenant_status,
|
||||||
tenant ->> 'playtomic_status' AS playtomic_status,
|
tenant ->> 'playtomic_status' AS playtomic_status,
|
||||||
tenant ->> 'booking_type' AS booking_type,
|
tenant ->> 'booking_type' AS booking_type,
|
||||||
|
|
||||||
-- Address
|
|
||||||
tenant -> 'address' ->> 'street' AS street,
|
tenant -> 'address' ->> 'street' AS street,
|
||||||
tenant -> 'address' ->> 'city' AS city,
|
tenant -> 'address' ->> 'city' AS city,
|
||||||
tenant -> 'address' ->> 'postal_code' AS postal_code,
|
tenant -> 'address' ->> 'postal_code' AS postal_code,
|
||||||
@@ -33,22 +77,13 @@ WITH parsed AS (
|
|||||||
tenant -> 'address' ->> 'administrative_area' AS administrative_area,
|
tenant -> 'address' ->> 'administrative_area' AS administrative_area,
|
||||||
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lat' AS DOUBLE) AS lat,
|
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lat' AS DOUBLE) AS lat,
|
||||||
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lon' AS DOUBLE) AS lon,
|
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lon' AS DOUBLE) AS lon,
|
||||||
|
TRY_CAST(tenant ->> 'vat_rate' AS DOUBLE) AS vat_rate,
|
||||||
-- Commercial
|
|
||||||
TRY_CAST(tenant ->> 'vat_rate' AS DOUBLE) AS vat_rate,
|
|
||||||
tenant ->> 'default_currency' AS default_currency,
|
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,
|
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 -> 'opening_hours' AS opening_hours_json,
|
||||||
tenant -> 'resources' AS resources_json,
|
tenant -> 'resources' AS resources_json,
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
tenant ->> 'created_at' AS created_at,
|
tenant ->> 'created_at' AS created_at,
|
||||||
tenant ->> 'is_playtomic_partner' AS is_playtomic_partner_raw,
|
tenant ->> 'is_playtomic_partner' AS is_playtomic_partner_raw,
|
||||||
|
|
||||||
filename AS source_file,
|
filename AS source_file,
|
||||||
CURRENT_DATE AS extracted_date
|
CURRENT_DATE AS extracted_date
|
||||||
FROM (
|
FROM (
|
||||||
@@ -62,6 +97,11 @@ WITH parsed AS (
|
|||||||
)
|
)
|
||||||
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
||||||
),
|
),
|
||||||
|
parsed AS (
|
||||||
|
SELECT * FROM jsonl_parsed
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM blob_parsed
|
||||||
|
),
|
||||||
deduped AS (
|
deduped AS (
|
||||||
SELECT *,
|
SELECT *,
|
||||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY source_file DESC) AS rn
|
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY source_file DESC) AS rn
|
||||||
|
|||||||
@@ -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.
|
-- 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).
|
-- 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 (
|
MODEL (
|
||||||
name staging.stg_population_geonames,
|
name staging.stg_population_geonames,
|
||||||
@@ -11,11 +16,41 @@ MODEL (
|
|||||||
grain geoname_id
|
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
|
SELECT
|
||||||
TRY_CAST(row ->> 'geoname_id' AS INTEGER) AS geoname_id,
|
TRY_CAST(row ->> 'geoname_id' AS INTEGER) AS geoname_id,
|
||||||
row ->> 'city_name' AS city_name,
|
row ->> 'city_name' AS city_name,
|
||||||
row ->> 'country_code' AS country_code,
|
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 ->> 'population' AS BIGINT) AS population,
|
||||||
TRY_CAST(row ->> 'ref_year' AS INTEGER) AS ref_year,
|
TRY_CAST(row ->> 'ref_year' AS INTEGER) AS ref_year,
|
||||||
CURRENT_DATE AS extracted_date
|
CURRENT_DATE AS extracted_date
|
||||||
@@ -23,20 +58,32 @@ WITH parsed AS (
|
|||||||
SELECT UNNEST(rows) AS row
|
SELECT UNNEST(rows) AS row
|
||||||
FROM read_json(
|
FROM read_json(
|
||||||
@LANDING_DIR || '/geonames/*/*/cities_global.json.gz',
|
@LANDING_DIR || '/geonames/*/*/cities_global.json.gz',
|
||||||
auto_detect = true
|
auto_detect = true,
|
||||||
|
maximum_object_size = 40000000
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
WHERE (row ->> 'geoname_id') IS NOT NULL
|
WHERE (row ->> 'geoname_id') IS NOT NULL
|
||||||
|
),
|
||||||
|
all_rows AS (
|
||||||
|
SELECT * FROM jsonl_rows
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM blob_rows
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
geoname_id,
|
geoname_id,
|
||||||
TRIM(city_name) AS city_name,
|
TRIM(city_name) AS city_name,
|
||||||
UPPER(country_code) AS country_code,
|
UPPER(country_code) AS country_code,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
NULLIF(TRIM(admin1_code), '') AS admin1_code,
|
||||||
|
NULLIF(TRIM(admin2_code), '') AS admin2_code,
|
||||||
population,
|
population,
|
||||||
ref_year,
|
ref_year,
|
||||||
extracted_date
|
extracted_date
|
||||||
FROM parsed
|
FROM all_rows
|
||||||
WHERE population IS NOT NULL
|
WHERE population IS NOT NULL
|
||||||
AND population > 0
|
AND population > 0
|
||||||
AND geoname_id IS NOT NULL
|
AND geoname_id IS NOT NULL
|
||||||
AND city_name IS NOT NULL
|
AND city_name IS NOT NULL
|
||||||
|
AND lat IS NOT NULL
|
||||||
|
AND lon IS NOT NULL
|
||||||
|
|||||||
@@ -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
175
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.3.1"
|
version = "3.3.1"
|
||||||
@@ -1297,8 +1381,8 @@ dependencies = [
|
|||||||
{ name = "duckdb" },
|
{ name = "duckdb" },
|
||||||
{ name = "google-api-python-client" },
|
{ name = "google-api-python-client" },
|
||||||
{ name = "google-auth" },
|
{ name = "google-auth" },
|
||||||
|
{ name = "granian", extra = ["reload"] },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "hypercorn" },
|
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "mistune" },
|
{ name = "mistune" },
|
||||||
@@ -1318,8 +1402,8 @@ requires-dist = [
|
|||||||
{ name = "duckdb", specifier = ">=1.0.0" },
|
{ name = "duckdb", specifier = ">=1.0.0" },
|
||||||
{ name = "google-api-python-client", specifier = ">=2.100.0" },
|
{ name = "google-api-python-client", specifier = ">=2.100.0" },
|
||||||
{ name = "google-auth", specifier = ">=2.23.0" },
|
{ name = "google-auth", specifier = ">=2.23.0" },
|
||||||
|
{ name = "granian", extras = ["reload"], specifier = ">=1.6.0" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
{ name = "hypercorn", specifier = ">=0.17.0" },
|
|
||||||
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
{ name = "mistune", specifier = ">=3.0.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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "wcwidth"
|
name = "wcwidth"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ dependencies = [
|
|||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"itsdangerous>=2.1.0",
|
"itsdangerous>=2.1.0",
|
||||||
"jinja2>=3.1.0",
|
"jinja2>=3.1.0",
|
||||||
"hypercorn>=0.17.0",
|
"granian[reload]>=1.6.0",
|
||||||
"paddle-python-sdk>=1.13.0",
|
"paddle-python-sdk>=1.13.0",
|
||||||
"mistune>=3.0.0",
|
"mistune>=3.0.0",
|
||||||
"resend>=2.22.0",
|
"resend>=2.22.0",
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ echo ""
|
|||||||
echo "Press Ctrl-C to stop all processes."
|
echo "Press Ctrl-C to stop all processes."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
run_with_label "$COLOR_APP" "app " uv run python -m padelnomics.app
|
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 -m padelnomics.worker
|
run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker
|
||||||
run_with_label "$COLOR_CSS" "css " make css-watch
|
run_with_label "$COLOR_CSS" "css " make css-watch
|
||||||
|
|
||||||
wait
|
wait
|
||||||
|
|||||||
209
web/src/padelnomics/admin/pseo_routes.py
Normal file
209
web/src/padelnomics/admin/pseo_routes.py
Normal 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)
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
Admin domain: role-based admin panel for managing users, tasks, etc.
|
Admin domain: role-based admin panel for managing users, tasks, etc.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime, timedelta
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import mistune
|
import mistune
|
||||||
@@ -29,7 +30,12 @@ from ..core import (
|
|||||||
fetch_one,
|
fetch_one,
|
||||||
send_email,
|
send_email,
|
||||||
slugify,
|
slugify,
|
||||||
|
utcnow,
|
||||||
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
|
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
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")
|
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
|
g.admin_unread_count = row["cnt"] if row else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("Failed to load admin sidebar unread count")
|
||||||
g.admin_unread_count = 0
|
g.admin_unread_count = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -64,9 +71,9 @@ def _admin_context():
|
|||||||
|
|
||||||
async def get_dashboard_stats() -> dict:
|
async def get_dashboard_stats() -> dict:
|
||||||
"""Get admin dashboard statistics."""
|
"""Get admin dashboard statistics."""
|
||||||
now = datetime.utcnow()
|
now = utcnow()
|
||||||
today = now.date().isoformat()
|
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_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL")
|
||||||
users_today = await fetch_one(
|
users_today = await fetch_one(
|
||||||
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
|
"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
|
SET status = 'pending', run_at = ?, error = NULL
|
||||||
WHERE id = ? AND status = 'failed'
|
WHERE id = ? AND status = 'failed'
|
||||||
""",
|
""",
|
||||||
(datetime.utcnow().isoformat(), task_id)
|
(utcnow_iso(), task_id)
|
||||||
)
|
)
|
||||||
return result > 0
|
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>")
|
@bp.route("/users/<int:user_id>")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def user_detail(user_id: int):
|
async def user_detail(user_id: int):
|
||||||
@@ -357,9 +381,10 @@ HEAT_OPTIONS = ["hot", "warm", "cool"]
|
|||||||
|
|
||||||
async def get_leads(
|
async def get_leads(
|
||||||
status: str = None, heat: str = None, country: str = None,
|
status: str = None, heat: str = None, country: str = None,
|
||||||
|
search: str = None, days: int = None,
|
||||||
page: int = 1, per_page: int = 50,
|
page: int = 1, per_page: int = 50,
|
||||||
) -> list[dict]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Get leads with optional filters."""
|
"""Get leads with optional filters. Returns (leads, total_count)."""
|
||||||
wheres = ["lead_type = 'quote'"]
|
wheres = ["lead_type = 'quote'"]
|
||||||
params: list = []
|
params: list = []
|
||||||
|
|
||||||
@@ -372,16 +397,27 @@ async def get_leads(
|
|||||||
if country:
|
if country:
|
||||||
wheres.append("country = ?")
|
wheres.append("country = ?")
|
||||||
params.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)
|
where = " AND ".join(wheres)
|
||||||
offset = (page - 1) * per_page
|
count_row = await fetch_one(
|
||||||
params.extend([per_page, offset])
|
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}
|
f"""SELECT * FROM lead_requests WHERE {where}
|
||||||
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
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:
|
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:
|
async def get_lead_stats() -> dict:
|
||||||
"""Get lead conversion funnel counts."""
|
"""Get lead conversion funnel counts + summary card metrics."""
|
||||||
rows = await fetch_all(
|
rows = await fetch_all(
|
||||||
"SELECT status, COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' GROUP BY status"
|
"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")
|
@bp.route("/leads")
|
||||||
@@ -417,10 +474,15 @@ async def leads():
|
|||||||
status = request.args.get("status", "")
|
status = request.args.get("status", "")
|
||||||
heat = request.args.get("heat", "")
|
heat = request.args.get("heat", "")
|
||||||
country = request.args.get("country", "")
|
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"))
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
lead_list = await get_leads(
|
lead_list, total = await get_leads(
|
||||||
status=status or None, heat=heat or None, country=country or None, page=page,
|
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()
|
lead_stats = await get_lead_stats()
|
||||||
|
|
||||||
@@ -438,7 +500,11 @@ async def leads():
|
|||||||
current_status=status,
|
current_status=status,
|
||||||
current_heat=heat,
|
current_heat=heat,
|
||||||
current_country=country,
|
current_country=country,
|
||||||
|
current_search=search,
|
||||||
|
current_days=days_str,
|
||||||
page=page,
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -449,12 +515,28 @@ async def lead_results():
|
|||||||
status = request.args.get("status", "")
|
status = request.args.get("status", "")
|
||||||
heat = request.args.get("heat", "")
|
heat = request.args.get("heat", "")
|
||||||
country = request.args.get("country", "")
|
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"))
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
lead_list = await get_leads(
|
lead_list, total = await get_leads(
|
||||||
status=status or None, heat=heat or None, country=country or None, page=page,
|
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>")
|
@bp.route("/leads/<int:lead_id>")
|
||||||
@@ -505,11 +587,18 @@ async def lead_new():
|
|||||||
contact_name = form.get("contact_name", "").strip()
|
contact_name = form.get("contact_name", "").strip()
|
||||||
contact_email = form.get("contact_email", "").strip()
|
contact_email = form.get("contact_email", "").strip()
|
||||||
facility_type = form.get("facility_type", "indoor")
|
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)
|
court_count = int(form.get("court_count", 6) or 6)
|
||||||
country = form.get("country", "")
|
country = form.get("country", "")
|
||||||
city = form.get("city", "").strip()
|
city = form.get("city", "").strip()
|
||||||
|
location_status = form.get("location_status", "")
|
||||||
timeline = form.get("timeline", "")
|
timeline = form.get("timeline", "")
|
||||||
budget_estimate = int(form.get("budget_estimate", 0) or 0)
|
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", "")
|
stakeholder_type = form.get("stakeholder_type", "")
|
||||||
heat_score = form.get("heat_score", "warm")
|
heat_score = form.get("heat_score", "warm")
|
||||||
status = form.get("status", "new")
|
status = form.get("status", "new")
|
||||||
@@ -522,19 +611,23 @@ async def lead_new():
|
|||||||
|
|
||||||
from ..credits import HEAT_CREDIT_COSTS
|
from ..credits import HEAT_CREDIT_COSTS
|
||||||
credit_cost = HEAT_CREDIT_COSTS.get(heat_score, 8)
|
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
|
verified_at = now if status != "pending_verification" else None
|
||||||
|
|
||||||
lead_id = await execute(
|
lead_id = await execute(
|
||||||
"""INSERT INTO lead_requests
|
"""INSERT INTO lead_requests
|
||||||
(lead_type, facility_type, court_count, country, location, timeline,
|
(lead_type, facility_type, build_context, glass_type, lighting_type,
|
||||||
budget_estimate, stakeholder_type, heat_score, status,
|
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,
|
contact_name, contact_email, contact_phone, contact_company,
|
||||||
credit_cost, verified_at, created_at)
|
credit_cost, verified_at, created_at)
|
||||||
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
facility_type, court_count, country, city, timeline,
|
facility_type, build_context, glass_type, lighting_type,
|
||||||
budget_estimate, stakeholder_type, heat_score, status,
|
court_count, country, city, location_status, timeline,
|
||||||
|
budget_estimate, financing_status, services_needed, additional_info,
|
||||||
|
stakeholder_type, heat_score, status,
|
||||||
contact_name, contact_email,
|
contact_name, contact_email,
|
||||||
form.get("contact_phone", ""), form.get("contact_company", ""),
|
form.get("contact_phone", ""), form.get("contact_company", ""),
|
||||||
credit_cost, verified_at, now,
|
credit_cost, verified_at, now,
|
||||||
@@ -567,7 +660,7 @@ async def lead_forward(lead_id: int):
|
|||||||
await flash("Already forwarded to this supplier.", "warning")
|
await flash("Already forwarded to this supplier.", "warning")
|
||||||
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
await execute(
|
await execute(
|
||||||
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
|
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
|
||||||
VALUES (?, ?, 0, 'sent', ?)""",
|
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))
|
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
|
# Supplier Management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -771,7 +1033,7 @@ async def supplier_new():
|
|||||||
instagram_url = form.get("instagram_url", "").strip()
|
instagram_url = form.get("instagram_url", "").strip()
|
||||||
youtube_url = form.get("youtube_url", "").strip()
|
youtube_url = form.get("youtube_url", "").strip()
|
||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
supplier_id = await execute(
|
supplier_id = await execute(
|
||||||
"""INSERT INTO suppliers
|
"""INSERT INTO suppliers
|
||||||
(name, slug, country_code, city, region, website, description, category,
|
(name, slug, country_code, city, region, website, description, category,
|
||||||
@@ -865,14 +1127,15 @@ async def flag_toggle():
|
|||||||
return redirect(url_for("admin.flags"))
|
return redirect(url_for("admin.flags"))
|
||||||
|
|
||||||
new_enabled = 0 if row["enabled"] else 1
|
new_enabled = 0 if row["enabled"] else 1
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
await execute(
|
await execute(
|
||||||
"UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?",
|
"UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?",
|
||||||
(new_enabled, now, flag_name),
|
(new_enabled, now, flag_name),
|
||||||
)
|
)
|
||||||
state = "enabled" if new_enabled else "disabled"
|
state = "enabled" if new_enabled else "disabled"
|
||||||
await flash(f"Flag '{flag_name}' {state}.", "success")
|
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")
|
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'")
|
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'")
|
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,))
|
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
|
||||||
return {
|
return {
|
||||||
"total": total["cnt"] if total else 0,
|
"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")
|
@bp.route("/emails/results")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def email_results():
|
async def email_results():
|
||||||
@@ -1022,12 +1324,21 @@ async def email_detail(email_id: int):
|
|||||||
else:
|
else:
|
||||||
enriched_html = getattr(result, "html", "")
|
enriched_html = getattr(result, "html", "")
|
||||||
except Exception:
|
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(
|
return await render_template(
|
||||||
"admin/email_detail.html",
|
"admin/email_detail.html",
|
||||||
email=email,
|
email=email,
|
||||||
enriched_html=enriched_html,
|
enriched_html=enriched_html,
|
||||||
|
related_lead=related_lead,
|
||||||
|
related_supplier=related_supplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1127,10 +1438,16 @@ async def email_compose():
|
|||||||
email_addresses=EMAIL_ADDRESSES,
|
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:
|
if wrap:
|
||||||
from ..worker import _email_wrap
|
html = render_email_template(
|
||||||
html = _email_wrap(html)
|
"emails/admin_compose.html",
|
||||||
|
lang="en",
|
||||||
|
body_html=body_html,
|
||||||
|
preheader="",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = body_html
|
||||||
|
|
||||||
result = await send_email(
|
result = await send_email(
|
||||||
to=to, subject=subject, html=html,
|
to=to, subject=subject, html=html,
|
||||||
@@ -1147,8 +1464,39 @@ async def email_compose():
|
|||||||
email_addresses=EMAIL_ADDRESSES,
|
email_addresses=EMAIL_ADDRESSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
prefill_to = request.args.get("to", "")
|
||||||
return await render_template(
|
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")
|
@role_required("admin")
|
||||||
async def audiences():
|
async def audiences():
|
||||||
"""List Resend audiences with local cache + API contact counts."""
|
"""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:
|
for a in audience_list:
|
||||||
a["contact_count"] = None
|
a["contact_count"] = None
|
||||||
if config.RESEND_API_KEY and a.get("audience_id"):
|
if config.RESEND_API_KEY and a.get("audience_id"):
|
||||||
@@ -1175,7 +1525,7 @@ async def audiences():
|
|||||||
data = getattr(contacts, "data", [])
|
data = getattr(contacts, "data", [])
|
||||||
a["contact_count"] = len(data) if data else 0
|
a["contact_count"] = len(data) if data else 0
|
||||||
except Exception:
|
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)
|
return await render_template("admin/audiences.html", audiences=audience_list)
|
||||||
|
|
||||||
@@ -1201,6 +1551,7 @@ async def audience_contacts(audience_id: str):
|
|||||||
else:
|
else:
|
||||||
contacts = getattr(result, "data", []) or []
|
contacts = getattr(result, "data", []) or []
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch contacts from Resend for audience %s", audience_id)
|
||||||
await flash("Failed to fetch contacts from Resend.", "error")
|
await flash("Failed to fetch contacts from Resend.", "error")
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
@@ -1239,21 +1590,20 @@ async def audience_contact_remove(audience_id: str):
|
|||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def templates():
|
async def templates():
|
||||||
"""List content templates scanned from disk."""
|
"""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()
|
template_list = discover_templates()
|
||||||
|
|
||||||
# Attach DuckDB row counts
|
# Single query: article counts for all templates — avoids N SQLite round-trips
|
||||||
for t in template_list:
|
counts_raw = await fetch_all(
|
||||||
count_rows = await fetch_template_data(t["data_table"], limit=501)
|
"SELECT template_slug, COUNT(*) as cnt FROM articles GROUP BY template_slug"
|
||||||
t["data_count"] = len(count_rows)
|
)
|
||||||
|
article_counts = {r["template_slug"]: r["cnt"] for r in counts_raw}
|
||||||
|
|
||||||
# Count generated articles for this template
|
# One DuckDB COUNT(*) per template (N queries, but cheap vs SELECT * LIMIT 501)
|
||||||
row = await fetch_one(
|
for t in template_list:
|
||||||
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?",
|
t["data_count"] = await count_template_data(t["data_table"])
|
||||||
(t["slug"],),
|
t["generated_count"] = article_counts.get(t["slug"], 0)
|
||||||
)
|
|
||||||
t["generated_count"] = row["cnt"] if row else 0
|
|
||||||
|
|
||||||
return await render_template("admin/templates.html", templates=template_list)
|
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")
|
@bp.route("/scenarios")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def scenarios():
|
async def scenarios():
|
||||||
"""List published scenarios."""
|
"""List published scenarios with optional filters."""
|
||||||
scenario_list = await fetch_all(
|
search = request.args.get("search", "").strip()
|
||||||
"SELECT * FROM published_scenarios ORDER BY created_at DESC"
|
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"])
|
@bp.route("/scenarios/new", methods=["GET", "POST"])
|
||||||
@@ -1487,7 +1897,7 @@ async def scenario_edit(scenario_id: int):
|
|||||||
dbl = state.get("dblCourts", 0)
|
dbl = state.get("dblCourts", 0)
|
||||||
sgl = state.get("sglCourts", 0)
|
sgl = state.get("sglCourts", 0)
|
||||||
court_config = f"{dbl} double + {sgl} single"
|
court_config = f"{dbl} double + {sgl} single"
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""UPDATE published_scenarios
|
"""UPDATE published_scenarios
|
||||||
@@ -1640,14 +2050,22 @@ async def _get_article_stats() -> dict:
|
|||||||
row = await fetch_one(
|
row = await fetch_one(
|
||||||
"""SELECT
|
"""SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END) AS live,
|
COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live,
|
||||||
SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END) AS scheduled,
|
COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled,
|
||||||
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft
|
COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft
|
||||||
FROM articles"""
|
FROM articles"""
|
||||||
)
|
)
|
||||||
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
|
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")
|
@bp.route("/articles")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def articles():
|
async def articles():
|
||||||
@@ -1677,6 +2095,7 @@ async def articles():
|
|||||||
current_template=template_filter,
|
current_template=template_filter,
|
||||||
current_language=language_filter,
|
current_language=language_filter,
|
||||||
page=page,
|
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,
|
language=language_filter or None, search=search or None, page=page,
|
||||||
)
|
)
|
||||||
return await render_template(
|
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.mkdir(parents=True, exist_ok=True)
|
||||||
(md_dir / f"{article_slug}.md").write_text(body)
|
(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(
|
await execute(
|
||||||
"""INSERT INTO articles
|
"""INSERT INTO articles
|
||||||
@@ -1800,7 +2222,7 @@ async def article_edit(article_id: int):
|
|||||||
md_dir.mkdir(parents=True, exist_ok=True)
|
md_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(md_dir / f"{article['slug']}.md").write_text(body)
|
(md_dir / f"{article['slug']}.md").write_text(body)
|
||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
pub_dt = published_at or article["published_at"]
|
pub_dt = published_at or article["published_at"]
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
@@ -1867,7 +2289,7 @@ async def article_publish(article_id: int):
|
|||||||
return redirect(url_for("admin.articles"))
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
new_status = "published" if article["status"] == "draft" else "draft"
|
new_status = "published" if article["status"] == "draft" else "draft"
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
await execute(
|
await execute(
|
||||||
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
|
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
|
||||||
(new_status, now, article_id),
|
(new_status, now, article_id),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
|
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
|
||||||
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
|
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
<form class="flex flex-wrap gap-3 items-end"
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
hx-get="{{ url_for('admin.article_results') }}"
|
hx-get="{{ url_for('admin.article_results') }}"
|
||||||
hx-target="#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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -65,6 +66,11 @@
|
|||||||
<option value="de" {% if current_language == 'de' %}selected{% endif %}>DE</option>
|
<option value="de" {% if current_language == 'de' %}selected{% endif %}>DE</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline">
|
<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="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 '') }}">
|
<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>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
|
|
||||||
.admin-main { flex: 1; padding: 2rem; overflow-y: auto; }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.admin-layout { flex-direction: column; }
|
.admin-layout { flex-direction: column; }
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
@@ -54,7 +63,11 @@
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</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 %}">
|
<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>
|
<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
|
Leads
|
||||||
@@ -86,6 +99,12 @@
|
|||||||
Templates
|
Templates
|
||||||
</a>
|
</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>
|
<div class="admin-sidebar__section">Email</div>
|
||||||
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
<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>
|
<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>
|
<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
|
Compose
|
||||||
</a>
|
</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 %}">
|
<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>
|
<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
|
Audiences
|
||||||
@@ -130,4 +153,24 @@
|
|||||||
{% block admin_content %}{% endblock %}
|
{% block admin_content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,51 +2,91 @@
|
|||||||
{% set admin_page = "compose" %}
|
{% set admin_page = "compose" %}
|
||||||
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% 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 %}
|
{% block admin_content %}
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
||||||
<h1 class="text-2xl mt-1">Compose Email</h1>
|
<h1 class="text-2xl mt-1">Compose Email</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card" style="padding:1.5rem;max-width:640px">
|
<div class="compose-layout">
|
||||||
<form method="post" action="{{ url_for('admin.email_compose') }}">
|
{# ── Left: form ────────────────────────────────────── #}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">From</label>
|
<label class="text-xs font-semibold text-slate block mb-1">From</label>
|
||||||
<select name="from_addr" class="form-input">
|
<select name="from_addr" class="form-input">
|
||||||
{% for key, addr in email_addresses.items() %}
|
{% for key, addr in email_addresses.items() %}
|
||||||
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
|
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">To</label>
|
<label class="text-xs font-semibold text-slate block mb-1">To</label>
|
||||||
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
|
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
|
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
|
||||||
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
|
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
|
<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 <br>)" required>{{ data.get('body', '') }}</textarea>
|
<textarea
|
||||||
</div>
|
name="body" rows="14" class="form-input"
|
||||||
|
placeholder="Plain text (line breaks become <br>)"
|
||||||
|
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">
|
<div class="mb-6">
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" name="wrap" value="1" checked>
|
<input
|
||||||
Wrap in branded email template
|
type="checkbox" name="wrap" value="1"
|
||||||
</label>
|
{% if data.get('wrap', True) %}checked{% endif %}
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="submit" class="btn">Send Email</button>
|
<button type="submit" class="btn">Send Email</button>
|
||||||
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
|
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,7 +14,15 @@
|
|||||||
<h2 class="text-lg mb-4">Details</h2>
|
<h2 class="text-lg mb-4">Details</h2>
|
||||||
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">To</dt>
|
<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>
|
<dt class="text-slate">From</dt>
|
||||||
<dd>{{ email.from_addr }}</dd>
|
<dd>{{ email.from_addr }}</dd>
|
||||||
<dt class="text-slate">Subject</dt>
|
<dt class="text-slate">Subject</dt>
|
||||||
|
|||||||
81
web/src/padelnomics/admin/templates/admin/email_gallery.html
Normal file
81
web/src/padelnomics/admin/templates/admin/email_gallery.html
Normal 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 →</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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">← 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 →
|
||||||
|
</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 %}
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
<form class="flex flex-wrap gap-3 items-end"
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
hx-get="{{ url_for('admin.email_results') }}"
|
hx-get="{{ url_for('admin.email_results') }}"
|
||||||
hx-target="#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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -51,6 +52,11 @@
|
|||||||
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
<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">
|
<input type="text" name="search" value="{{ current_search }}" class="form-input" placeholder="Email or subject..." style="min-width:180px">
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Generate Articles
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,122 +2,137 @@
|
|||||||
{% set admin_page = "leads" %}
|
{% set admin_page = "leads" %}
|
||||||
{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% 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 %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-6">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||||
<h1 class="text-2xl mt-1">Lead #{{ lead.id }}
|
<div class="flex items-center gap-3 mt-1">
|
||||||
{% if lead.heat_score == 'hot' %}<span class="badge-danger">HOT</span>
|
<h1 class="text-2xl">Lead #{{ lead.id }}</h1>
|
||||||
{% elif lead.heat_score == 'warm' %}<span class="badge-warning">WARM</span>
|
<span class="lead-heat-{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
|
||||||
{% else %}<span class="badge">COOL</span>{% endif %}
|
</div>
|
||||||
</h1>
|
</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:160px">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<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>
|
</div>
|
||||||
<!-- Status update -->
|
|
||||||
<form method="post" action="{{ url_for('admin.lead_status', lead_id=lead.id) }}" 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">
|
|
||||||
{% for s in statuses %}
|
|
||||||
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn-outline btn-sm">Update</button>
|
|
||||||
</form>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid-2" style="gap:1.5rem">
|
<div class="grid-2" style="gap:1.5rem">
|
||||||
<!-- Project brief -->
|
<!-- Project brief -->
|
||||||
<div class="card" style="padding:1.5rem">
|
<div class="card" style="padding:1.5rem">
|
||||||
<h2 class="text-lg mb-4">Project Brief</h2>
|
<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">
|
<dl style="display:grid;grid-template-columns:160px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">Facility</dt>
|
<dt class="text-slate">Facility</dt> <dd>{{ lead.facility_type or '-' }}</dd>
|
||||||
<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>
|
<dt class="text-slate">Courts</dt> <dd>{{ lead.court_count or '-' }}</dd>
|
||||||
<dd>{{ lead.court_count or '-' }}</dd>
|
<dt class="text-slate">Glass</dt> <dd>{{ lead.glass_type or '-' }}</dd>
|
||||||
<dt class="text-slate">Glass</dt>
|
<dt class="text-slate">Lighting</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
|
||||||
<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>
|
|
||||||
<dt class="text-slate">Location</dt>
|
<dt class="text-slate">Location</dt>
|
||||||
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
<dd>{{ lead.location or '-' }}{% if lead.country %},
|
||||||
<dt class="text-slate">Timeline</dt>
|
<a href="{{ url_for('admin.leads', country=lead.country) }}" class="text-sm">{{ lead.country }}</a>
|
||||||
<dd>{{ lead.timeline or '-' }}</dd>
|
{% else %}-{% endif %}</dd>
|
||||||
<dt class="text-slate">Phase</dt>
|
<dt class="text-slate">Phase</dt> <dd>{{ lead.location_status or '-' }}</dd>
|
||||||
<dd>{{ lead.location_status or '-' }}</dd>
|
<dt class="text-slate">Timeline</dt> <dd>{{ lead.timeline or '-' }}</dd>
|
||||||
<dt class="text-slate">Budget</dt>
|
<dt class="text-slate">Budget</dt> <dd>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</dd>
|
||||||
<dd>{{ lead.budget_estimate or '-' }}</dd>
|
<dt class="text-slate">Financing</dt> <dd>{{ lead.financing_status or '-' }}</dd>
|
||||||
<dt class="text-slate">Financing</dt>
|
<dt class="text-slate">Services</dt> <dd>{{ lead.services_needed or '-' }}</dd>
|
||||||
<dd>{{ lead.financing_status or '-' }}</dd>
|
<dt class="text-slate">Additional Info</dt><dd>{{ lead.additional_info or '-' }}</dd>
|
||||||
<dt class="text-slate">Services</dt>
|
<dt class="text-slate">Credit Cost</dt> <dd>{{ lead.credit_cost or '-' }} credits</dd>
|
||||||
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact info -->
|
<!-- Contact + forward -->
|
||||||
<div>
|
<div>
|
||||||
<div class="card mb-4" style="padding:1.5rem">
|
<div class="card mb-4" style="padding:1.5rem">
|
||||||
<h2 class="text-lg mb-4">Contact</h2>
|
<h2 class="text-lg mb-4">Contact</h2>
|
||||||
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">Name</dt>
|
<dt class="text-slate">Name</dt> <dd>{{ lead.contact_name or '-' }}</dd>
|
||||||
<dd>{{ lead.contact_name or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Email</dt>
|
<dt class="text-slate">Email</dt>
|
||||||
<dd>{{ lead.contact_email or '-' }}</dd>
|
<dd class="flex items-center gap-2 flex-wrap">
|
||||||
<dt class="text-slate">Phone</dt>
|
{{ lead.contact_email or '-' }}
|
||||||
<dd>{{ lead.contact_phone or '-' }}</dd>
|
{% if lead.contact_email %}
|
||||||
<dt class="text-slate">Company</dt>
|
<a href="{{ url_for('admin.emails', search=lead.contact_email) }}" class="text-xs text-slate" title="Email log">📧</a>
|
||||||
<dd>{{ lead.contact_company or '-' }}</dd>
|
<a href="mailto:{{ lead.contact_email }}" class="text-xs text-slate" title="mailto">✉</a>
|
||||||
<dt class="text-slate">Role</dt>
|
<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>
|
||||||
<dd>{{ lead.stakeholder_type or '-' }}</dd>
|
{% endif %}
|
||||||
<dt class="text-slate">Created</dt>
|
</dd>
|
||||||
<dd class="mono">{{ lead.created_at or '-' }}</dd>
|
<dt class="text-slate">Phone</dt> <dd>{{ lead.contact_phone or '-' }}</dd>
|
||||||
<dt class="text-slate">Verified</dt>
|
<dt class="text-slate">Company</dt> <dd>{{ lead.contact_company or '-' }}</dd>
|
||||||
<dd class="mono">{{ lead.verified_at or 'Not verified' }}</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward to supplier -->
|
<!-- Forward to supplier (HTMX) -->
|
||||||
<div class="card" style="padding:1.5rem">
|
<div class="card" style="padding:1.5rem">
|
||||||
<h2 class="text-lg mb-4">Forward to Supplier</h2>
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<select name="supplier_id" class="form-input mb-3" style="width:100%">
|
<select name="supplier_id" class="form-input mb-3" style="width:100%">
|
||||||
<option value="">Select supplier...</option>
|
<option value="">Select supplier…</option>
|
||||||
{% for s in suppliers %}
|
{# Lead's country first, then alphabetical #}
|
||||||
<option value="{{ s.id }}">{{ s.name }} ({{ s.country_code }}, {{ s.category }})</option>
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward history -->
|
<!-- Forward history -->
|
||||||
{% if lead.forwards %}
|
|
||||||
<section class="mt-6">
|
<section class="mt-6">
|
||||||
<h2 class="text-lg mb-3">Forward History</h2>
|
<h2 class="text-lg mb-3">Forward History
|
||||||
<div class="card">
|
<span class="text-sm text-slate font-normal">({{ lead.forwards | length }} total)</span>
|
||||||
<table class="table">
|
</h2>
|
||||||
<thead>
|
<div class="card" style="padding:1rem">
|
||||||
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Sent</th></tr>
|
<div id="forward-history">
|
||||||
</thead>
|
{% include "admin/partials/lead_forward_history.html" with context %}
|
||||||
<tbody>
|
</div>
|
||||||
{% 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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<div style="max-width:640px">
|
<div style="max-width:720px">
|
||||||
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||||
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
|
<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', '') }}">
|
<input type="email" name="contact_email" class="form-input" required value="{{ data.get('contact_email', '') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Phone</label>
|
<label class="form-label">Phone</label>
|
||||||
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
|
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
|
||||||
@@ -31,6 +30,14 @@
|
|||||||
<label class="form-label">Company</label>
|
<label class="form-label">Company</label>
|
||||||
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
|
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<hr style="margin:1.5rem 0">
|
<hr style="margin:1.5rem 0">
|
||||||
@@ -40,18 +47,44 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="form-label">Facility Type *</label>
|
<label class="form-label">Facility Type *</label>
|
||||||
<select name="facility_type" class="form-input" required>
|
<select name="facility_type" class="form-input" required>
|
||||||
<option value="indoor">Indoor</option>
|
{% for v in ['indoor','outdoor','both'] %}
|
||||||
<option value="outdoor">Outdoor</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('facility_type', 'indoor') == v }}>{{ v | title }}</option>
|
||||||
<option value="both">Both</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Courts</label>
|
<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') }}">
|
<input type="number" name="court_count" class="form-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Budget (€)</label>
|
<label class="form-label">Glass Type</label>
|
||||||
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,8 +92,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="form-label">Country *</label>
|
<label class="form-label">Country *</label>
|
||||||
<select name="country" class="form-input" required>
|
<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')] %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,41 +105,68 @@
|
|||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Timeline *</label>
|
<label class="form-label">Location Status</label>
|
||||||
<select name="timeline" class="form-input" required>
|
<select name="location_status" class="form-input">
|
||||||
<option value="asap">ASAP</option>
|
<option value="">—</option>
|
||||||
<option value="3-6mo">3-6 Months</option>
|
{% for v in ['secured','searching','evaluating'] %}
|
||||||
<option value="6-12mo">6-12 Months</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('location_status') == v }}>{{ v | title }}</option>
|
||||||
<option value="12+mo">12+ Months</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Stakeholder Type *</label>
|
<label class="form-label">Timeline *</label>
|
||||||
<select name="stakeholder_type" class="form-input" required>
|
<select name="timeline" class="form-input" required>
|
||||||
<option value="entrepreneur">Entrepreneur</option>
|
{% for v, label in [('asap','ASAP'),('3-6mo','3–6 Months'),('6-12mo','6–12 Months'),('12+mo','12+ Months')] %}
|
||||||
<option value="tennis_club">Tennis Club</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('timeline') == v }}>{{ label }}</option>
|
||||||
<option value="municipality">Municipality</option>
|
{% endfor %}
|
||||||
<option value="developer">Developer</option>
|
|
||||||
<option value="operator">Operator</option>
|
|
||||||
<option value="architect">Architect</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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;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>
|
<div>
|
||||||
<label class="form-label">Heat Score</label>
|
<label class="form-label">Heat Score</label>
|
||||||
<select name="heat_score" class="form-input">
|
<select name="heat_score" class="form-input">
|
||||||
<option value="hot">Hot</option>
|
{% for v in ['hot','warm','cool'] %}
|
||||||
<option value="warm" selected>Warm</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('heat_score', 'warm') == v }}>{{ v | title }}</option>
|
||||||
<option value="cool">Cool</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Status</label>
|
<label class="form-label">Status</label>
|
||||||
<select name="status" class="form-input">
|
<select name="status" class="form-input">
|
||||||
{% for s in statuses %}
|
{% for s in statuses %}
|
||||||
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s }}</option>
|
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s | replace('_', ' ') }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,83 @@
|
|||||||
{% extends "admin/base_admin.html" %}
|
{% extends "admin/base_admin.html" %}
|
||||||
{% set admin_page = "leads" %}
|
{% 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 %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-8">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Lead Management</h1>
|
<h1 class="text-2xl">Leads</h1>
|
||||||
<p class="text-sm text-slate mt-1">
|
<p class="text-sm text-slate mt-1">{{ total }} leads found</p>
|
||||||
{{ leads | length }} leads shown
|
|
||||||
{% if lead_stats %}
|
|
||||||
· {{ lead_stats.get('new', 0) }} new
|
|
||||||
· {{ lead_stats.get('forwarded', 0) }} forwarded
|
|
||||||
{% endif %}
|
|
||||||
</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>
|
</div>
|
||||||
|
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
|
||||||
</header>
|
</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 -->
|
<!-- Filters -->
|
||||||
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
||||||
<form class="flex flex-wrap gap-3 items-end"
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
hx-get="{{ url_for('admin.lead_results') }}"
|
hx-get="{{ url_for('admin.lead_results') }}"
|
||||||
hx-target="#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() }}">
|
<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>
|
<div>
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||||
<select name="status" class="form-input" style="min-width:140px">
|
<select name="status" class="form-input" style="min-width:140px">
|
||||||
@@ -57,6 +107,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
142
web/src/padelnomics/admin/templates/admin/marketplace.html
Normal file
142
web/src/padelnomics/admin/templates/admin/marketplace.html
Normal 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 %}
|
||||||
@@ -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 %}
|
{% if articles %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table class="table text-sm">
|
<table class="table text-sm">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
{% if leads %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -18,23 +43,15 @@
|
|||||||
{% for lead in leads %}
|
{% for lead in leads %}
|
||||||
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
|
<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><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
||||||
<td>
|
<td>{{ heat_badge(lead.heat_score) }}</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>
|
<td>
|
||||||
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
|
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
|
||||||
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
|
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ lead.country or '-' }}</td>
|
<td>{{ lead.country or '-' }}</td>
|
||||||
<td>{{ lead.court_count or '-' }}</td>
|
<td>{{ lead.court_count or '-' }}</td>
|
||||||
<td>{{ lead.budget_estimate or '-' }}</td>
|
<td>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</td>
|
||||||
<td><span class="badge">{{ lead.status }}</span></td>
|
<td>{{ status_badge(lead.status) }}</td>
|
||||||
<td>{{ lead.unlock_count or 0 }}</td>
|
<td>{{ lead.unlock_count or 0 }}</td>
|
||||||
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
|
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -42,6 +59,39 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
||||||
|
← 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 →
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-slate mt-3">Showing all {{ total }} results</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
<p class="text-slate">No leads match the current filters.</p>
|
<p class="text-slate">No leads match the current filters.</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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">← 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 →</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm" style="padding:1.5rem 1rem">No users found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
195
web/src/padelnomics/admin/templates/admin/pseo_dashboard.html
Normal file
195
web/src/padelnomics/admin/templates/admin/pseo_dashboard.html
Normal 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' %}🟢 Fresh
|
||||||
|
{% elif status == 'stale' %}🟡 Stale
|
||||||
|
{% elif status == 'no_articles' %}🟣 No articles
|
||||||
|
{% else %}⚪ 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 %}
|
||||||
43
web/src/padelnomics/admin/templates/admin/pseo_gaps.html
Normal file
43
web/src/padelnomics/admin/templates/admin/pseo_gaps.html
Normal 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">✓ 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 %}
|
||||||
99
web/src/padelnomics/admin/templates/admin/pseo_health.html
Normal file
99
web/src/padelnomics/admin/templates/admin/pseo_health.html
Normal 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">✓ 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">
|
||||||
|
⚠ 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">
|
||||||
|
❌ 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">
|
||||||
|
❌ 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>
|
||||||
@@ -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>
|
||||||
95
web/src/padelnomics/admin/templates/admin/pseo_jobs.html
Normal file
95
web/src/padelnomics/admin/templates/admin/pseo_jobs.html
Normal 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 %}
|
||||||
@@ -1,57 +1,68 @@
|
|||||||
{% extends "admin/base_admin.html" %}
|
{% extends "admin/base_admin.html" %}
|
||||||
{% set admin_page = "scenarios" %}
|
{% set admin_page = "scenarios" %}
|
||||||
|
|
||||||
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-8">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Published Scenarios</h1>
|
<h1 class="text-2xl">Scenarios</h1>
|
||||||
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
|
<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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
|
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
|
||||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card">
|
<form class="card mb-4 flex flex-wrap gap-3 items-end"
|
||||||
{% if scenarios %}
|
hx-get="{{ url_for('admin.scenario_results') }}"
|
||||||
<table class="table">
|
hx-target="#scenario-results"
|
||||||
<thead>
|
hx-trigger="change, input delay:300ms"
|
||||||
<tr>
|
hx-indicator="#scenario-loading">
|
||||||
<th>Title</th>
|
|
||||||
<th>Slug</th>
|
<div class="flex-1 min-w-48">
|
||||||
<th>Location</th>
|
<label class="block text-sm text-slate mb-1">Search</label>
|
||||||
<th>Config</th>
|
<input type="text" name="search" value="{{ current_search }}"
|
||||||
<th>Created</th>
|
placeholder="Title, location, slug…"
|
||||||
<th></th>
|
class="form-input w-full">
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<div>
|
||||||
{% for s in scenarios %}
|
<label class="block text-sm text-slate mb-1">Country</label>
|
||||||
<tr>
|
<select name="country" class="form-input">
|
||||||
<td>{{ s.title }}</td>
|
<option value="">All countries</option>
|
||||||
<td class="mono text-sm">{{ s.slug }}</td>
|
{% for c in countries %}
|
||||||
<td>{{ s.location }}, {{ s.country }}</td>
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</select>
|
||||||
</table>
|
</div>
|
||||||
{% else %}
|
|
||||||
<p class="text-slate text-sm">No published scenarios yet.</p>
|
<div>
|
||||||
{% endif %}
|
<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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -45,7 +45,14 @@
|
|||||||
<dd>{% if supplier.website %}<a href="{{ supplier.website }}" target="_blank" class="text-sm">{{ supplier.website }}</a>{% else %}-{% endif %}</dd>
|
<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>
|
<dt class="text-slate">Contact</dt>
|
||||||
<dd>{{ supplier.contact_name or '-' }}<br>
|
<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>
|
<dt class="text-slate">Tagline</dt>
|
||||||
<dd>{{ supplier.tagline or '-' }}</dd>
|
<dd>{{ supplier.tagline or '-' }}</dd>
|
||||||
<dt class="text-slate">Description</dt>
|
<dt class="text-slate">Description</dt>
|
||||||
@@ -73,7 +80,7 @@
|
|||||||
<dt class="text-slate">Enquiries</dt>
|
<dt class="text-slate">Enquiries</dt>
|
||||||
<dd>{{ enquiry_count }}</dd>
|
<dd>{{ enquiry_count }}</dd>
|
||||||
<dt class="text-slate">Claimed By</dt>
|
<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>
|
<dt class="text-slate">Created</dt>
|
||||||
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
|
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
<form class="flex flex-wrap gap-3 items-end"
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
hx-get="{{ url_for('admin.supplier_results') }}"
|
hx-get="{{ url_for('admin.supplier_results') }}"
|
||||||
hx-target="#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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -52,6 +53,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
|
<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">
|
<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() }}">
|
<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
|
Regenerate
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,69 +9,24 @@
|
|||||||
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">← Dashboard</a>
|
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">← Dashboard</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Search -->
|
<div class="card mb-6" style="padding:1rem 1.25rem">
|
||||||
<form method="get" class="mb-8">
|
<form class="flex gap-3 items-center"
|
||||||
<div class="flex gap-3 max-w-md">
|
hx-get="{{ url_for('admin.user_results') }}"
|
||||||
<input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
|
hx-target="#user-results"
|
||||||
<button type="submit" class="btn">Search</button>
|
hx-trigger="input delay:300ms"
|
||||||
</div>
|
hx-indicator="#user-loading">
|
||||||
</form>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Table -->
|
<div id="user-results">
|
||||||
<div class="card">
|
{% include "admin/partials/user_results.html" %}
|
||||||
{% 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 %}">← 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 →</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-slate text-sm">No users found.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ Usage:
|
|||||||
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])
|
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_conn = None # duckdb.DuckDBPyConnection | None — lazy import
|
_conn = None # duckdb.DuckDBPyConnection | None — lazy import
|
||||||
_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
_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:
|
def open_analytics_db() -> None:
|
||||||
"""Open the DuckDB connection. Call once at app startup."""
|
"""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.
|
Run a read-only DuckDB query and return rows as dicts.
|
||||||
|
|
||||||
Returns [] if analytics DB is unavailable (not yet built, or DUCKDB_PATH unset).
|
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"
|
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 []
|
return []
|
||||||
|
|
||||||
def _run() -> list[dict]:
|
def _run() -> list[dict]:
|
||||||
rel = _conn.execute(sql, params or [])
|
cur = _conn.cursor()
|
||||||
cols = [d[0] for d in rel.description]
|
try:
|
||||||
return [dict(zip(cols, row)) for row in rel.fetchall()]
|
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:
|
try:
|
||||||
return await asyncio.to_thread(_run)
|
return await asyncio.wait_for(
|
||||||
except Exception:
|
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 []
|
return []
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Padelnomics - Application factory and entry point.
|
Padelnomics - Application factory and entry point.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||||
|
|
||||||
from .analytics import close_analytics_db, open_analytics_db
|
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 .core import (
|
||||||
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
|
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()))
|
_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_x"] = _fmt_x
|
||||||
app.jinja_env.filters["fmt_n"] = _fmt_n
|
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["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
|
# Session config
|
||||||
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
||||||
@@ -208,7 +221,7 @@ def create_app() -> Quart:
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_globals():
|
def inject_globals():
|
||||||
from datetime import datetime
|
from .core import utcnow as _utcnow
|
||||||
lang = g.get("lang") or _detect_lang()
|
lang = g.get("lang") or _detect_lang()
|
||||||
g.lang = lang # ensure g.lang is always set (e.g. for dashboard/billing routes)
|
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"
|
effective_lang = lang if lang in SUPPORTED_LANGS else "en"
|
||||||
@@ -217,7 +230,7 @@ def create_app() -> Quart:
|
|||||||
"user": g.get("user"),
|
"user": g.get("user"),
|
||||||
"subscription": g.get("subscription"),
|
"subscription": g.get("subscription"),
|
||||||
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
|
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
|
||||||
"now": datetime.utcnow(),
|
"now": _utcnow(),
|
||||||
"csrf_token": get_csrf_token,
|
"csrf_token": get_csrf_token,
|
||||||
"ab_variant": getattr(g, "ab_variant", None),
|
"ab_variant": getattr(g, "ab_variant", None),
|
||||||
"ab_tag": getattr(g, "ab_tag", None),
|
"ab_tag": getattr(g, "ab_tag", None),
|
||||||
@@ -292,10 +305,15 @@ def create_app() -> Quart:
|
|||||||
async def legacy_suppliers():
|
async def legacy_suppliers():
|
||||||
return redirect("/en/suppliers", 301)
|
return redirect("/en/suppliers", 301)
|
||||||
|
|
||||||
|
@app.route("/market-score")
|
||||||
|
async def legacy_market_score():
|
||||||
|
return redirect("/en/market-score", 301)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Blueprint registration
|
# Blueprint registration
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from .admin.pseo_routes import bp as pseo_bp
|
||||||
from .admin.routes import bp as admin_bp
|
from .admin.routes import bp as admin_bp
|
||||||
from .auth.routes import bp as auth_bp
|
from .auth.routes import bp as auth_bp
|
||||||
from .billing.routes import bp as billing_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(dashboard_bp)
|
||||||
app.register_blueprint(billing_bp)
|
app.register_blueprint(billing_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(pseo_bp)
|
||||||
app.register_blueprint(webhooks_bp)
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
# Content catch-all LAST — lives under /<lang> too
|
# Content catch-all LAST — lives under /<lang> too
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Auth domain: magic link authentication, user management, decorators.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ from ..core import (
|
|||||||
fetch_one,
|
fetch_one,
|
||||||
is_disposable_email,
|
is_disposable_email,
|
||||||
is_flag_enabled,
|
is_flag_enabled,
|
||||||
|
utcnow,
|
||||||
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
from ..i18n import SUPPORTED_LANGS, get_translations
|
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:
|
async def create_user(email: str) -> int:
|
||||||
"""Create new user, return ID."""
|
"""Create new user, return ID."""
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
return await execute(
|
return await execute(
|
||||||
"INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now)
|
"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:
|
async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int:
|
||||||
"""Create auth token for user."""
|
"""Create auth token for user."""
|
||||||
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
|
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
|
||||||
expires = datetime.utcnow() + timedelta(minutes=minutes)
|
expires = utcnow() + timedelta(minutes=minutes)
|
||||||
return await execute(
|
return await execute(
|
||||||
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
"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
|
JOIN users u ON u.id = at.user_id
|
||||||
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
|
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:
|
async def mark_token_used(token_id: int) -> None:
|
||||||
"""Mark token as used."""
|
"""Mark token as used."""
|
||||||
await execute(
|
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"])
|
await mark_token_used(token_data["id"])
|
||||||
|
|
||||||
# Update last login
|
# 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
|
# Set session
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Payment provider: paddle
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from paddle_billing import Client as PaddleClient
|
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 quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
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
|
from ..i18n import get_translations
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ async def upsert_subscription(
|
|||||||
current_period_end: str = None,
|
current_period_end: str = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Create or update subscription. Finds existing by provider_subscription_id."""
|
"""Create or update subscription. Finds existing by provider_subscription_id."""
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
|
|
||||||
existing = await fetch_one(
|
existing = await fetch_one(
|
||||||
"SELECT id FROM subscriptions WHERE provider_subscription_id = ?",
|
"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:
|
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
|
||||||
"""Update subscription status by provider subscription ID."""
|
"""Update subscription status by provider subscription ID."""
|
||||||
extra["updated_at"] = datetime.utcnow().isoformat()
|
extra["updated_at"] = utcnow_iso()
|
||||||
extra["status"] = status
|
extra["status"] = status
|
||||||
sets = ", ".join(f"{k} = ?" for k in extra)
|
sets = ", ".join(f"{k} = ?" for k in extra)
|
||||||
values = list(extra.values())
|
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)
|
base_plan, tier = _derive_tier_from_plan(plan)
|
||||||
monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0)
|
monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0)
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
|
|
||||||
async with db_transaction() as db:
|
async with db_transaction() as db:
|
||||||
# Update supplier record — Basic tier also gets is_verified = 1
|
# 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)."""
|
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
|
||||||
supplier_id = custom_data.get("supplier_id")
|
supplier_id = custom_data.get("supplier_id")
|
||||||
user_id = custom_data.get("user_id")
|
user_id = custom_data.get("user_id")
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
|
|
||||||
items = data.get("items", [])
|
items = data.get("items", [])
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -412,10 +412,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
|
|||||||
|
|
||||||
# Sticky boost purchases
|
# Sticky boost purchases
|
||||||
elif key == "boost_sticky_week" and supplier_id:
|
elif key == "boost_sticky_week" and supplier_id:
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from ..core import transaction as db_transaction
|
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", "")
|
country = custom_data.get("sticky_country", "")
|
||||||
async with db_transaction() as db:
|
async with db_transaction() as db:
|
||||||
await db.execute(
|
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:
|
elif key == "boost_sticky_month" and supplier_id:
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from ..core import transaction as db_transaction
|
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", "")
|
country = custom_data.get("sticky_country", "")
|
||||||
async with db_transaction() as db:
|
async with db_transaction() as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ Data comes from DuckDB serving tables. Only articles + published_scenarios
|
|||||||
are stored in SQLite (routing / application state).
|
are stored in SQLite (routing / application state).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -15,7 +17,9 @@ import yaml
|
|||||||
from jinja2 import ChainableUndefined, Environment
|
from jinja2 import ChainableUndefined, Environment
|
||||||
|
|
||||||
from ..analytics import fetch_analytics
|
from ..analytics import fetch_analytics
|
||||||
from ..core import execute, fetch_one, slugify
|
from ..core import slugify, transaction, utcnow_iso
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Constants ────────────────────────────────────────────────────────────────
|
# ── 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:
|
def _validate_table_name(data_table: str) -> None:
|
||||||
"""Guard against SQL injection in table names."""
|
"""Guard against SQL injection in table names."""
|
||||||
assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
|
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:
|
def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str:
|
||||||
"""Jinja2 filter: format a date string (or 'now') with strftime."""
|
"""Jinja2 filter: format a date string (or 'now') with strftime."""
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime
|
||||||
|
|
||||||
if value == "now":
|
if value == "now":
|
||||||
dt = datetime.now(UTC)
|
dt = datetime.now(UTC)
|
||||||
@@ -271,6 +284,7 @@ async def generate_articles(
|
|||||||
*,
|
*,
|
||||||
limit: int = 500,
|
limit: int = 500,
|
||||||
base_url: str = "https://padelnomics.io",
|
base_url: str = "https://padelnomics.io",
|
||||||
|
task_id: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Generate articles from a git template + DuckDB data.
|
Generate articles from a git template + DuckDB data.
|
||||||
@@ -284,8 +298,14 @@ async def generate_articles(
|
|||||||
- write HTML to disk
|
- write HTML to disk
|
||||||
- upsert article row in SQLite
|
- 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 ..planner.calculator import DEFAULTS, calc, validate_state
|
||||||
from .routes import bake_scenario_cards, is_reserved_path
|
from .routes import bake_scenario_cards, is_reserved_path
|
||||||
|
|
||||||
@@ -298,64 +318,85 @@ async def generate_articles(
|
|||||||
if not rows:
|
if not rows:
|
||||||
return 0
|
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
|
publish_date = start_date
|
||||||
published_today = 0
|
published_today = 0
|
||||||
generated = 0
|
generated = 0
|
||||||
now_iso = datetime.now(UTC).isoformat()
|
now_iso = utcnow_iso()
|
||||||
|
|
||||||
for row in rows:
|
# Timing accumulators — logged at end so we can see where time goes.
|
||||||
for lang in config["languages"]:
|
t_calc = t_render = t_bake = 0.0
|
||||||
# Build render context: row data + language
|
|
||||||
ctx = {**row, "language": lang}
|
|
||||||
|
|
||||||
# Render URL pattern (no lang prefix — blueprint provides /<lang>)
|
_BATCH_SIZE = 200
|
||||||
url_path = _render_pattern(config["url_pattern"], ctx)
|
_PROGRESS_BATCH = 50 # write task progress every N articles (avoid write amplification)
|
||||||
if is_reserved_path(url_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = _render_pattern(config["title_pattern"], ctx)
|
# Write progress_total before the loop so the dashboard can show 0/N immediately.
|
||||||
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
|
if task_id is not None:
|
||||||
article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])
|
total = len(rows) * len(config["languages"])
|
||||||
|
await db_execute(
|
||||||
|
"UPDATE tasks SET progress_total = ? WHERE id = ?",
|
||||||
|
(total, task_id),
|
||||||
|
)
|
||||||
|
|
||||||
# Calculator content type: create scenario
|
async with transaction() as db:
|
||||||
scenario_slug = None
|
for row in rows:
|
||||||
if config["content_type"] == "calculator":
|
for lang in config["languages"]:
|
||||||
# DuckDB lowercases all column names; build a case-insensitive
|
# Build render context, replacing None with 0 so numeric
|
||||||
# reverse map so "ratepeak" (stored) matches "ratePeak" (DEFAULTS).
|
# Jinja filters (round, int) don't crash.
|
||||||
_defaults_ci = {k.lower(): k for k in DEFAULTS}
|
safe_ctx = {k: (v if v is not None else 0) for k, v in row.items()}
|
||||||
calc_overrides = {
|
safe_ctx["language"] = lang
|
||||||
_defaults_ci[k.lower()]: v
|
|
||||||
for k, v in row.items()
|
|
||||||
if k.lower() in _defaults_ci and v is not None
|
|
||||||
}
|
|
||||||
state = validate_state(calc_overrides)
|
|
||||||
d = calc(state, lang=lang)
|
|
||||||
|
|
||||||
scenario_slug = slug + "-" + str(row[config["natural_key"]])
|
# Render URL pattern (no lang prefix — blueprint provides /<lang>)
|
||||||
dbl = state.get("dblCourts", 0)
|
url_path = url_tmpl.render(**safe_ctx)
|
||||||
sgl = state.get("sglCourts", 0)
|
if is_reserved_path(url_path):
|
||||||
court_config = f"{dbl} double + {sgl} single"
|
continue
|
||||||
city = row.get("city_name", row.get("city", ""))
|
|
||||||
country = row.get("country", state.get("country", ""))
|
|
||||||
|
|
||||||
# Upsert published scenario
|
title = title_tmpl.render(**safe_ctx)
|
||||||
existing = await fetch_one(
|
meta_desc = meta_tmpl.render(**safe_ctx)
|
||||||
"SELECT id FROM published_scenarios WHERE slug = ?",
|
article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])
|
||||||
(scenario_slug,),
|
|
||||||
)
|
# Calculator content type: create scenario
|
||||||
if existing:
|
scenario_slug = None
|
||||||
await execute(
|
scenario_overrides = None
|
||||||
"""UPDATE published_scenarios
|
if config["content_type"] == "calculator":
|
||||||
SET state_json = ?, calc_json = ?, updated_at = ?
|
t0 = time.perf_counter()
|
||||||
WHERE slug = ?""",
|
# DuckDB lowercases all column names; build a case-insensitive
|
||||||
(json.dumps(state), json.dumps(d), now_iso, scenario_slug),
|
# reverse map so "ratepeak" (stored) matches "ratePeak" (DEFAULTS).
|
||||||
)
|
_defaults_ci = {k.lower(): k for k in DEFAULTS}
|
||||||
else:
|
calc_overrides = {
|
||||||
await execute(
|
_defaults_ci[k.lower()]: v
|
||||||
|
for k, v in row.items()
|
||||||
|
if k.lower() in _defaults_ci and v is not None
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
sgl = state.get("sglCourts", 0)
|
||||||
|
court_config = f"{dbl} double + {sgl} single"
|
||||||
|
city = row.get("city_name", row.get("city", ""))
|
||||||
|
country = row.get("country", state.get("country", ""))
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
"""INSERT INTO published_scenarios
|
"""INSERT INTO published_scenarios
|
||||||
(slug, title, location, country, venue_type, ownership,
|
(slug, title, location, country, venue_type, ownership,
|
||||||
court_config, state_json, calc_json, created_at)
|
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,
|
scenario_slug, city, city, country,
|
||||||
state.get("venue", "indoor"),
|
state.get("venue", "indoor"),
|
||||||
@@ -365,97 +406,114 @@ 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
|
# Render body template
|
||||||
body_md = _render_pattern(config["body_template"], ctx)
|
t0 = time.perf_counter()
|
||||||
body_html = mistune.html(body_md)
|
body_md = body_tmpl.render(**safe_ctx)
|
||||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
body_html = mistune.html(body_md)
|
||||||
|
t_render += time.perf_counter() - t0
|
||||||
|
|
||||||
# Extract FAQ pairs for structured data
|
t0 = time.perf_counter()
|
||||||
faq_pairs = _extract_faq_pairs(body_md)
|
body_html = await bake_scenario_cards(
|
||||||
|
body_html, lang=lang, scenario_overrides=scenario_overrides
|
||||||
|
)
|
||||||
|
t_bake += time.perf_counter() - t0
|
||||||
|
|
||||||
# Build SEO metadata (full_url includes lang prefix for canonical/OG)
|
# Extract FAQ pairs for structured data
|
||||||
full_url = f"{base_url}/{lang}{url_path}"
|
faq_pairs = _extract_faq_pairs(body_md)
|
||||||
publish_dt = datetime(
|
|
||||||
publish_date.year, publish_date.month, publish_date.day,
|
|
||||||
8, 0, 0,
|
|
||||||
).isoformat()
|
|
||||||
|
|
||||||
# Hreflang links
|
# Build SEO metadata (full_url includes lang prefix for canonical/OG)
|
||||||
hreflang_links = []
|
full_url = f"{base_url}/{lang}{url_path}"
|
||||||
for alt_lang in config["languages"]:
|
publish_dt = datetime(
|
||||||
alt_url = f"/{alt_lang}" + _render_pattern(config["url_pattern"], {**row, "language": alt_lang})
|
publish_date.year, publish_date.month, publish_date.day,
|
||||||
|
8, 0, 0,
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
# Hreflang links — reuse compiled url_tmpl with swapped language
|
||||||
|
hreflang_links = []
|
||||||
|
for alt_lang in config["languages"]:
|
||||||
|
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}" + url_tmpl.render(**{**safe_ctx, "language": default_lang})
|
||||||
hreflang_links.append(
|
hreflang_links.append(
|
||||||
f'<link rel="alternate" hreflang="{alt_lang}" href="{base_url}{alt_url}" />'
|
f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_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})
|
|
||||||
hreflang_links.append(
|
|
||||||
f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_url}" />'
|
|
||||||
)
|
|
||||||
|
|
||||||
# JSON-LD
|
# JSON-LD
|
||||||
breadcrumbs = _build_breadcrumbs(f"/{lang}{url_path}", base_url)
|
breadcrumbs = _build_breadcrumbs(f"/{lang}{url_path}", base_url)
|
||||||
jsonld_objects = build_jsonld(
|
jsonld_objects = build_jsonld(
|
||||||
config["schema_type"],
|
config["schema_type"],
|
||||||
title=title,
|
title=title,
|
||||||
description=meta_desc,
|
description=meta_desc,
|
||||||
url=full_url,
|
url=full_url,
|
||||||
published_at=publish_dt,
|
published_at=publish_dt,
|
||||||
date_modified=now_iso,
|
date_modified=now_iso,
|
||||||
language=lang,
|
language=lang,
|
||||||
breadcrumbs=breadcrumbs,
|
breadcrumbs=breadcrumbs,
|
||||||
faq_pairs=faq_pairs,
|
faq_pairs=faq_pairs,
|
||||||
)
|
|
||||||
|
|
||||||
# Build SEO head block
|
|
||||||
seo_head = "\n".join([
|
|
||||||
f'<link rel="canonical" href="{full_url}" />',
|
|
||||||
*hreflang_links,
|
|
||||||
f'<meta property="og:title" content="{_escape_attr(title)}" />',
|
|
||||||
f'<meta property="og:description" content="{_escape_attr(meta_desc)}" />',
|
|
||||||
f'<meta property="og:url" content="{full_url}" />',
|
|
||||||
'<meta property="og:type" content="article" />',
|
|
||||||
*[
|
|
||||||
f'<script type="application/ld+json">{json.dumps(obj, ensure_ascii=False)}</script>'
|
|
||||||
for obj in jsonld_objects
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
# Write HTML to disk
|
|
||||||
build_dir = BUILD_DIR / lang
|
|
||||||
build_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(build_dir / f"{article_slug}.html").write_text(body_html)
|
|
||||||
|
|
||||||
# Write markdown source to disk (for admin editing)
|
|
||||||
md_dir = BUILD_DIR / lang / "md"
|
|
||||||
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(
|
# Build SEO head block
|
||||||
|
seo_head = "\n".join([
|
||||||
|
f'<link rel="canonical" href="{full_url}" />',
|
||||||
|
*hreflang_links,
|
||||||
|
f'<meta property="og:title" content="{_escape_attr(title)}" />',
|
||||||
|
f'<meta property="og:description" content="{_escape_attr(meta_desc)}" />',
|
||||||
|
f'<meta property="og:url" content="{full_url}" />',
|
||||||
|
'<meta property="og:type" content="article" />',
|
||||||
|
*[
|
||||||
|
f'<script type="application/ld+json">{json.dumps(obj, ensure_ascii=False)}</script>'
|
||||||
|
for obj in jsonld_objects
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
# Write HTML to disk
|
||||||
|
build_dir = BUILD_DIR / lang
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / f"{article_slug}.html").write_text(body_html)
|
||||||
|
|
||||||
|
# Write markdown source to disk (for admin editing)
|
||||||
|
md_dir = BUILD_DIR / lang / "md"
|
||||||
|
md_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(md_dir / f"{article_slug}.md").write_text(body_md)
|
||||||
|
|
||||||
|
# Upsert article — keyed by (url_path, language).
|
||||||
|
# Single statement: no SELECT round-trip, no per-row commit.
|
||||||
|
await db.execute(
|
||||||
"""INSERT INTO articles
|
"""INSERT INTO articles
|
||||||
(url_path, slug, title, meta_description, country, region,
|
(url_path, slug, title, meta_description, country, region,
|
||||||
status, published_at, template_slug, language, date_modified,
|
status, published_at, template_slug, language, date_modified,
|
||||||
seo_head, created_at)
|
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,
|
url_path, article_slug, title, meta_desc,
|
||||||
row.get("country", ""), row.get("region", ""),
|
row.get("country", ""), row.get("region", ""),
|
||||||
@@ -463,14 +521,41 @@ async def generate_articles(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
generated += 1
|
generated += 1
|
||||||
|
|
||||||
# Stagger dates
|
# Commit every _BATCH_SIZE articles so the admin UI shows progress
|
||||||
published_today += 1
|
# earlier rather than waiting for the full run to complete.
|
||||||
if published_today >= articles_per_day:
|
if generated % _BATCH_SIZE == 0:
|
||||||
published_today = 0
|
await db.commit()
|
||||||
publish_date += timedelta(days=1)
|
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
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
397
web/src/padelnomics/content/health.py
Normal file
397
web/src/padelnomics/content/health.py
Normal 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),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ priority_column: population
|
|||||||
<div class="stats-strip__value">{{ padel_venue_count }}</div>
|
<div class="stats-strip__value">{{ padel_venue_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Market Score</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">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></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>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Spitzenpreis</div>
|
<div class="stats-strip__label">Spitzenpreis</div>
|
||||||
@@ -33,7 +33,7 @@ priority_column: population
|
|||||||
</div>
|
</div>
|
||||||
</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 }}.
|
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
|
## 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.
|
{{ 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.
|
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.
|
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**.
|
{{ 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 %}
|
{% 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.
|
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;">
|
<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? →
|
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 class="stats-strip__value">{{ padel_venue_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Market Score</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">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></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>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Peak Rate</div>
|
<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>
|
||||||
</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.
|
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
|
## 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.
|
{{ 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.
|
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.
|
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**.
|
{{ 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 %}
|
{% 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.
|
{{ 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;">
|
<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 }}? →
|
Ready to run the numbers for {{ city_name }}? →
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Die Preisspanne von {{ hourly_rate_p25 | round(0) | int }} bis {{ hourly_rate_p7
|
|||||||
|
|
||||||
## Wie steht {{ city_name }} im Vergleich da?
|
## 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 %}
|
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
|
## 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.
|
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.
|
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.
|
{{ 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.
|
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.
|
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;">
|
<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 →
|
Nutze die {{ city_name }}-Preisdaten für Deinen Businessplan →
|
||||||
@@ -153,7 +168,7 @@ The P25–P75 price range of {{ hourly_rate_p25 | round(0) | int }} to {{ hourly
|
|||||||
|
|
||||||
## How Does {{ city_name }} Compare?
|
## 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 %}
|
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
|
## 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.
|
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.
|
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.
|
{{ 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.
|
{{ 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.
|
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;">
|
<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 →
|
Use {{ city_name }} pricing data in your business plan →
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ priority_column: total_venues
|
|||||||
<div class="stats-strip__value">{{ city_count }}</div>
|
<div class="stats-strip__value">{{ city_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Ø Market Score</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">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></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>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Median Spitzenpreis</div>
|
<div class="stats-strip__label">Median Spitzenpreis</div>
|
||||||
@@ -34,7 +34,7 @@ priority_column: total_venues
|
|||||||
</div>
|
</div>
|
||||||
</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
|
## Marktlandschaft
|
||||||
|
|
||||||
@@ -82,20 +82,35 @@ Jede Stadt hat andere Kostenstrukturen, Wettbewerbsbedingungen und Zielgruppen.
|
|||||||
|
|
||||||
## FAQ
|
## 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.
|
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.
|
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 %}
|
{% 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.
|
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.
|
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;">
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||||
Du überlegst, eine Padelhalle in {{ country_name_en }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
|
Du überlegst, eine Padelhalle in {{ country_name_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 class="stats-strip__value">{{ city_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Avg Market Score</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">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></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>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Median Peak Rate</div>
|
<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>
|
||||||
</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
|
## Market Landscape
|
||||||
|
|
||||||
@@ -172,20 +187,35 @@ Every city has a different cost structure, competitive landscape, and customer b
|
|||||||
|
|
||||||
## FAQ
|
## 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.
|
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.
|
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 %}
|
{% 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.
|
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.
|
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;">
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||||
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →
|
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if article.country %}
|
{% if article.country %}
|
||||||
<span class="badge">{{ article.country }}</span>
|
<span class="badge">{{ article.country | country_name(lang) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if article.region %}
|
{% if article.region %}
|
||||||
<span class="text-xs text-slate">{{ article.region }}</span>
|
<span class="text-xs text-slate">{{ article.region }}</span>
|
||||||
|
|||||||
@@ -4,19 +4,25 @@ Core infrastructure: database, config, email, and shared utilities.
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import resend
|
import resend
|
||||||
from dotenv import load_dotenv
|
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
|
from quart import g, make_response, render_template, request, session
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -71,7 +77,7 @@ class Config:
|
|||||||
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
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 = {
|
PLAN_FEATURES: dict = {
|
||||||
"free": ["basic"],
|
"free": ["basic"],
|
||||||
@@ -88,6 +94,45 @@ class Config:
|
|||||||
|
|
||||||
config = 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
|
# Database
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -106,7 +151,8 @@ async def init_db(path: str = None) -> None:
|
|||||||
|
|
||||||
await _db.execute("PRAGMA journal_mode=WAL")
|
await _db.execute("PRAGMA journal_mode=WAL")
|
||||||
await _db.execute("PRAGMA foreign_keys=ON")
|
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 synchronous=NORMAL")
|
||||||
await _db.execute("PRAGMA cache_size=-64000")
|
await _db.execute("PRAGMA cache_size=-64000")
|
||||||
await _db.execute("PRAGMA temp_store=MEMORY")
|
await _db.execute("PRAGMA temp_store=MEMORY")
|
||||||
@@ -364,7 +410,7 @@ async def send_email(
|
|||||||
resend_id = None
|
resend_id = None
|
||||||
|
|
||||||
if not config.RESEND_API_KEY:
|
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"
|
resend_id = "dev"
|
||||||
else:
|
else:
|
||||||
resend.api_key = config.RESEND_API_KEY
|
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)
|
resend_id = result.get("id") if isinstance(result, dict) else getattr(result, "id", None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[EMAIL] Error sending to {to}: {e}")
|
logger.error("Error sending to %s: %s", to, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Log to email_log (best-effort, never fail the send)
|
# 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),
|
(resend_id, sender, to, subject, email_type),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[EMAIL] Failed to log email: {e}")
|
logger.error("Failed to log email: %s", e)
|
||||||
|
|
||||||
return resend_id
|
return resend_id
|
||||||
|
|
||||||
@@ -417,6 +463,7 @@ async def _get_or_create_resend_audience(name: str) -> str | None:
|
|||||||
)
|
)
|
||||||
return audience_id
|
return audience_id
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("Failed to create Resend audience %r", name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -457,7 +504,8 @@ async def capture_waitlist_email(
|
|||||||
)
|
)
|
||||||
is_new = cursor_result > 0
|
is_new = cursor_result > 0
|
||||||
except Exception:
|
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
|
is_new = False
|
||||||
|
|
||||||
# Enqueue confirmation email only if new
|
# Enqueue confirmation email only if new
|
||||||
@@ -479,7 +527,8 @@ async def capture_waitlist_email(
|
|||||||
resend.api_key = config.RESEND_API_KEY
|
resend.api_key = config.RESEND_API_KEY
|
||||||
resend.Contacts.create({"email": email, "audience_id": audience_id})
|
resend.Contacts.create({"email": email, "audience_id": audience_id})
|
||||||
except Exception:
|
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
|
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.
|
Uses SQLite for storage - no Redis needed.
|
||||||
"""
|
"""
|
||||||
limit = limit or config.RATE_LIMIT_REQUESTS
|
limit = limit or config.RATE_LIMIT_REQUESTS
|
||||||
window = window or config.RATE_LIMIT_WINDOW
|
window = window or config.RATE_LIMIT_WINDOW_SECONDS
|
||||||
now = datetime.utcnow()
|
now = utcnow()
|
||||||
window_start = now - timedelta(seconds=window)
|
window_start = now - timedelta(seconds=window)
|
||||||
|
|
||||||
# Clean old entries and count recent
|
# Clean old entries and count recent
|
||||||
await execute(
|
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(
|
result = await fetch_one(
|
||||||
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
|
"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
|
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
|
return False, info
|
||||||
|
|
||||||
# Record this request
|
# 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
|
return True, info
|
||||||
|
|
||||||
@@ -628,7 +681,7 @@ async def soft_delete(table: str, id: int) -> bool:
|
|||||||
"""Mark record as deleted."""
|
"""Mark record as deleted."""
|
||||||
result = await execute(
|
result = await execute(
|
||||||
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
|
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
|
||||||
(datetime.utcnow().isoformat(), id),
|
(utcnow_iso(), id),
|
||||||
)
|
)
|
||||||
return result > 0
|
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:
|
async def purge_deleted(table: str, days: int = 30) -> int:
|
||||||
"""Purge records deleted more than X days ago."""
|
"""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(
|
return await execute(
|
||||||
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
|
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
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, utcnow_iso
|
||||||
|
|
||||||
from .core import execute, fetch_all, fetch_one, transaction
|
|
||||||
|
|
||||||
# Credit cost per heat tier
|
# Credit cost per heat tier
|
||||||
HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8}
|
HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8}
|
||||||
@@ -44,7 +42,7 @@ async def add_credits(
|
|||||||
note: str = None,
|
note: str = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Add credits to a supplier. Returns new balance."""
|
"""Add credits to a supplier. Returns new balance."""
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
async with transaction() as db:
|
async with transaction() as db:
|
||||||
row = await db.execute_fetchall(
|
row = await db.execute_fetchall(
|
||||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||||
@@ -73,7 +71,7 @@ async def spend_credits(
|
|||||||
note: str = None,
|
note: str = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Spend credits from a supplier. Returns new balance. Raises InsufficientCredits."""
|
"""Spend credits from a supplier. Returns new balance. Raises InsufficientCredits."""
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
async with transaction() as db:
|
async with transaction() as db:
|
||||||
row = await db.execute_fetchall(
|
row = await db.execute_fetchall(
|
||||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
"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")
|
raise ValueError("Lead not found")
|
||||||
|
|
||||||
cost = lead["credit_cost"] or compute_credit_cost(lead)
|
cost = lead["credit_cost"] or compute_credit_cost(lead)
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
|
|
||||||
async with transaction() as db:
|
async with transaction() as db:
|
||||||
# Check balance
|
# Check balance
|
||||||
@@ -180,7 +178,7 @@ async def monthly_credit_refill(supplier_id: int) -> int:
|
|||||||
if not row or not row["monthly_credits"]:
|
if not row or not row["monthly_credits"]:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
new_balance = await add_credits(
|
new_balance = await add_credits(
|
||||||
supplier_id,
|
supplier_id,
|
||||||
row["monthly_credits"],
|
row["monthly_credits"],
|
||||||
@@ -201,6 +199,6 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]:
|
|||||||
FROM credit_ledger cl
|
FROM credit_ledger cl
|
||||||
LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock'
|
LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock'
|
||||||
WHERE cl.supplier_id = ?
|
WHERE cl.supplier_id = ?
|
||||||
ORDER BY cl.created_at DESC LIMIT ?""",
|
ORDER BY cl.created_at DESC, cl.id DESC LIMIT ?""",
|
||||||
(supplier_id, limit),
|
(supplier_id, limit),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Dashboard domain: user dashboard and settings.
|
Dashboard domain: user dashboard and settings.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
|
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..auth.routes import login_required, update_user
|
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
|
from ..i18n import get_translations
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -57,7 +56,7 @@ async def settings():
|
|||||||
await update_user(
|
await update_user(
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
name=form.get("name", "").strip() or None,
|
name=form.get("name", "").strip() or None,
|
||||||
updated_at=datetime.utcnow().isoformat(),
|
updated_at=utcnow_iso(),
|
||||||
)
|
)
|
||||||
t = get_translations(g.get("lang") or "en")
|
t = get_translations(g.get("lang") or "en")
|
||||||
await flash(t["dash_settings_saved"], "success")
|
await flash(t["dash_settings_saved"], "success")
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
Supplier directory: public, searchable listing of padel court suppliers.
|
Supplier directory: public, searchable listing of padel court suppliers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
|
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
from ..core import csrf_protect, execute, fetch_all, fetch_one, utcnow_iso
|
||||||
from ..i18n import get_translations
|
from ..i18n import COUNTRY_LABELS, get_translations
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
"directory",
|
"directory",
|
||||||
@@ -17,41 +16,6 @@ bp = Blueprint(
|
|||||||
template_folder=str(Path(__file__).parent / "templates"),
|
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 = {
|
CATEGORY_LABELS = {
|
||||||
"manufacturer": "Manufacturer",
|
"manufacturer": "Manufacturer",
|
||||||
@@ -89,7 +53,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
|
|||||||
lang = g.get("lang", "en")
|
lang = g.get("lang", "en")
|
||||||
cat_labels, country_labels, region_labels = get_directory_labels(lang)
|
cat_labels, country_labels, region_labels = get_directory_labels(lang)
|
||||||
|
|
||||||
now = datetime.now(UTC).isoformat()
|
now = utcnow_iso()
|
||||||
|
|
||||||
params: list = []
|
params: list = []
|
||||||
wheres: list[str] = []
|
wheres: list[str] = []
|
||||||
|
|||||||
307
web/src/padelnomics/email_templates.py
Normal file
307
web/src/padelnomics/email_templates.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -13,6 +13,44 @@ from pathlib import Path
|
|||||||
SUPPORTED_LANGS = {"en", "de"}
|
SUPPORTED_LANGS = {"en", "de"}
|
||||||
LANG_BLUEPRINTS = {"public", "planner", "directory", "content", "leads", "suppliers"}
|
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"
|
_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}"
|
assert lang in _CALC_ITEM_NAMES, f"Unknown lang: {lang!r}"
|
||||||
return _CALC_ITEM_NAMES[lang]
|
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)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Leads domain: capture interest in court suppliers and financing.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
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_disposable_email,
|
||||||
is_plausible_phone,
|
is_plausible_phone,
|
||||||
send_email,
|
send_email,
|
||||||
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
from ..i18n import get_translations
|
from ..i18n import get_translations
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ async def suppliers():
|
|||||||
form.get("court_count", 0),
|
form.get("court_count", 0),
|
||||||
form.get("budget", 0),
|
form.get("budget", 0),
|
||||||
form.get("message", ""),
|
form.get("message", ""),
|
||||||
datetime.utcnow().isoformat(),
|
utcnow_iso(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Notify admin
|
# Notify admin
|
||||||
@@ -147,7 +147,7 @@ async def financing():
|
|||||||
form.get("court_count", 0),
|
form.get("court_count", 0),
|
||||||
form.get("budget", 0),
|
form.get("budget", 0),
|
||||||
form.get("message", ""),
|
form.get("message", ""),
|
||||||
datetime.utcnow().isoformat(),
|
utcnow_iso(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await send_email(
|
await send_email(
|
||||||
@@ -346,8 +346,8 @@ async def quote_request():
|
|||||||
previous_supplier_contact, services_needed, additional_info,
|
previous_supplier_contact, services_needed, additional_info,
|
||||||
contact_name, contact_email, contact_phone, contact_company,
|
contact_name, contact_email, contact_phone, contact_company,
|
||||||
stakeholder_type,
|
stakeholder_type,
|
||||||
heat_score, status, credit_cost, token, created_at)
|
heat_score, status, credit_cost, token, created_at, visible_from)
|
||||||
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+2 hours'))""",
|
||||||
(
|
(
|
||||||
user_id,
|
user_id,
|
||||||
form.get("court_count", 0),
|
form.get("court_count", 0),
|
||||||
@@ -375,7 +375,7 @@ async def quote_request():
|
|||||||
status,
|
status,
|
||||||
credit_cost,
|
credit_cost,
|
||||||
secrets.token_urlsafe(16),
|
secrets.token_urlsafe(16),
|
||||||
datetime.utcnow().isoformat(),
|
utcnow_iso(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -520,9 +520,9 @@ async def verify_quote():
|
|||||||
from ..credits import compute_credit_cost
|
from ..credits import compute_credit_cost
|
||||||
|
|
||||||
credit_cost = compute_credit_cost(dict(lead))
|
credit_cost = compute_credit_cost(dict(lead))
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
await execute(
|
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"]),
|
(now, credit_cost, lead["id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -556,6 +556,7 @@ async def verify_quote():
|
|||||||
from ..worker import enqueue
|
from ..worker import enqueue
|
||||||
|
|
||||||
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
|
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(
|
return await render_template(
|
||||||
"quote_submitted.html",
|
"quote_submitted.html",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"nav_planner": "Finanzplaner",
|
"nav_planner": "Finanzplaner",
|
||||||
"nav_quotes": "Angebot erhalten",
|
"nav_quotes": "Angebote anfragen",
|
||||||
"nav_directory": "Anbieterverzeichnis",
|
"nav_directory": "Anbieterverzeichnis",
|
||||||
"nav_markets": "Märkte",
|
"nav_markets": "Märkte",
|
||||||
"nav_suppliers": "Für Anbieter",
|
"nav_suppliers": "Für Anbieter",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"nav_section_plan": "Planen & Entdecken",
|
"nav_section_plan": "Planen & Entdecken",
|
||||||
"nav_section_suppliers": "Anbieter",
|
"nav_section_suppliers": "Anbieter",
|
||||||
"nav_section_account": "Konto",
|
"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_product": "Produkt",
|
||||||
"footer_legal": "Rechtliches",
|
"footer_legal": "Rechtliches",
|
||||||
"footer_company": "Unternehmen",
|
"footer_company": "Unternehmen",
|
||||||
@@ -52,29 +52,29 @@
|
|||||||
"auth_signup_have_account": "Bereits ein Konto?",
|
"auth_signup_have_account": "Bereits ein Konto?",
|
||||||
"auth_signup_signin_link": "Anmelden",
|
"auth_signup_signin_link": "Anmelden",
|
||||||
"auth_magic_title": "E-Mail prüfen",
|
"auth_magic_title": "E-Mail prüfen",
|
||||||
"auth_magic_sent_to": "Wir haben dir einen Anmeldelink geschickt an:",
|
"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_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_no_email": "Keine E-Mail erhalten?",
|
||||||
"auth_magic_check_spam": "Schau in deinen Spam-Ordner",
|
"auth_magic_check_spam": "Schau in deinen Spam-Ordner",
|
||||||
"auth_magic_correct_email": "Stelle sicher, dass die E-Mail-Adresse korrekt ist",
|
"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_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_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_hint": "Du gehörst zu den Ersten, die Zugang erhalten, wenn wir launchen.",
|
||||||
"auth_waitlist_btn": "In Warteliste eintragen",
|
"auth_waitlist_btn": "In Warteliste eintragen",
|
||||||
"auth_waitlist_confirmed_title": "Du stehst auf der Warteliste!",
|
"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_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_next": "Was passiert als Nächstes?",
|
||||||
"auth_waitlist_confirmed_step1": "Du erhältst in Kürze eine Bestätigungs-E-Mail",
|
"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_step3": "Du erhältst exklusiven Frühzugang vor dem öffentlichen Launch",
|
||||||
"auth_waitlist_confirmed_back": "Zurück zur Startseite",
|
"auth_waitlist_confirmed_back": "Zurück zur Startseite",
|
||||||
"auth_flash_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
|
"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_disposable_email": "Bitte verwende eine dauerhafte E-Mail-Adresse.",
|
||||||
"auth_flash_login_sent": "Schau in deine E-Mails für den Anmeldelink!",
|
"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_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": "Ungültiger oder abgelaufener Link.",
|
||||||
"auth_flash_invalid_token_detail": "Ungültiger oder abgelaufener Link. Bitte fordere einen neuen an.",
|
"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_success": "Vielen Dank für dein Feedback!",
|
||||||
"flash_feedback_empty": "Bitte gib eine Nachricht ein.",
|
"flash_feedback_empty": "Bitte gib eine Nachricht ein.",
|
||||||
"flash_feedback_rate_limit": "Zu viele Anfragen. Bitte versuch es später erneut.",
|
"flash_feedback_rate_limit": "Zu viele Anfragen. Bitte versuch es später erneut.",
|
||||||
"flash_suppliers_success": "Danke! Wir verbinden dich mit verifizierten Hoflieferanten.",
|
"flash_suppliers_success": "Danke! Wir vermitteln Dich an verifizierte Platz-Anbieter.",
|
||||||
"flash_financing_success": "Danke! Wir verbinden dich mit Finanzierungspartnern.",
|
"flash_financing_success": "Danke! Wir vermitteln Dich an Finanzierungspartner.",
|
||||||
"flash_verify_invalid": "Ungültiger Verifizierungslink.",
|
"flash_verify_invalid": "Ungültiger Verifizierungslink.",
|
||||||
"flash_verify_expired": "Dieser Link ist abgelaufen oder wurde bereits verwendet. Bitte stelle eine neue Anfrage.",
|
"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.",
|
"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_1": "Plan Dein Padel-",
|
||||||
"landing_hero_h1_2": "Business in Minuten,",
|
"landing_hero_h1_2": "Business in Minuten,",
|
||||||
"landing_hero_h1_3": "nicht Monaten",
|
"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_btn_secondary": "Anbieter durchsuchen",
|
||||||
"landing_hero_bullet_1": "Keine Registrierung erforderlich",
|
"landing_hero_bullet_1": "Keine Registrierung erforderlich",
|
||||||
"landing_hero_bullet_2": "60+ Variablen",
|
"landing_hero_bullet_2": "60+ Variablen",
|
||||||
"landing_hero_bullet_3": "Unbegrenzte Szenarien",
|
"landing_hero_bullet_3": "Unbegrenzte Szenarien",
|
||||||
"landing_roi_title": "Schnelle Renditeschätzung",
|
"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_courts": "Plätze",
|
||||||
"landing_roi_rate": "Durchschn. Stundensatz",
|
"landing_roi_rate": "Durchschn. Stundensatz",
|
||||||
"landing_roi_util": "Ziel-Auslastung",
|
"landing_roi_util": "Ziel-Auslastung",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"landing_roi_payback": "Amortisationszeit",
|
"landing_roi_payback": "Amortisationszeit",
|
||||||
"landing_roi_annual_roi": "Jährlicher ROI",
|
"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_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_title": "Deine Reise",
|
||||||
"landing_journey_01": "Analysieren",
|
"landing_journey_01": "Analysieren",
|
||||||
"landing_journey_01_badge": "Demnächst",
|
"landing_journey_01_badge": "Demnächst",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"landing_journey_04": "Bauen",
|
"landing_journey_04": "Bauen",
|
||||||
"landing_journey_05": "Wachsen",
|
"landing_journey_05": "Wachsen",
|
||||||
"landing_journey_05_badge": "Demnächst",
|
"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_1_h3": "60+ Variablen",
|
||||||
"landing_feature_2_h3": "6 Analyse-Tabs",
|
"landing_feature_2_h3": "6 Analyse-Tabs",
|
||||||
"landing_feature_3_h3": "Indoor & Outdoor",
|
"landing_feature_3_h3": "Indoor & Outdoor",
|
||||||
@@ -137,9 +137,9 @@
|
|||||||
"landing_faq_q4": "Ist das Anbieterverzeichnis kostenlos?",
|
"landing_faq_q4": "Ist das Anbieterverzeichnis kostenlos?",
|
||||||
"landing_faq_q5": "Wie genau sind die Finanzprojektionen?",
|
"landing_faq_q5": "Wie genau sind die Finanzprojektionen?",
|
||||||
"landing_seo_title": "Padel-Platz-Investitionsplanung",
|
"landing_seo_title": "Padel-Platz-Investitionsplanung",
|
||||||
"landing_final_cta_h2": "Jetzt mit der Planung beginnen",
|
"landing_final_cta_h2": "Jetzt mit der Planung loslegen",
|
||||||
"landing_final_cta_btn": "Jetzt planen →",
|
"landing_final_cta_btn": "Jetzt Dein Padel-Business planen →",
|
||||||
"features_h1": "Alles, was du für dein Padel-Business brauchst",
|
"features_h1": "Alles, was Du für Dein Padel-Business brauchst",
|
||||||
"features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.",
|
"features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.",
|
||||||
"features_card_1_h2": "60+ Variablen",
|
"features_card_1_h2": "60+ Variablen",
|
||||||
"features_card_2_h2": "6 Analyse-Tabs",
|
"features_card_2_h2": "6 Analyse-Tabs",
|
||||||
@@ -154,19 +154,19 @@
|
|||||||
"features_cta_open": "Planer öffnen",
|
"features_cta_open": "Planer öffnen",
|
||||||
"features_cta_signup": "Kostenloses Konto erstellen",
|
"features_cta_signup": "Kostenloses Konto erstellen",
|
||||||
"about_why_h3": "Warum kostenlos?",
|
"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_open": "Planer öffnen",
|
||||||
"about_cta_signup": "Kostenloses Konto erstellen",
|
"about_cta_signup": "Kostenloses Konto erstellen",
|
||||||
"suppliers_hero_cta": "Pläne & Preise ansehen",
|
"suppliers_hero_cta": "Pläne & Preise ansehen",
|
||||||
"suppliers_stat_plans_label": "Erstellte Geschäftspläne",
|
"suppliers_stat_plans_label": "Erstellte Geschäftspläne",
|
||||||
"suppliers_stat_avg_value": "Durchschn. Projektwert",
|
"suppliers_stat_avg_value": "Durchschn. Projektwert",
|
||||||
"suppliers_stat_leads_label": "Leads diesen Monat",
|
"suppliers_stat_leads_label": "Leads diesen Monat",
|
||||||
"suppliers_problem_h2": "Das Problem bei der Kundengewinnung heute",
|
"suppliers_problem_h2": "Das Problem bei der Neukundengewinnung heute",
|
||||||
"suppliers_problem_sub": "Die meisten Kanäle verschwenden Zeit und Budget, bevor du mit einem echten Käufer sprichst.",
|
"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_1_h3": "Messen",
|
||||||
"suppliers_problem_2_h3": "Google Ads",
|
"suppliers_problem_2_h3": "Google Ads",
|
||||||
"suppliers_problem_3_h3": "Kaltakquise",
|
"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_h2": "So funktioniert es",
|
||||||
"suppliers_how_sub": "Drei Schritte zu qualifizierten Leads.",
|
"suppliers_how_sub": "Drei Schritte zu qualifizierten Leads.",
|
||||||
"suppliers_step_1_h3": "Eintrag beanspruchen",
|
"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_boosts_sub": "Mit jedem bezahlten Plan verfügbar. Verwalte sie über Dein Dashboard.",
|
||||||
"suppliers_comparison_h2": "Der direkte Vergleich",
|
"suppliers_comparison_h2": "Der direkte Vergleich",
|
||||||
"suppliers_faq_h2": "FAQ für Anbieter",
|
"suppliers_faq_h2": "FAQ für Anbieter",
|
||||||
"suppliers_final_cta_h2": "Dein nächster Kunde erstellt gerade einen Geschäftsplan",
|
"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_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",
|
"suppliers_final_cta_btn": "Pläne & Preise ansehen",
|
||||||
"planner_page_h2": "100 % kostenlos. Kein Haken.",
|
"planner_page_h2": "100 % kostenlos. Kein Haken.",
|
||||||
"planner_card_1_h3": "Finanzplaner",
|
"planner_card_1_h3": "Finanzplaner",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"planner_card_2_signup_btn": "Registrieren und loslegen",
|
"planner_card_2_signup_btn": "Registrieren und loslegen",
|
||||||
"planner_quote_cta_label": "Nächster Schritt",
|
"planner_quote_cta_label": "Nächster Schritt",
|
||||||
"planner_quote_cta_title": "Angebote von verifizierten Anbietern einholen",
|
"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_1": "Passende Anbieter",
|
||||||
"planner_quote_cta_check_2": "Direktkontakt, kein Vermittler",
|
"planner_quote_cta_check_2": "Direktkontakt, kein Vermittler",
|
||||||
"planner_quote_cta_check_3": "Keine Verpflichtung",
|
"planner_quote_cta_check_3": "Keine Verpflichtung",
|
||||||
@@ -245,12 +245,12 @@
|
|||||||
"export_back": "← Zurück zum Planer",
|
"export_back": "← Zurück zum Planer",
|
||||||
"export_success_title": "Zahlung eingegangen",
|
"export_success_title": "Zahlung eingegangen",
|
||||||
"export_success_subtitle": "Dein Geschäftsplan-PDF wird generiert. Dies dauert üblicherweise weniger als eine Minute.",
|
"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_refresh": "Status aktualisieren",
|
||||||
"export_success_all": "Alle Exporte anzeigen",
|
"export_success_all": "Alle Exporte anzeigen",
|
||||||
"export_success_planner": "Zurück zum Planer",
|
"export_success_planner": "Zurück zum Planer",
|
||||||
"export_gen_title": "Geschäftsplan wird generiert",
|
"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_refresh": "Jetzt aktualisieren",
|
||||||
"export_gen_all": "Alle Exporte anzeigen",
|
"export_gen_all": "Alle Exporte anzeigen",
|
||||||
"export_waitlist_title": "Geschäftsplan-PDF-Export demnächst verfügbar",
|
"export_waitlist_title": "Geschäftsplan-PDF-Export demnächst verfügbar",
|
||||||
@@ -264,9 +264,9 @@
|
|||||||
"scenario_created": "Erstellt",
|
"scenario_created": "Erstellt",
|
||||||
"dir_heading": "Padelplatz-Hersteller, Platzbauer & Anbieter",
|
"dir_heading": "Padelplatz-Hersteller, Platzbauer & Anbieter",
|
||||||
"dir_page_title": "Padel-Platz Anbieterverzeichnis",
|
"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_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": "Über {count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.",
|
"dir_page_og_desc": "{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_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_suppliers": "Anbieter",
|
||||||
"dir_stat_countries": "Länder",
|
"dir_stat_countries": "Länder",
|
||||||
"dir_stat_categories": "Kategorien",
|
"dir_stat_categories": "Kategorien",
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
"dir_search_btn": "Suchen",
|
"dir_search_btn": "Suchen",
|
||||||
"dir_filter_clear": "Alle löschen",
|
"dir_filter_clear": "Alle löschen",
|
||||||
"dir_cta_heading": "Bist Du ein Padelplatz-Anbieter?",
|
"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_cta_btn": "Eintrag erstellen",
|
||||||
"dir_card_verified": "Verifiziert",
|
"dir_card_verified": "Verifiziert",
|
||||||
"dir_card_featured": "Featured",
|
"dir_card_featured": "Featured",
|
||||||
@@ -352,16 +352,16 @@
|
|||||||
"sp_about": "Über uns",
|
"sp_about": "Über uns",
|
||||||
"sp_services": "Angebotene Leistungen",
|
"sp_services": "Angebotene Leistungen",
|
||||||
"sp_service_area": "Servicegebiet",
|
"sp_service_area": "Servicegebiet",
|
||||||
"sp_enquiry_heading": "Anfrage senden",
|
"sp_enquiry_heading": "Anfrage stellen",
|
||||||
"sp_enquiry_name": "Dein Name",
|
"sp_enquiry_name": "Dein Name",
|
||||||
"sp_enquiry_email": "E-Mail",
|
"sp_enquiry_email": "E-Mail",
|
||||||
"sp_enquiry_message": "Nachricht",
|
"sp_enquiry_message": "Nachricht",
|
||||||
"sp_enquiry_submit": "Anfrage senden",
|
"sp_enquiry_submit": "Anfrage absenden",
|
||||||
"sp_contact": "Kontakt",
|
"sp_contact": "Kontakt",
|
||||||
"sp_years": "Jahre aktiv",
|
"sp_years": "Jahre aktiv",
|
||||||
"sp_projects": "Projekte",
|
"sp_projects": "Projekte",
|
||||||
"sp_trust": "Verifizierter Eintrag — Identität und Inhaberschaft bestätigt",
|
"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_h3": "Ist das Dein Unternehmen?",
|
||||||
"sp_cta_claim_btn": "Eintrag beanspruchen →",
|
"sp_cta_claim_btn": "Eintrag beanspruchen →",
|
||||||
"sp_locked_hint": "Eintrag noch nicht verifiziert",
|
"sp_locked_hint": "Eintrag noch nicht verifiziert",
|
||||||
@@ -369,18 +369,18 @@
|
|||||||
"sp_locked_popover_link": "Angebotsassistent nutzen →",
|
"sp_locked_popover_link": "Angebotsassistent nutzen →",
|
||||||
"sp_locked_popover_dismiss": "Schließen",
|
"sp_locked_popover_dismiss": "Schließen",
|
||||||
"sp_enquiry_placeholder": "Erzähl {name} von Deinem Projekt…",
|
"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_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.",
|
"sp_cta_claim_desc": "Beanspruche und verifiziere diesen Eintrag, um Projektanfragen von Padel-Entwicklern zu erhalten.",
|
||||||
"enquiry_success_title": "Anfrage gesendet!",
|
"enquiry_success_title": "Anfrage gesendet!",
|
||||||
"enquiry_error_title": "Bitte korrigiere Folgendes:",
|
"enquiry_error_title": "Bitte korrigiere Folgendes:",
|
||||||
"enquiry_forwarded_msg": "Deine Nachricht wurde an {name} weitergeleitet. Der Anbieter meldet sich direkt 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.",
|
"enquiry_received_msg": "Deine Nachricht wurde empfangen. Das Team meldet sich in Kürze bei Dir.",
|
||||||
"q_btn_next": "Weiter →",
|
"q_btn_next": "Weiter →",
|
||||||
"q_btn_back": "← Zurück",
|
"q_btn_back": "← Zurück",
|
||||||
"q_btn_submit": "Absenden & Angebote erhalten →",
|
"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}",
|
"q_step_counter": "Schritt {step} von {total}",
|
||||||
"q1_heading": "Dein Projekt",
|
"q1_heading": "Dein Projekt",
|
||||||
"q1_subheading": "Welche Art von Padel-Anlage planst Du?",
|
"q1_subheading": "Welche Art von Padel-Anlage planst Du?",
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"q6_decision_partners": "Mit Partnern",
|
"q6_decision_partners": "Mit Partnern",
|
||||||
"q6_decision_committee": "Ausschuss / Vorstand",
|
"q6_decision_committee": "Ausschuss / Vorstand",
|
||||||
"q7_heading": "Über Dich",
|
"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_label": "Du bist…",
|
||||||
"q7_role_entrepreneur": "Unternehmer / Investor",
|
"q7_role_entrepreneur": "Unternehmer / Investor",
|
||||||
"q7_role_tennis": "Tennis- / Sportclub",
|
"q7_role_tennis": "Tennis- / Sportclub",
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
"q8_additional_label": "Noch etwas?",
|
"q8_additional_label": "Noch etwas?",
|
||||||
"q8_additional_placeholder": "Besondere Anforderungen, Fragen oder Hintergrundinformationen…",
|
"q8_additional_placeholder": "Besondere Anforderungen, Fragen oder Hintergrundinformationen…",
|
||||||
"q9_heading": "Kontaktdaten",
|
"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_privacy_msg": "Deine Kontaktdaten werden nur mit geprüften Anbietern geteilt, die zu Deinen Projektspezifikationen passen.",
|
||||||
"q9_name_label": "Vollständiger Name",
|
"q9_name_label": "Vollständiger Name",
|
||||||
"q9_email_label": "E-Mail",
|
"q9_email_label": "E-Mail",
|
||||||
@@ -492,7 +492,7 @@
|
|||||||
"q9_error_email": "E-Mail ist erforderlich",
|
"q9_error_email": "E-Mail ist erforderlich",
|
||||||
"q9_error_phone": "Telefonnummer ist erforderlich",
|
"q9_error_phone": "Telefonnummer ist erforderlich",
|
||||||
"qs_title": "Erfolgreich vermittelt!",
|
"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": "Anbieter prüfen Deinen Projektbrief und bereiten Angebote vor",
|
||||||
"qs_step_1_time": "Jetzt",
|
"qs_step_1_time": "Jetzt",
|
||||||
"qs_step_2": "Passende Anbieter kontaktieren Dich mit maßgeschneiderten Angeboten",
|
"qs_step_2": "Passende Anbieter kontaktieren Dich mit maßgeschneiderten Angeboten",
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
"qs_matched_court_suffix": "-Platz-",
|
"qs_matched_court_suffix": "-Platz-",
|
||||||
"qs_matched_facility_fmt": "{type}-",
|
"qs_matched_facility_fmt": "{type}-",
|
||||||
"qs_matched_project": "Projekt",
|
"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_heading": "E-Mail prüfen",
|
||||||
"qv_link_expiry": "Der Link läuft in 60 Minuten ab.",
|
"qv_link_expiry": "Der Link läuft in 60 Minuten ab.",
|
||||||
"qv_spam": "Spam-Ordner überprüfen",
|
"qv_spam": "Spam-Ordner überprüfen",
|
||||||
@@ -517,7 +517,7 @@
|
|||||||
"qv_wrong_email": "Falsche E-Mail?",
|
"qv_wrong_email": "Falsche E-Mail?",
|
||||||
"qv_wrong_email_link": "Neue Anfrage stellen",
|
"qv_wrong_email_link": "Neue Anfrage stellen",
|
||||||
"qv_sent_msg": "Wir haben einen Verifizierungslink an folgende Adresse gesendet:",
|
"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_no_email": "E-Mail nicht erhalten?",
|
||||||
"qv_check_email_pre": "Stell sicher, dass ",
|
"qv_check_email_pre": "Stell sicher, dass ",
|
||||||
"qv_check_email_post": " korrekt ist",
|
"qv_check_email_post": " korrekt ist",
|
||||||
@@ -529,20 +529,20 @@
|
|||||||
"sup_signup_of_steps": "von 4",
|
"sup_signup_of_steps": "von 4",
|
||||||
"sup_success_h2": "Alles bereit!",
|
"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_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_btn": "Zum Lead-Feed",
|
||||||
"sup_success_page_title": "Willkommen!",
|
"sup_success_page_title": "Willkommen!",
|
||||||
"sup_success_li1": "Dein Eintrag wird in wenigen Minuten aktualisiert",
|
"sup_success_li1": "Dein Eintrag wird in wenigen Minuten aktualisiert",
|
||||||
"sup_success_li2": "Lead-Credits wurden deinem Konto hinzugefügt",
|
"sup_success_li2": "Lead-Credits wurden deinem Konto hinzugefügt",
|
||||||
"sup_success_li3": "Prüfe deine E-Mail auf einen Anmelde-Link",
|
"sup_success_li3": "Prüfe deine E-Mail auf einen Anmelde-Link",
|
||||||
"sup_success_li4": "Durchsuche und entsperre Leads in deinem Feed",
|
"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_email_label": "E-Mail",
|
||||||
"sup_waitlist_submit": "Zur Warteliste",
|
"sup_waitlist_submit": "Zur Warteliste",
|
||||||
"sup_waitlist_signin_text": "Bereits ein Konto?",
|
"sup_waitlist_signin_text": "Bereits ein Konto?",
|
||||||
"sup_waitlist_signin_link": "Anmelden",
|
"sup_waitlist_signin_link": "Anmelden",
|
||||||
"sup_waitlist_page_title": "Anbieter-Warteliste",
|
"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_plan_h3": "{name} Plan-Highlights",
|
||||||
"sup_waitlist_hint": "Frühzeitiger Zugang, exklusiver Launch-Preis und bevorzugtes Onboarding.",
|
"sup_waitlist_hint": "Frühzeitiger Zugang, exklusiver Launch-Preis und bevorzugtes Onboarding.",
|
||||||
"sup_waitlist_conf_page_title": "Du stehst auf der Anbieter-Warteliste",
|
"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_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_pre": "Du gehörst zu den ersten Anbietern mit Zugang zum ",
|
||||||
"sup_waitlist_conf_first_post": "-Tier bei unserem Launch.",
|
"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_li1": "Erster Zugang zu qualifizierten Leads von Padel-Unternehmern",
|
||||||
"sup_waitlist_conf_li2": "Exklusiver Launch-Preis (für 12 Monate festgeschrieben)",
|
"sup_waitlist_conf_li2": "Exklusiver Launch-Preis (für 12 Monate festgeschrieben)",
|
||||||
"sup_waitlist_conf_li3": "Vorrangiges Onboarding und Support bei der Eintragsoptimierung",
|
"sup_waitlist_conf_li3": "Vorrangiges Onboarding und Support bei der Eintragsoptimierung",
|
||||||
@@ -577,7 +577,7 @@
|
|||||||
"sup_step3_free_desc": "Nur Plan-Credits",
|
"sup_step3_free_desc": "Nur Plan-Credits",
|
||||||
"sup_step3_next": "Weiter: Deine Daten",
|
"sup_step3_next": "Weiter: Deine Daten",
|
||||||
"sup_step4_title": "Kontodaten",
|
"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_contact_name": "Ansprechpartner",
|
||||||
"sup_step4_email": "E-Mail",
|
"sup_step4_email": "E-Mail",
|
||||||
"sup_step4_phone": "Telefon",
|
"sup_step4_phone": "Telefon",
|
||||||
@@ -710,8 +710,8 @@
|
|||||||
"sl_hold_years": "Haltedauer",
|
"sl_hold_years": "Haltedauer",
|
||||||
"sl_exit_multiple": "Exit-EBITDA-Multiplikator",
|
"sl_exit_multiple": "Exit-EBITDA-Multiplikator",
|
||||||
"sl_annual_rev_growth": "Jährliches Umsatzwachstum",
|
"sl_annual_rev_growth": "Jährliches Umsatzwachstum",
|
||||||
"wiz_summary_label": "Aktuelle Werte",
|
"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_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_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_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 (200 m²), Sicherheitszonen, Laufwege und Mindestabstände. Standard: 300–350 m².",
|
"tip_sqm_dbl_hall": "Gesamte Hallenfläche pro Doppelplatz. Enthält Spielfeld (200 m²), Sicherheitszonen, Laufwege und Mindestabstände. Standard: 300–350 m².",
|
||||||
@@ -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_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_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: 5–15 % des Platzumsatzes.",
|
"tip_booking_fee": "Provision von Buchungsplattformen wie Playtomic oder Matchi. Typisch: 5–15 % des Platzumsatzes.",
|
||||||
"tip_util_target": "Anteil der verfügbaren Platzstunden, der tatsächlich gebucht wird. 35–45 % sind realistisch für neue Anlagen, 50 %+ ist stark.",
|
"tip_util_target": "Anteil der verfügbaren Platzstunden, der tatsächlich gebucht wird. 35–45 % sind realistisch für neue Anlagen, ab 50 % ist stark.",
|
||||||
"tip_hours_per_day": "Gesamte Betriebsstunden pro Tag. Typische Padel-Anlagen öffnen 7–23 Uhr (16 h). Manche auch 6–24 Uhr.",
|
"tip_hours_per_day": "Gesamte Betriebsstunden pro Tag. Typische Padel-Anlagen öffnen 7–23 Uhr (16 h). Manche auch 6–24 Uhr.",
|
||||||
"tip_days_indoor": "Durchschnittliche Betriebstage pro Monat für Indoor-Anlagen. ~29 berücksichtigt Feiertage und Wartungsschließungen.",
|
"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.",
|
"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_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_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_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. 10–15 % sind beim Bau Standard, 15–20 % bei komplexen Projekten.",
|
"tip_contingency": "Prozentualer Puffer auf den Gesamt-CAPEX für unvorhergesehene Kosten. 10–15 % sind beim Bau Standard, 15–20 % bei komplexen Projekten.",
|
||||||
"tip_budget_target": "Gesamtbudget festlegen, um den geplanten CAPEX zu vergleichen. 0 lassen, um den Budgetindikator auszublenden.",
|
"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.",
|
"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_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_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_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 70–85 %. Höher mit Bürgüschaft oder Fördermitteln.",
|
"tip_loan_pct": "Anteil des Gesamt-CAPEX, der fremdfinanziert wird. Banken bieten typisch 70–85 %. 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_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_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_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: 5–7 Jahre. Betreiber-Eigentümer können unbegrenzt halten.",
|
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch für PE/Investoren: 5–7 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: 4–6×, starke Marke: 6–8×.",
|
"tip_exit_multiple": "EBITDA-Multiplikator zur Unternehmensbewertung beim Exit. Spiegelt Marktnachfrage, Markenstärke und Wachstumspotenzial wider. Kleines Business: 4–6×, starke Marke: 6–8×.",
|
||||||
"tip_annual_rev_growth": "Erwartetes jährliches Umsatzwachstum nach der ersten 12-monatigen Anlaufphase. Getrieben durch Preiserhöhungen und steigende Auslastung.",
|
"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.",
|
"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.",
|
"tip_result_yield_on_cost": "Stabilisiertes EBITDA ÷ Gesamtinvestition (CAPEX). Ungehebelte Rendite — nützlich zum Vergleich mit anderen Anlageklassen oder Bauprojekten.",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_my_scenarios": "Meine Szenarien",
|
"btn_my_scenarios": "Meine Szenarien",
|
||||||
"btn_reset": "Zurücksetzen",
|
"btn_reset": "Auf Standardwerte zurücksetzen",
|
||||||
"btn_reset_confirm": "Sicher? Zurücksetzen",
|
"btn_reset_confirm": "Sicher? Zurücksetzen",
|
||||||
"btn_back": "← Zurück",
|
"btn_back": "← Zurück",
|
||||||
"btn_next": "Weiter →",
|
"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_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_h2": "So funktioniert es",
|
||||||
"sup_how_sub": "Drei Schritte zu qualifizierten Leads.",
|
"sup_how_sub": "Drei Schritte zu qualifizierten Leads.",
|
||||||
"sup_how_step1_h3": "Dein Inserat beanspruchen",
|
"sup_how_step1_h3": "Deinen Eintrag 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_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_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_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_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_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",
|
"sup_credits_hot": "Heißer Lead",
|
||||||
@@ -986,7 +986,7 @@
|
|||||||
"sup_boost_sticky": "Sticky Top",
|
"sup_boost_sticky": "Sticky Top",
|
||||||
"sup_boost_color": "Eigene Kartenfarbe",
|
"sup_boost_color": "Eigene Kartenfarbe",
|
||||||
"sup_cmp_h2": "So schlagen wir den Vergleich",
|
"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_us": "Padelnomics Growth",
|
||||||
"sup_cmp_th_tradeshow": "Messepräsenz",
|
"sup_cmp_th_tradeshow": "Messepräsenz",
|
||||||
"sup_cmp_th_ads": "Google Ads",
|
"sup_cmp_th_ads": "Google Ads",
|
||||||
@@ -1012,7 +1012,7 @@
|
|||||||
"sup_cmp_t4": "Nie",
|
"sup_cmp_t4": "Nie",
|
||||||
"sup_cmp_m1": "Nach Kategorie gefiltert",
|
"sup_cmp_m1": "Nach Kategorie gefiltert",
|
||||||
"sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €20–80 CPC für Padel-Baukeywords bei 5–10 Klicks/Tag.",
|
"sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €20–80 CPC für Padel-Baukeywords bei 5–10 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_stat1": "erstellte Businesspläne",
|
||||||
"sup_proof_stat2": "Anbieter",
|
"sup_proof_stat2": "Anbieter",
|
||||||
"sup_proof_stat3": "Länder",
|
"sup_proof_stat3": "Länder",
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
"sup_faq_h2": "Anbieter-FAQ",
|
"sup_faq_h2": "Anbieter-FAQ",
|
||||||
"sup_faq_q1": "Wie werde ich gelistet?",
|
"sup_faq_q1": "Wie werde ich gelistet?",
|
||||||
"sup_faq_a1_pre": "Finde dein Unternehmen in unserem",
|
"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_dir_link": "Verzeichnis",
|
||||||
"sup_faq_q2": "Wie viel kostet es?",
|
"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.",
|
"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_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_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_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_cta_try_numbers": "Mit eigenen Zahlen testen →",
|
||||||
"scenario_payback_label": "Amortisation",
|
"scenario_payback_label": "Amortisation",
|
||||||
"scenario_months_unit": "Monate",
|
"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_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_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_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_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 verborgen. Wir wollten etwas mit der Tiefe eines professionellen Finanzmodells, aber der Zugänglichkeit einer Web-App.",
|
"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_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.",
|
"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_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.",
|
"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_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_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_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_01_desc": "Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.",
|
||||||
"landing_journey_02_desc": "Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.",
|
"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_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_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_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.",
|
"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_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_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_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 6–8 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 2–3 Mio. € (Neubau), mit Amortisationszeiten von 3–5 Jahren für gut gelegene Anlagen.",
|
"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 6–8 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 2–3 Mio. € (Neubau), mit Amortisationszeiten von 3–5 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_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_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.",
|
"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_f1": "Verifiziert-Badge",
|
||||||
"plan_basic_f2": "Firmenlogo",
|
"plan_basic_f2": "Firmenlogo",
|
||||||
@@ -1259,7 +1259,7 @@
|
|||||||
"billing_pricing_og_title": "Kostenloser Padel-Finanzplaner",
|
"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_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_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_card": "Finanzplaner",
|
||||||
"billing_planner_free": "Kostenlos",
|
"billing_planner_free": "Kostenlos",
|
||||||
"billing_planner_forever": "— für immer",
|
"billing_planner_forever": "— für immer",
|
||||||
@@ -1281,7 +1281,7 @@
|
|||||||
"billing_signup": "Jetzt registrieren",
|
"billing_signup": "Jetzt registrieren",
|
||||||
"billing_success_title": "Willkommen",
|
"billing_success_title": "Willkommen",
|
||||||
"billing_success_h1": "Willkommen bei Padelnomics!",
|
"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_success_btn": "Planer öffnen",
|
||||||
"billing_no_subscription": "Kein aktives Abonnement gefunden.",
|
"billing_no_subscription": "Kein aktives Abonnement gefunden.",
|
||||||
"sd_page_title": "Anbieter-Dashboard",
|
"sd_page_title": "Anbieter-Dashboard",
|
||||||
@@ -1303,11 +1303,11 @@
|
|||||||
"sd_ov_credits_balance": "Credits-Guthaben",
|
"sd_ov_credits_balance": "Credits-Guthaben",
|
||||||
"sd_ov_directory_rank": "Verzeichnis-Rang",
|
"sd_ov_directory_rank": "Verzeichnis-Rang",
|
||||||
"sd_ov_basic_plan_label": "Basic-Tarif",
|
"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_upgrade_growth": "Auf Growth upgraden",
|
||||||
"sd_ov_recent_activity": "Letzte Aktivitäten",
|
"sd_ov_recent_activity": "Letzte Aktivitäten",
|
||||||
"sd_ov_credits": "Credits",
|
"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_current_plan": "Aktueller Tarif",
|
||||||
"sd_bst_credits_month": "Credits/Monat",
|
"sd_bst_credits_month": "Credits/Monat",
|
||||||
"sd_bst_per_mo": "/Monat",
|
"sd_bst_per_mo": "/Monat",
|
||||||
@@ -1462,16 +1462,15 @@
|
|||||||
"sd_boost_verified_name": "Verifiziert-Badge",
|
"sd_boost_verified_name": "Verifiziert-Badge",
|
||||||
"sd_boost_verified_desc": "Verifiziertes Häkchen-Badge",
|
"sd_boost_verified_desc": "Verifiziertes Häkchen-Badge",
|
||||||
"sd_boost_card_color_name": "Individuelle Kartenfarbe",
|
"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_yearly": "jährlich abgerechnet zu €{price}/Jahr",
|
||||||
"sd_billing_monthly": "monatlich abgerechnet",
|
"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_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_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_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_claim_error": "Dieser Eintrag wurde bereits beansprucht oder existiert nicht.",
|
||||||
"sd_flash_listing_saved": "Eintrag erfolgreich gespeichert.",
|
"sd_flash_listing_saved": "Eintrag erfolgreich gespeichert.",
|
||||||
|
|
||||||
"bp_indoor": "Indoor",
|
"bp_indoor": "Indoor",
|
||||||
"bp_outdoor": "Outdoor",
|
"bp_outdoor": "Outdoor",
|
||||||
"bp_own": "Kauf",
|
"bp_own": "Kauf",
|
||||||
@@ -1480,49 +1479,41 @@
|
|||||||
"bp_payback_not_reached": "Nicht in 60 Monaten erreicht",
|
"bp_payback_not_reached": "Nicht in 60 Monaten erreicht",
|
||||||
"bp_months": "{n} Monate",
|
"bp_months": "{n} Monate",
|
||||||
"bp_years": "{n} Jahre",
|
"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_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_total_investment": "Gesamtinvestition",
|
||||||
"bp_lbl_equity_required": "Eigenkapitalbedarf",
|
"bp_lbl_equity_required": "Eigenkapitalbedarf",
|
||||||
"bp_lbl_year3_ebitda": "EBITDA Jahr 3",
|
"bp_lbl_year3_ebitda": "EBITDA Jahr 3",
|
||||||
"bp_lbl_irr": "IRR",
|
"bp_lbl_irr": "IRR",
|
||||||
"bp_lbl_payback_period": "Amortisationszeit",
|
"bp_lbl_payback_period": "Amortisationszeit",
|
||||||
"bp_lbl_year1_revenue": "Umsatz Jahr 1",
|
"bp_lbl_year1_revenue": "Umsatz Jahr 1",
|
||||||
|
|
||||||
"bp_lbl_item": "Position",
|
"bp_lbl_item": "Position",
|
||||||
"bp_lbl_amount": "Betrag",
|
"bp_lbl_amount": "Betrag",
|
||||||
"bp_lbl_notes": "Hinweise",
|
"bp_lbl_notes": "Hinweise",
|
||||||
"bp_lbl_total_capex": "Gesamt-CAPEX",
|
"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_equity": "Eigenkapital",
|
||||||
"bp_lbl_loan": "Darlehen",
|
"bp_lbl_loan": "Darlehen",
|
||||||
"bp_lbl_interest_rate": "Zinssatz",
|
"bp_lbl_interest_rate": "Zinssatz",
|
||||||
"bp_lbl_loan_term": "Darlehenslaufzeit",
|
"bp_lbl_loan_term": "Darlehenslaufzeit",
|
||||||
"bp_lbl_monthly_payment": "Monatliche Rate",
|
"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_ltv": "Beleihungsauslauf",
|
||||||
|
|
||||||
"bp_lbl_monthly": "Monatlich",
|
"bp_lbl_monthly": "Monatlich",
|
||||||
"bp_lbl_total_monthly_opex": "Monatlicher OPEX gesamt",
|
"bp_lbl_total_monthly_opex": "Monatlicher OPEX gesamt",
|
||||||
"bp_lbl_annual_opex": "Jahres-OPEX",
|
"bp_lbl_annual_opex": "Jahres-OPEX",
|
||||||
|
|
||||||
"bp_lbl_weighted_hourly_rate": "Gewichteter Stundensatz",
|
"bp_lbl_weighted_hourly_rate": "Gewichteter Stundensatz",
|
||||||
"bp_lbl_target_utilization": "Zielauslastung",
|
"bp_lbl_target_utilization": "Zielauslastung",
|
||||||
"bp_lbl_gross_monthly_revenue": "Monatlicher Bruttoumsatz",
|
"bp_lbl_gross_monthly_revenue": "Monatlicher Bruttoumsatz",
|
||||||
"bp_lbl_net_monthly_revenue": "Monatlicher Nettoumsatz",
|
"bp_lbl_net_monthly_revenue": "Monatlicher Nettoumsatz",
|
||||||
"bp_lbl_monthly_ebitda": "Monatliches EBITDA",
|
"bp_lbl_monthly_ebitda": "Monatliches EBITDA",
|
||||||
"bp_lbl_monthly_net_cf": "Monatlicher Netto-Cashflow",
|
"bp_lbl_monthly_net_cf": "Monatlicher Netto-Cashflow",
|
||||||
|
|
||||||
"bp_lbl_year": "Jahr",
|
"bp_lbl_year": "Jahr",
|
||||||
"bp_lbl_revenue": "Umsatz",
|
"bp_lbl_revenue": "Umsatz",
|
||||||
"bp_lbl_ebitda": "EBITDA",
|
"bp_lbl_ebitda": "EBITDA",
|
||||||
"bp_lbl_debt_service": "Schuldendienst",
|
"bp_lbl_debt_service": "Schuldendienst",
|
||||||
"bp_lbl_net_cf": "Netto-CF",
|
"bp_lbl_net_cf": "Netto-CF",
|
||||||
|
|
||||||
"bp_lbl_moic": "MOIC",
|
"bp_lbl_moic": "MOIC",
|
||||||
"bp_lbl_cash_on_cash": "Cash-on-Cash (J3)",
|
"bp_lbl_cash_on_cash": "Cash-on-Cash (J3)",
|
||||||
"bp_lbl_payback": "Amortisation",
|
"bp_lbl_payback": "Amortisation",
|
||||||
@@ -1530,116 +1521,179 @@
|
|||||||
"bp_lbl_ebitda_margin": "EBITDA-Marge",
|
"bp_lbl_ebitda_margin": "EBITDA-Marge",
|
||||||
"bp_lbl_dscr_y3": "DSCR (J3)",
|
"bp_lbl_dscr_y3": "DSCR (J3)",
|
||||||
"bp_lbl_yield_on_cost": "Rendite auf Kosten",
|
"bp_lbl_yield_on_cost": "Rendite auf Kosten",
|
||||||
|
|
||||||
"bp_lbl_month": "Monat",
|
"bp_lbl_month": "Monat",
|
||||||
"bp_lbl_opex": "OPEX",
|
"bp_lbl_opex": "OPEX",
|
||||||
"bp_lbl_debt": "Schulden",
|
"bp_lbl_debt": "Schulden",
|
||||||
"bp_lbl_cumulative": "Kumulativ",
|
"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ä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",
|
||||||
"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",
|
|
||||||
|
|
||||||
"email_magic_link_heading": "Bei {app_name} anmelden",
|
"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_body": "Hier ist dein Anmeldelink. Er läuft in {expiry_minutes} Minuten ab.",
|
||||||
"email_magic_link_btn": "Anmelden \u2192",
|
"email_magic_link_btn": "Anmelden →",
|
||||||
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
|
"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_ignore": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail ignorieren.",
|
||||||
"email_magic_link_subject": "Dein Anmeldelink f\u00fcr {app_name}",
|
"email_magic_link_subject": "Dein Anmeldelink für {app_name}",
|
||||||
"email_magic_link_preheader": "Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab",
|
"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_heading": "Best\u00e4tige deine E-Mail f\u00fcr Angebote",
|
|
||||||
"email_quote_verify_greeting": "Hallo {first_name},",
|
"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_project_label": "Dein Projekt:",
|
||||||
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt behandelt.",
|
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt bearbeitet.",
|
||||||
"email_quote_verify_btn": "Best\u00e4tigen & Aktivieren \u2192",
|
"email_quote_verify_btn": "Bestätigen & Aktivieren →",
|
||||||
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
|
"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_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_ignore": "Falls 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_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": "Ein Klick, um deine Angebotsanfrage zu aktivieren",
|
||||||
"email_quote_verify_preheader_courts": "Ein Klick, um dein {court_count}-Court-Projekt 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_heading": "Willkommen bei {app_name}",
|
||||||
"email_welcome_greeting": "Hallo {first_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_quickstart_heading": "Schnellstart:",
|
||||||
"email_welcome_link_planner": "Finanzplaner \u2014 modelliere deine Investition",
|
"email_welcome_link_planner": "Finanzplaner — modelliere deine Investition",
|
||||||
"email_welcome_link_markets": "Marktdaten \u2014 erkunde die Padel-Nachfrage nach Stadt",
|
"email_welcome_link_markets": "Marktdaten — erkunde die Padel-Nachfrage nach Stadt",
|
||||||
"email_welcome_link_quotes": "Angebote einholen \u2014 verbinde dich mit verifizierten Anbietern",
|
"email_welcome_link_quotes": "Angebote einholen — verbinde dich mit verifizierten Anbietern",
|
||||||
"email_welcome_btn": "Jetzt planen \u2192",
|
"email_welcome_btn": "Jetzt planen →",
|
||||||
"email_welcome_subject": "Du bist dabei \u2014 so f\u00e4ngst du an",
|
"email_welcome_subject": "Du bist dabei — so fängst Du an",
|
||||||
"email_welcome_preheader": "Dein Padel-Planungstoolkit ist bereit",
|
"email_welcome_preheader": "Dein Padel-Planungstoolkit ist bereit",
|
||||||
|
|
||||||
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
|
"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_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\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
|
"email_waitlist_supplier_perks_intro": "Als frühes Wartelisten-Mitglied erhältst du:",
|
||||||
"email_waitlist_supplier_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
|
"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_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_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
|
||||||
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
|
"email_waitlist_supplier_link_planner": "Finanzplanungstool — plane deine Padel-Anlage",
|
||||||
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
|
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis — verifizierte Anbieter durchsuchen",
|
||||||
"email_waitlist_supplier_subject": "Du bist dabei \u2014 {plan_name} fr\u00fcher Zugang kommt",
|
"email_waitlist_supplier_subject": "Du bist dabei — {plan_name} früher Zugang kommt",
|
||||||
"email_waitlist_supplier_preheader": "Exklusive Launch-Preise + bevorzugtes Onboarding",
|
"email_waitlist_supplier_preheader": "Exklusive Launch-Preise + bevorzugtes Onboarding",
|
||||||
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
|
"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_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\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
|
"email_waitlist_general_perks_intro": "Als frühes Wartelisten-Mitglied erhältst du:",
|
||||||
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
|
"email_waitlist_general_perk_1": "Frühen Zugang vor dem öffentlichen Launch",
|
||||||
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
|
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
|
||||||
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
|
"email_waitlist_general_perk_3": "Prioritäts-Onboarding und Support",
|
||||||
"email_waitlist_general_outro": "Wir melden uns bald.",
|
"email_waitlist_general_outro": "Wir melden uns in Kürze.",
|
||||||
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 wir benachrichtigen dich zum Launch",
|
"email_waitlist_general_subject": "Du stehst auf der Liste — wir benachrichtigen dich zum Launch",
|
||||||
"email_waitlist_general_preheader": "Fr\u00fcher Zugang + exklusive Launch-Preise",
|
"email_waitlist_general_preheader": "Früher Zugang + exklusive Launch-Preise",
|
||||||
|
|
||||||
"email_lead_forward_heading": "Neues Projekt-Lead",
|
"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_brief": "Projektbeschreibung",
|
||||||
"email_lead_forward_section_contact": "Kontakt",
|
"email_lead_forward_section_contact": "Kontakt",
|
||||||
"email_lead_forward_lbl_facility": "Anlage",
|
"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_location": "Standort",
|
||||||
"email_lead_forward_lbl_timeline": "Zeitplan",
|
"email_lead_forward_lbl_timeline": "Zeitplan",
|
||||||
"email_lead_forward_lbl_phase": "Phase",
|
"email_lead_forward_lbl_phase": "Phase",
|
||||||
"email_lead_forward_lbl_services": "Leistungen",
|
"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_name": "Name",
|
||||||
"email_lead_forward_lbl_email": "E-Mail",
|
"email_lead_forward_lbl_email": "E-Mail",
|
||||||
"email_lead_forward_lbl_phone": "Telefon",
|
"email_lead_forward_lbl_phone": "Telefon",
|
||||||
"email_lead_forward_lbl_company": "Unternehmen",
|
"email_lead_forward_lbl_company": "Unternehmen",
|
||||||
"email_lead_forward_lbl_role": "Rolle",
|
"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_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_forward_preheader_suffix": "Kontaktdaten enthalten",
|
||||||
|
"email_lead_matched_heading": "Ein Anbieter möchte dein Projekt besprechen",
|
||||||
"email_lead_matched_heading": "Ein Anbieter m\u00f6chte dein Projekt besprechen",
|
|
||||||
"email_lead_matched_greeting": "Hallo {first_name},",
|
"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_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\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
|
"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\u00e4chstes",
|
"email_lead_matched_next_heading": "Was passiert als Nächstes",
|
||||||
"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_next_body": "Der Anbieter hat Dein Projektbriefing und Deine Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 24–48 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_tip": "Tipp: Wer schnell auf Anbieter-Kontaktaufnahmen reagiert, erhöht seine Chancen auf wettbewerbsfähige Angebote.",
|
||||||
"email_lead_matched_btn": "Zum Dashboard \u2192",
|
"email_lead_matched_btn": "Zum Dashboard →",
|
||||||
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
|
"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\u00f6chte dein Projekt besprechen",
|
"email_lead_matched_subject": "{first_name}, ein Anbieter möchte dein Projekt besprechen",
|
||||||
"email_lead_matched_preheader": "Der Anbieter wird sich direkt bei dir melden \u2014 das erwartet dich",
|
"email_lead_matched_preheader": "Der Anbieter meldet sich direkt bei Dir — das erwartet Dich",
|
||||||
|
|
||||||
"email_enquiry_heading": "Neue Anfrage von {contact_name}",
|
"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_from": "Von",
|
||||||
"email_enquiry_lbl_message": "Nachricht",
|
"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_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_subject": "Neue Anfrage von {contact_name} über deinen Verzeichniseintrag",
|
||||||
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu treten",
|
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu kommen",
|
||||||
|
|
||||||
"email_business_plan_heading": "Dein Businessplan ist fertig",
|
"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_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_includes": "Dein Plan enthält Investitionsübersicht, Umsatzprognosen und Break-Even-Analyse.",
|
||||||
"email_business_plan_btn": "PDF herunterladen \u2192",
|
"email_business_plan_btn": "PDF herunterladen →",
|
||||||
"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_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_subject": "Dein Businessplan-PDF steht zum Download bereit",
|
||||||
"email_business_plan_preheader": "Professioneller Padel-Finanzplan \u2014 jetzt herunterladen",
|
"email_business_plan_preheader": "Professioneller Padel-Finanzplan — jetzt herunterladen",
|
||||||
|
"email_footer_tagline": "Die Planungsplattform für Padel-Unternehmer",
|
||||||
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
|
"email_footer_copyright": "© {year} {app_name}. Du erhältst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.",
|
||||||
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst 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 (0–100), 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": "70–100: 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": "45–69: 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 20–35 €/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": "70–100: 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": "45–69: 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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1471,7 +1471,6 @@
|
|||||||
"sd_flash_valid_email": "Please enter a valid email address.",
|
"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_claim_error": "This listing has already been claimed or does not exist.",
|
||||||
"sd_flash_listing_saved": "Listing saved successfully.",
|
"sd_flash_listing_saved": "Listing saved successfully.",
|
||||||
|
|
||||||
"bp_indoor": "Indoor",
|
"bp_indoor": "Indoor",
|
||||||
"bp_outdoor": "Outdoor",
|
"bp_outdoor": "Outdoor",
|
||||||
"bp_own": "Own",
|
"bp_own": "Own",
|
||||||
@@ -1480,24 +1479,20 @@
|
|||||||
"bp_payback_not_reached": "Not reached in 60 months",
|
"bp_payback_not_reached": "Not reached in 60 months",
|
||||||
"bp_months": "{n} months",
|
"bp_months": "{n} months",
|
||||||
"bp_years": "{n} years",
|
"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_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_total_investment": "Total Investment",
|
||||||
"bp_lbl_equity_required": "Equity Required",
|
"bp_lbl_equity_required": "Equity Required",
|
||||||
"bp_lbl_year3_ebitda": "Year 3 EBITDA",
|
"bp_lbl_year3_ebitda": "Year 3 EBITDA",
|
||||||
"bp_lbl_irr": "IRR",
|
"bp_lbl_irr": "IRR",
|
||||||
"bp_lbl_payback_period": "Payback Period",
|
"bp_lbl_payback_period": "Payback Period",
|
||||||
"bp_lbl_year1_revenue": "Year 1 Revenue",
|
"bp_lbl_year1_revenue": "Year 1 Revenue",
|
||||||
|
|
||||||
"bp_lbl_item": "Item",
|
"bp_lbl_item": "Item",
|
||||||
"bp_lbl_amount": "Amount",
|
"bp_lbl_amount": "Amount",
|
||||||
"bp_lbl_notes": "Notes",
|
"bp_lbl_notes": "Notes",
|
||||||
"bp_lbl_total_capex": "Total CAPEX",
|
"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_equity": "Equity",
|
||||||
"bp_lbl_loan": "Loan",
|
"bp_lbl_loan": "Loan",
|
||||||
"bp_lbl_interest_rate": "Interest Rate",
|
"bp_lbl_interest_rate": "Interest Rate",
|
||||||
@@ -1505,24 +1500,20 @@
|
|||||||
"bp_lbl_monthly_payment": "Monthly Payment",
|
"bp_lbl_monthly_payment": "Monthly Payment",
|
||||||
"bp_lbl_annual_debt_service": "Annual Debt Service",
|
"bp_lbl_annual_debt_service": "Annual Debt Service",
|
||||||
"bp_lbl_ltv": "Loan-to-Value",
|
"bp_lbl_ltv": "Loan-to-Value",
|
||||||
|
|
||||||
"bp_lbl_monthly": "Monthly",
|
"bp_lbl_monthly": "Monthly",
|
||||||
"bp_lbl_total_monthly_opex": "Total Monthly OPEX",
|
"bp_lbl_total_monthly_opex": "Total Monthly OPEX",
|
||||||
"bp_lbl_annual_opex": "Annual OPEX",
|
"bp_lbl_annual_opex": "Annual OPEX",
|
||||||
|
|
||||||
"bp_lbl_weighted_hourly_rate": "Weighted Hourly Rate",
|
"bp_lbl_weighted_hourly_rate": "Weighted Hourly Rate",
|
||||||
"bp_lbl_target_utilization": "Target Utilization",
|
"bp_lbl_target_utilization": "Target Utilization",
|
||||||
"bp_lbl_gross_monthly_revenue": "Gross Monthly Revenue",
|
"bp_lbl_gross_monthly_revenue": "Gross Monthly Revenue",
|
||||||
"bp_lbl_net_monthly_revenue": "Net Monthly Revenue",
|
"bp_lbl_net_monthly_revenue": "Net Monthly Revenue",
|
||||||
"bp_lbl_monthly_ebitda": "Monthly EBITDA",
|
"bp_lbl_monthly_ebitda": "Monthly EBITDA",
|
||||||
"bp_lbl_monthly_net_cf": "Monthly Net Cash Flow",
|
"bp_lbl_monthly_net_cf": "Monthly Net Cash Flow",
|
||||||
|
|
||||||
"bp_lbl_year": "Year",
|
"bp_lbl_year": "Year",
|
||||||
"bp_lbl_revenue": "Revenue",
|
"bp_lbl_revenue": "Revenue",
|
||||||
"bp_lbl_ebitda": "EBITDA",
|
"bp_lbl_ebitda": "EBITDA",
|
||||||
"bp_lbl_debt_service": "Debt Service",
|
"bp_lbl_debt_service": "Debt Service",
|
||||||
"bp_lbl_net_cf": "Net CF",
|
"bp_lbl_net_cf": "Net CF",
|
||||||
|
|
||||||
"bp_lbl_moic": "MOIC",
|
"bp_lbl_moic": "MOIC",
|
||||||
"bp_lbl_cash_on_cash": "Cash-on-Cash (Y3)",
|
"bp_lbl_cash_on_cash": "Cash-on-Cash (Y3)",
|
||||||
"bp_lbl_payback": "Payback",
|
"bp_lbl_payback": "Payback",
|
||||||
@@ -1530,46 +1521,40 @@
|
|||||||
"bp_lbl_ebitda_margin": "EBITDA Margin",
|
"bp_lbl_ebitda_margin": "EBITDA Margin",
|
||||||
"bp_lbl_dscr_y3": "DSCR (Y3)",
|
"bp_lbl_dscr_y3": "DSCR (Y3)",
|
||||||
"bp_lbl_yield_on_cost": "Yield on Cost",
|
"bp_lbl_yield_on_cost": "Yield on Cost",
|
||||||
|
|
||||||
"bp_lbl_month": "Month",
|
"bp_lbl_month": "Month",
|
||||||
"bp_lbl_opex": "OPEX",
|
"bp_lbl_opex": "OPEX",
|
||||||
"bp_lbl_debt": "Debt",
|
"bp_lbl_debt": "Debt",
|
||||||
"bp_lbl_cumulative": "Cumulative",
|
"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. © Padelnomics — 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. \u00a9 Padelnomics \u2014 padelnomics.io",
|
|
||||||
|
|
||||||
"email_magic_link_heading": "Sign in to {app_name}",
|
"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_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_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_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_subject": "Your sign-in link for {app_name}",
|
||||||
"email_magic_link_preheader": "This link expires in {expiry_minutes} minutes",
|
"email_magic_link_preheader": "This link expires in {expiry_minutes} minutes",
|
||||||
|
|
||||||
"email_quote_verify_heading": "Verify your email to get quotes",
|
"email_quote_verify_heading": "Verify your email to get quotes",
|
||||||
"email_quote_verify_greeting": "Hi {first_name},",
|
"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_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_project_label": "Your project:",
|
||||||
"email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.",
|
"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_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_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_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": "One click to activate your quote request",
|
||||||
"email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project",
|
"email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project",
|
||||||
|
|
||||||
"email_welcome_heading": "Welcome to {app_name}",
|
"email_welcome_heading": "Welcome to {app_name}",
|
||||||
"email_welcome_greeting": "Hi {first_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_quickstart_heading": "Quick start:",
|
||||||
"email_welcome_link_planner": "Financial Planner \u2014 model your investment",
|
"email_welcome_link_planner": "Financial Planner — model your investment",
|
||||||
"email_welcome_link_markets": "Market Data \u2014 explore padel demand by city",
|
"email_welcome_link_markets": "Market Data — explore padel demand by city",
|
||||||
"email_welcome_link_quotes": "Get Quotes \u2014 connect with verified suppliers",
|
"email_welcome_link_quotes": "Get Quotes — connect with verified suppliers",
|
||||||
"email_welcome_btn": "Start Planning \u2192",
|
"email_welcome_btn": "Start Planning →",
|
||||||
"email_welcome_subject": "You're in \u2014 here's how to 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_welcome_preheader": "Your padel business planning toolkit is ready",
|
||||||
|
|
||||||
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
|
"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_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:",
|
"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_2": "Exclusive launch pricing (locked in)",
|
||||||
"email_waitlist_supplier_perk_3": "Dedicated onboarding call",
|
"email_waitlist_supplier_perk_3": "Dedicated onboarding call",
|
||||||
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
|
"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_planner": "Financial Planning Tool — model your padel facility",
|
||||||
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
|
"email_waitlist_supplier_link_directory": "Supplier Directory — browse verified suppliers",
|
||||||
"email_waitlist_supplier_subject": "You're in \u2014 {plan_name} early access is coming",
|
"email_waitlist_supplier_subject": "You're in — {plan_name} early access is coming",
|
||||||
"email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding",
|
"email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding",
|
||||||
"email_waitlist_general_heading": "You're on the Waitlist",
|
"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_perks_intro": "As an early waitlist member, you'll get:",
|
||||||
"email_waitlist_general_perk_1": "Early access before public launch",
|
"email_waitlist_general_perk_1": "Early access before public launch",
|
||||||
"email_waitlist_general_perk_2": "Exclusive launch pricing",
|
"email_waitlist_general_perk_2": "Exclusive launch pricing",
|
||||||
"email_waitlist_general_perk_3": "Priority onboarding and support",
|
"email_waitlist_general_perk_3": "Priority onboarding and support",
|
||||||
"email_waitlist_general_outro": "We'll be in touch soon.",
|
"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_waitlist_general_preheader": "Early access + exclusive launch pricing",
|
||||||
|
|
||||||
"email_lead_forward_heading": "New Project Lead",
|
"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_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",
|
"email_lead_forward_section_brief": "Project Brief",
|
||||||
@@ -1607,22 +1591,20 @@
|
|||||||
"email_lead_forward_lbl_phone": "Phone",
|
"email_lead_forward_lbl_phone": "Phone",
|
||||||
"email_lead_forward_lbl_company": "Company",
|
"email_lead_forward_lbl_company": "Company",
|
||||||
"email_lead_forward_lbl_role": "Role",
|
"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_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_forward_preheader_suffix": "contact details inside",
|
||||||
|
|
||||||
"email_lead_matched_heading": "A supplier wants to discuss your project",
|
"email_lead_matched_heading": "A supplier wants to discuss your project",
|
||||||
"email_lead_matched_greeting": "Hi {first_name},",
|
"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_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_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 24–48 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_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_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_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_heading": "New enquiry from {contact_name}",
|
||||||
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
|
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
|
||||||
"email_enquiry_lbl_from": "From",
|
"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_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_subject": "New enquiry from {contact_name} via your directory listing",
|
||||||
"email_enquiry_preheader": "Reply to connect with this potential client",
|
"email_enquiry_preheader": "Reply to connect with this potential client",
|
||||||
|
|
||||||
"email_business_plan_heading": "Your business plan is ready",
|
"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_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_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.",
|
||||||
"email_business_plan_btn": "Download PDF \u2192",
|
"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 \u2192</a>",
|
"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_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_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 (0–100) 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, it’s 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": "70–100: Strong market",
|
||||||
|
"mscore_band_high_p": "Large population, economic power, and proven demand from existing venues. These cities have validated padel markets with reliable benchmarks for financial planning.",
|
||||||
|
"mscore_band_mid_label": "45–69: 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 city’s score low?",
|
||||||
|
"mscore_faq_a3": "Usually because of limited data coverage or smaller population. A low score doesn’t 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 €20–35/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": "70–100: 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": "45–69: 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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ Design decisions
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -89,7 +92,7 @@ def migrate(db_path=None):
|
|||||||
|
|
||||||
if pending:
|
if pending:
|
||||||
for name in pending:
|
for name in pending:
|
||||||
print(f" Applying {name}...")
|
logger.info("Applying %s...", name)
|
||||||
mod = importlib.import_module(
|
mod = importlib.import_module(
|
||||||
f"padelnomics.migrations.versions.{name}"
|
f"padelnomics.migrations.versions.{name}"
|
||||||
)
|
)
|
||||||
@@ -98,9 +101,9 @@ def migrate(db_path=None):
|
|||||||
"INSERT INTO _migrations (name) VALUES (?)", (name,)
|
"INSERT INTO _migrations (name) VALUES (?)", (name,)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print(f"✓ Applied {len(pending)} migration(s): {db_path}")
|
logger.info("Applied %s migration(s): %s", len(pending), db_path)
|
||||||
else:
|
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)
|
# Show tables (excluding internal sqlite/fts tables)
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -109,10 +112,11 @@ def migrate(db_path=None):
|
|||||||
" ORDER BY name"
|
" ORDER BY name"
|
||||||
)
|
)
|
||||||
tables = [row[0] for row in cursor.fetchall()]
|
tables = [row[0] for row in cursor.fetchall()]
|
||||||
print(f" Tables: {', '.join(tables)}")
|
logger.info("Tables: %s", ", ".join(tables))
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
|
||||||
migrate()
|
migrate()
|
||||||
|
|||||||
@@ -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
|
||||||
|
""")
|
||||||
@@ -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)"
|
||||||
|
)
|
||||||
@@ -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 '[]'"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -3,10 +3,12 @@ Planner domain: padel court financial planner + scenario management.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from quart import Blueprint, Response, g, jsonify, render_template, request
|
from quart import Blueprint, Response, g, jsonify, render_template, request
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
from ..auth.routes import login_required
|
||||||
@@ -18,6 +20,7 @@ from ..core import (
|
|||||||
fetch_all,
|
fetch_all,
|
||||||
fetch_one,
|
fetch_one,
|
||||||
get_paddle_price,
|
get_paddle_price,
|
||||||
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
from ..i18n import get_translations
|
from ..i18n import get_translations
|
||||||
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
|
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
|
||||||
@@ -502,7 +505,7 @@ async def save_scenario():
|
|||||||
location = form.get("location", "")
|
location = form.get("location", "")
|
||||||
scenario_id = form.get("scenario_id")
|
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
|
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:
|
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")
|
lang = g.get("lang", "en")
|
||||||
t = get_translations(lang)
|
t = get_translations(lang)
|
||||||
@@ -563,7 +566,7 @@ async def get_scenario(scenario_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def delete_scenario(scenario_id: int):
|
async def delete_scenario(scenario_id: int):
|
||||||
now = datetime.utcnow().isoformat()
|
now = utcnow_iso()
|
||||||
await execute(
|
await execute(
|
||||||
"UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
"UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||||
(now, scenario_id, g.user["id"]),
|
(now, scenario_id, g.user["id"]),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
<button type="submit" class="btn" style="width:100%;margin-top:0.5rem" id="export-buy-btn">
|
<button type="submit" class="btn" style="width:100%;margin-top:0.5rem" id="export-buy-btn">
|
||||||
{{ t.export_btn }}
|
{{ t.export_btn }}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,9 +107,16 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<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) {
|
document.getElementById('export-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.getElementById('export-buy-btn');
|
const btn = document.getElementById('export-buy-btn');
|
||||||
|
document.getElementById('export-error').style.display = 'none';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '{{ t.export_generating }}';
|
btn.textContent = '{{ t.export_generating }}';
|
||||||
|
|
||||||
@@ -120,7 +128,7 @@ document.getElementById('export-form').addEventListener('submit', async function
|
|||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert(data.error);
|
showExportError(data.error);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '{{ t.export_btn }}';
|
btn.textContent = '{{ t.export_btn }}';
|
||||||
return;
|
return;
|
||||||
@@ -133,7 +141,7 @@ document.getElementById('export-form').addEventListener('submit', async function
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '{{ t.export_btn }}';
|
btn.textContent = '{{ t.export_btn }}';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('{{ t.export_failed }}');
|
showExportError('{{ t.export_failed }}');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '{{ t.export_btn }}';
|
btn.textContent = '{{ t.export_btn }}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ async def about():
|
|||||||
return await render_template("about.html")
|
return await render_template("about.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/market-score")
|
||||||
|
async def market_score():
|
||||||
|
return await render_template("market_score.html")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/imprint")
|
@bp.route("/imprint")
|
||||||
async def imprint():
|
async def imprint():
|
||||||
lang = g.get("lang", "en")
|
lang = g.get("lang", "en")
|
||||||
|
|||||||
265
web/src/padelnomics/public/templates/market_score.html
Normal file
265
web/src/padelnomics/public/templates/market_score.html
Normal 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">👥</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">💶</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">📈</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">🔍</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">📊</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">💶</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">🎯</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">📍</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">🎾</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 %}
|
||||||
@@ -34,12 +34,15 @@ Fields mapped (DuckDB → data_json camelCase key):
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
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)
|
path = Path(DUCKDB_PATH)
|
||||||
if not path.exists():
|
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 {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import duckdb
|
import duckdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print(" [analytics] duckdb not installed — skipping analytics refresh.")
|
logger.warning("duckdb not installed — skipping analytics refresh.")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
result: dict[str, dict] = {}
|
result: dict[str, dict] = {}
|
||||||
@@ -98,7 +101,7 @@ def _load_analytics(city_slugs: list[str]) -> dict[str, dict]:
|
|||||||
result[slug] = overrides
|
result[slug] = overrides
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f" [analytics] DuckDB query failed: {exc}")
|
logger.error("DuckDB query failed: %s", exc)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -124,13 +127,13 @@ def refresh(dry_run: bool = False) -> int:
|
|||||||
city_slug_to_ids.setdefault(slug, []).append(row["id"])
|
city_slug_to_ids.setdefault(slug, []).append(row["id"])
|
||||||
|
|
||||||
if not city_slug_to_ids:
|
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()
|
conn.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
analytics = _load_analytics(list(city_slug_to_ids.keys()))
|
analytics = _load_analytics(list(city_slug_to_ids.keys()))
|
||||||
if not analytics:
|
if not analytics:
|
||||||
print("No analytics data found — nothing to update.")
|
logger.info("No analytics data found — nothing to update.")
|
||||||
conn.close()
|
conn.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -154,13 +157,13 @@ def refresh(dry_run: bool = False) -> int:
|
|||||||
|
|
||||||
data.update(overrides)
|
data.update(overrides)
|
||||||
if dry_run:
|
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:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE template_data SET data_json = ?, updated_at = datetime('now') WHERE id = ?",
|
"UPDATE template_data SET data_json = ?, updated_at = datetime('now') WHERE id = ?",
|
||||||
(json.dumps(data), row_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
|
updated += 1
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
@@ -184,7 +187,7 @@ def _trigger_generation() -> None:
|
|||||||
headers={"X-Admin-Key": admin_key},
|
headers={"X-Admin-Key": admin_key},
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
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:
|
def main() -> None:
|
||||||
@@ -195,14 +198,17 @@ def main() -> None:
|
|||||||
help="Trigger article re-generation after updating")
|
help="Trigger article re-generation after updating")
|
||||||
args = parser.parse_args()
|
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)
|
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:
|
if args.generate and count > 0 and not args.dry_run:
|
||||||
print("Triggering article generation…")
|
logger.info("Triggering article generation...")
|
||||||
_trigger_generation()
|
_trigger_generation()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
|
||||||
main()
|
main()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user