This commit is contained in:
Deeman
2026-02-19 19:16:23 +01:00
parent 781281f9bc
commit b108a53ef3
57 changed files with 1203 additions and 1048 deletions

View File

@@ -1,5 +1,5 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: v0.2.0 _commit: 29ac25b
_src_path: /home/Deeman/Projects/quart_saas_boilerplate _src_path: /home/Deeman/Projects/quart_saas_boilerplate
author_email: '' author_email: ''
author_name: '' author_name: ''

View File

@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added
- SEO defaults in `base.html`: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed
- `robots.txt` route: disallows `/admin/`, `/auth/`, `/dashboard/`, `/billing/`, `/directory/results`; includes Sitemap reference
- Meta descriptions and OG tags on all public pages: about, terms, privacy, pricing, features, suppliers, directory, supplier detail
- `og:image` on landing and features pages pointing to planner screenshot
- JSON-LD Organization schema on homepage and supplier detail pages
- JSON-LD FAQPage schema on homepage (5 FAQ entries)
- JSON-LD Article schema on article detail pages
- Sitemap supplier slugs: all supplier detail pages now indexed
- Sitemap `<lastmod>` on all entries (static pages: today, articles: `COALESCE(updated_at, published_at)`, suppliers: `created_at`)
- `rel="preconnect"` for Google Fonts to reduce font-load latency
- `X-Robots-Tag: noindex` header on `/directory/results` HTMX partial
### Fixed
- Render-blocking Paddle.js: added `defer` attribute and wrapped `Paddle.Initialize` in `DOMContentLoaded` listener
- Render-blocking Chart.js on planner page: added `defer` attribute
- Broken `og:image` on planner page (`og-planner.png``planner-screenshot.png`)
- Homepage meta description trimmed to under 155 characters
- Duplicate `og:url` and `canonical` tags removed from landing, markets, and article detail pages
### Changed
- Rewrote supplier page hero: pain-first headline ("Stop Chasing Cold Leads"), differentiator copy, micro-proof under CTA
- Added "Problem With Finding Padel Clients Today" section with trade show / Google Ads / cold outreach pain points
- Tightened "How It Works" step titles: "Claim Your Listing", "Browse Pre-Qualified Leads", "Win Projects Faster"
- Added Basic tier (€39/mo / €29/mo yearly) to public pricing section on suppliers page
- Pricing section now shows 3-column grid (Basic / Growth / Pro) with CSS-only billing period toggle (monthly / yearly, no JS)
- Added "How We Compare" table: Padelnomics vs trade show / Google Ads / cold directory
- Upgraded social proof section: two testimonial cards with decorative quote mark, stat bar in subtitle
- Updated supplier FAQ: three tiers with correct prices (€39/€199/€499), two new entries (what makes leads different, pricing vs alternatives), removed stale €149/€399 figures
- Strengthened final CTA: "Your Next Client Is Already Building a Business Plan"
- Fixed stale prices in landing page FAQ: Basic €39/mo, Growth €199/mo, Pro €499/mo (was €149/€399)
### Changed ### Changed
- Redesigned directory cards: image-first 16:9 layout with cover photos, frosted category badge, CSS court/grid placeholders, and logo avatar straddling the media/body border - Redesigned directory cards: image-first 16:9 layout with cover photos, frosted category badge, CSS court/grid placeholders, and logo avatar straddling the media/body border
- 4-tier visual ladder: Free (62% opacity, grey placeholder, unverified chip) → Basic (full opacity, green placeholder, verified chip, description, "View Listing →") → Growth (green placeholder, Growth chip + stats, "Request Quote →") → Pro (green 2.5px top border, CSS court media, full stats, green hover glow) - 4-tier visual ladder: Free (62% opacity, grey placeholder, unverified chip) → Basic (full opacity, green placeholder, verified chip, description, "View Listing →") → Growth (green placeholder, Growth chip + stats, "Request Quote →") → Pro (green 2.5px top border, CSS court media, full stats, green hover glow)

View File

@@ -1,575 +0,0 @@
# Global Padel Court Suppliers & Builders Directory
**200+ companies across 30+ countries** — court manufacturers, hall builders, turf suppliers, lighting specialists, booking software, and consultants. The padel court market reached ~$163M in 2023 and is projected to hit $592M by 2031. This directory is organized by region, with Germany as the priority market.
---
## 1. GERMANY
Germany's padel market is growing 60%+ annually, with 100+ facilities now operating. A wave of domestic manufacturers has emerged since 2020.
### 1.1 German-Headquartered Court Manufacturers
| Company | City/Region | Website | Contact | Description |
|---------|------------|---------|---------|-------------|
| **artec Sportgeräte GmbH** | Melle, Lower Saxony | artec-sportgeraete.de | Via contact form | 30+ years sports equipment. 12mm laminated safety glass, DIN-standard statics, customizable colors. Indoor & outdoor. |
| **PADELWERK Court GmbH** | Dortmund | padelwerk.de | +49 172 54 60 150 | "Germany's first padel court manufacturer" (est. 2021). All components made in Germany. Basic and luxury versions, LED lighting, roofing, financing consulting. |
| **Vindico Sport GmbH** | Germany | vindico-sport.de | Via website | Serie B and Pano models. Carbon-steel S235JRH, 1012mm tempered glass, 9-stage corrosion coating, WPT-standard entrances, stainless steel A4 connectors. |
| **Kübler Sport GmbH** | Backnang, Baden-Württemberg (71522) | kuebler-sport.de | info@kuebler-sport.de | Major sports supplier. CLASSIC and PANORAMA courts with steel structure, tempered glass, wire mesh, artificial turf, 8 LED elements. In-house installation teams. |
| **Padello GmbH** | Troisdorf, NRW | padello.de | Via contact form | Full-service builder with own metalworking shop. Steel construction up to 5mm strength adapted to regional wind loads. DIN-compliant statics, DEKRA-certified noise assessment. |
| **LOB Sport (BECO Bermüller)** | Nuremberg, Bavaria (90451) | lobsport.de / beco-bermueller.de | +49 911 64200-0 | 40+ years in tennis equipment, now padel. "Edge" and "Infinity" models. Foundation-free option for converting sand courts. Part of BECO Bermüller group. |
| **Brako Padel GmbH** | Berlin | brakopadel.com | Via contact form | Padel specialist since 2013. 15-year corrosion warranty. Indoor, outdoor, panoramic, and portable courts. No-foundation portable options. |
| **Padel Concept** | Germany (DACH) | padelconcept.de | Via website | Production in Baltic states, serves DACH/Scandinavia/BeNeLux. Own assembly teams. Innovative mobile floor plate solution. Full statics and noise protection reports. |
| **Padelsportanlagenbau** | Germany | padelsportanlagenbau.de | Via contact form | Manufacturer & turnkey builder, active in 18+ countries. |
### 1.2 Turnkey Solution Providers & Consultants (Germany)
| Company | City/Region | Website | Contact | Description |
|---------|------------|---------|---------|-------------|
| **The Court Company** | Cologne, NRW | courtcompany.de | +49 2237 6034685 | "Germany's most experienced padel construction expert." 40+ courts in 2021. Licensed partner of AFP Courts, RedSport, adidas courts. Full service plus leasing and mobile courts. |
| **Real Padel GmbH** | Schönebeck, Saxony-Anhalt (39218) | realpadel.de / padel-court.de | Schornsteinfeger Str. 3, 39218 Schönebeck | Premium courts and full-service club consulting. Booking systems, automation, equipment, sponsor connections. Also brokers connecting DACH customers with top manufacturers. |
| **Padel4U (P4U)** | Germany | padel4u.de | Via website | German distributor for Manzasport. Full range including standard, panoramic, and custom courts. ~1,000 courts/year through partnership. |
| **Trendsport Rummenigge** | Germany | trendsport-rummenigge.de | Via website | Turnkey solutions from concept to completion. Guidance on German funding programs (Sportstättenförderung). |
| **PadelCity** | Multiple (21+ locations) | padelcity.de | Via contact form | Germany's largest padel operator (20 facilities, 100+ courts). DTB partner. Proprietary booking app. Franchise model. |
| **padelBOX** | Multiple locations | padelbox.de | r.stroehl@padelbox.de | Major German padel operator and consultant. |
| **Padel Solution** | Germany | padelsolution.de | Via website | Supplier, planner, autonomous club solutions. |
| **Best World Padel** | Denmark/Germany | Via website | Via website | Official MejorSet distributor for Germany and Denmark. |
### 1.3 Padel Hall & Building Constructors (Germany)
| Company | HQ | Website | Contact | Description |
|---------|------|---------|---------|-------------|
| **SMC2 Bau** | France/Germany | smc2-bau.de | Via website | Textile membrane sport halls — glass court walls serve as lower facade with membrane roof above. Meets German building norms. 10-year guarantee. |
| **Padberg Projektbau** | Germany | padberg-projektbau.de | Via website | Turnkey padel hall specialist. Expertise in minimum 67m ceiling height, glass wall statics, LED lighting. Assists with permits and grant funding. |
| **Planeco Building** | Germany | planecobuilding.de | Via website | Building permit consultancy for converting warehouses/halls into padel facilities. Fire safety, structural analysis, change-of-use permits. |
| **BORGA** | Sweden/Austria | borga.at | Via website | 45+ years building steel halls. 3 standardized padel hall solutions plus custom. Sandwich panels, mezzanine floors, court layout optimization. |
### 1.4 International Manufacturers with Strong German Presence
| Company | HQ | Website | Contact | Description |
|---------|------|---------|---------|-------------|
| **AFP Courts / adidas Padel Courts** | Barcelona, Spain | afpcourts.com | Via website | 18+ years, 4,000+ courts in 54+ countries. Official adidas licensee. German distribution via The Court Company. |
| **Portico Sport** | Spain | porticosport.com/germany | Via website | Leading European builder. Installed at TSG Augsburg, TC Degerloch Stuttgart, and more. Turnkey with canopies. |
| **Padelcreations** | Spain (DACH-active) | padelcreations.com | +34 965 049 221 / info@padelcreations.com | 24+ courts in DACH region. 10 court models. Certified Spanish assembly teams. |
| **MejorSet (LeDap Group)** | Alicante, Spain | mejorset.com | +34 966 374 289 / info@mejorset.com | Official court of Premier Padel and FIP. 20+ years, pioneer of panoramic design. Present in Germany via LeDap / Best World Padel. |
| **Manzasport** | Valencia, Spain | manzasport.com | +34 963 217 472 / info@manzasport.com | Top-4 global builder, 15+ years. German distribution through Padel4U. Multiple court models. |
| **Courtwall (The Padelcourt Biz)** | Vienna, Austria | padelcourt.biz | Via website | Building courts since 1984 (squash) and padel since 2007. 1,200+ courts worldwide. ISO-certified. |
| **UnixPadel** | Istanbul, Turkey | unixpadel.com | Via website | 7,800+ courts globally. FIP-standard, KIWA-tested. Up to 12 courts/day production. TÜV-tested components. |
| **The Padel Lab** | International | thepadellab.com | Via website | HD Vision courts, tournament-grade panoramic, modular steel frame technology. Claims 40% reduction in construction time. |
### 1.5 Turf, Lighting, Software & Accessories (Germany)
| Company | Type | Website | Contact | Description |
|---------|------|---------|---------|-------------|
| **Mondo S.p.A.** | Turf | mondoworldwide.com | Via website | World's leading padel turf manufacturer. Official FIP/Premier Padel turf partner. 13,000+ courts globally. |
| **BECO Bermüller** | Turf | beco-bermueller.de | +49 911 64200-0 | German synthetic turf and sports field materials. Parent of LOB Sport. |
| **Polytan GmbH** | Turf | polytan.com | Via website | Since 1969. Sports surfaces development and production. Active in padel turf across Europe. |
| **Primaflor** | Turf | primaflor.de | Via website | Artificial turf supplier for padel courts. |
| **Playtomic** | Booking software | playtomic.com | Via website | World's largest padel booking platform. 73+ locations in Germany. 6,700+ clubs, 52+ countries. €56M raised. |
| **bookaball** | Booking software | bookaball.com | Via website | Booking software for racket sport clubs. Used by German market leaders. €30M+ bookings processed. |
| **Ledkia** | Lighting | ledkia.com | Via website | LED padel court floodlight solutions, 50W1,250W. |
| **Lumosa** | Lighting | lumosa.eu | Via website | Dutch LED sports lighting manufacturer. Custom lighting plans, up to 80% energy savings. |
| **AS LED Lighting** | Lighting | Via website | Via website | Upper Bavaria-based LED specialist for padel. |
### 1.6 German Industry Organizations & Directories
| Organization | Website | Description |
|-------------|---------|-------------|
| **Deutscher Padel Verband (DPV)** | dpv-padel.de | Germany's official padel federation. Provides quality criteria for court purchasing. |
| **mypadel.de (DTB)** | mypadel.de | German Tennis Federation's padel portal. Court finder, leagues, partners. |
| **padel-test.de** | padel-test.de | Comprehensive German directory with builder comparisons and filterable listings. |
| **Padelinsider.de** | padelinsider.de | German padel information platform with booking system overviews. |
| **Sportstättenrechner** | sportstaettenrechner.de | Cost calculators, supplier info, and funding guidance for padel projects. |
| **Padel Lands** | padellands.com | International directory listing 60+ German clubs. |
---
## 2. SPAIN — World's Largest Padel Market (17,000+ courts)
Spain dominates global padel court manufacturing. Its manufacturers have exported courts to 70+ countries.
### 2.1 Court Manufacturers
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **MejorSet** | Crevillente, Alicante | mejorset.com | +34 966 374 289 / info@mejorset.com | Official court of Premier Padel & FIP. 10,000+ courts in 70+ countries. Part of LeDap group. Pioneer of panoramic design. |
| **Padel Galis** | Valencia | padelgalis.com | Via website | 10,000+ courts, 75+ countries. Partnership with Wilson and Fernando Belasteguín. Official WPT/Premier Padel supplier. |
| **Portico Sport** | Villafranca de los Barros, Badajoz | porticosport.com | Via website | 15+ years, 35+ countries, 4,000+ courts. Only manufacturer also building sports canopies. 110,000+ sqft factory. |
| **Padel10** | Rubí, Barcelona | padel10.com | Calle París 1-7, nave 18, Rubí 08191 | Exclusively padel since 2008. 4,500+ courts. Former WPT official supplier. 15-day production. Mondo and ACT turf systems. |
| **Manzasport** | Beniparrell, Valencia | manzasport.com | +34 963 217 472 / info@manzasport.com | Top-4 global builder since 2003, 4 factories in Valencia. 100% Spanish manufacturing, FIP and NIDE 2021 compliant. |
| **Maxpeed** | Viladecans, Barcelona | maxpeed.com | +34 936 593 961 / maxpeed@maxpeed.com | Manufacturer, builder, equipment supplier. |
| **AFP Courts / RedSport** | Portugal/Spain | afpcourts.com / redsportpadel.com | Via website | Official adidas licensee. 15+ years, 33+ exclusive distribution centers. Official PPL court. 2,150+ courts under RedSport brand. |
| **Jubo Padel** | Spain | jubopadel.com | Via website | Family-owned, 25+ years, 6,000+ courts. FIP official. Comprehensive alliance partner network. Court configurator. |
| **Padel Courts Deluxe (PCDLX)** | Alicante | padelcourtsdeluxe.es | info@padelcourtsdeluxe.com | 100% Spanish, premium courts. Robotic welding, carbon fibre courts. Partnership with Greenset for surfaces. |
| **SportBS** | Extremadura | sportbs.es | Via website | 100% Spanish materials. 360° service including courts, lighting, turf, software. |
| **Ingode Padel Courts** | Crevillente, Alicante | ingodepadel.com | Via website | ISO 14001 & 9001. Galvanized steel with epoxy paint. 20+ years. |
| **SkyPadel** | Spain (global) | skypadel.com | Via website | 1,500+ courts in 40+ countries. EUROCODE compliant. Featured at WPT events globally. Subsidiaries in India/Brazil/Mexico. |
| **PadelMagic** | Valladolid | padelmagic.es | Via website | 400+ courts/year, 80% exported. FIP-certified. |
| **Padel Hispania** | Spain/Portugal | padelhispania.com | Via website | Federation-approved specialist in Spain and Portugal. Ambassador: Catarina Santos. |
| **VerdePadel** | Spain | verdepadel.com | Via website | Building since 2004. 2,000+ courts, 40,000+ m² of artificial grass installed. |
| **J'hayber Padel** | Spain | jhayberinstalaciones.com | Via website | Manufacturer & lighting specialist since 1972. |
| **PadelFan Valencia** | Alaquàs, Valencia | padelfanvalencia.com | +34 661 320 398 | 10+ years, 800+ customers. Also provides turf replacement. |
| **TMPadel** | Valencia | tmpadel.com | Via website | Kiwa certified, exports to 6+ EU countries. |
| **Niberma** | Spain | niberma.es | Via website | 700+ courts. |
| **Grupo Pineda** | Griñón, Madrid | grupopineda.eu | Via website | Proprietary turf & pavement. |
| **MR Instalaciones** | Madrid | mrinstalaciones.es | Via website | 20+ years, turnkey. |
| **X-Treme Group** | Alcalá de Henares, Madrid | x-tremegroup.com | Via website | 18+ years, 6,000 m² facility. |
| **MTR Padel** | Spain | mtrpadel.com | Via website | Methacrylate glass, turnkey. |
| **GimPadel** | León | gimpadel.com | Via website | 800+ installations. Built in Netherlands, Belgium, Italy, Kuwait, Portugal, Kenya. |
| **EE Padel** | Spain/Sweden | eepadel.com | Via website | 2021 merger of Eljoi Padel, EcoNatura, Swedish investors. Aluminum courts. Ships to US/Canada/ME. |
| **Greencourt** | Spain | greencourt.es | Via website | Galvanized/aluminum/precast options. |
| **ExtremaAdel** | Extremadura | extremapadel-portugal8.webnode.pt | Via website | Aluminum model innovation. |
| **Padelgest** | Spain | Via website | Via website | Urban/sustainability focus. |
| **Iberopadel** | Spain | iberopadel.com | Via website | FEP approved, Joma/Maxpeed partner. |
| **Padel Alba** | Granja de Rocamora, Valencia | padelalba.com | Via website | 25+ years, FIP-compliant. |
| **Pistas-Padel.es** | Spain | pistas-padel.es | Via website | All court types. |
### 2.2 Spanish Turf/Surface Specialists
| Company | Website | Contact | Description |
|---------|---------|---------|-------------|
| **Mondo** | mondoworldwide.com | Via website | Official FIP/Premier Padel turf. 16,200 m² tufting factory in Borja, Spain. |
| **Realturf** | realturf.com | Via website | Official USPA sponsor. Drive Pro, Match Play, fibrillated lines. FEP-compliant. |
| **Act Sports** | act.sport | Via website | Global padel turf leader, 10,000+ fields. |
| **Eurocesped** | eurocesped.com | Via website | ITF parameters. |
| **Allgrass** | allgrass.es | Via website | FEP compliant. |
| **Albergrass** | albergrass.com | Via website | Pilar de la Horadada, Alicante. |
### 2.3 Spanish Lighting Specialists
| Company | Website | Description |
|---------|---------|-------------|
| **Led Projects** | ledprojects.es | World leader in padel lighting, 5,000+ courts, WPT/Premier Padel official. |
| **Óptima LED** | optimaled.es | ProTour padel spotlights, anti-glare. |
| **PlazaLED** | plazaled.es | Sports LED projectors & scoreboards, Madrid. |
| **Ellite LED Padel** | ledpadel.com | 30+ years R&D. 360° perimeter system. 10-year warranty. Integration with booking apps. |
### 2.4 Spanish Industry Body
The **International Padel Cluster (CIP)** in Madrid (clusterpadel.com) is the world's largest padel industry association with **132+ member companies**, 165 associated brands, 4,500+ employees, and **€2B+ combined turnover**. It encompasses 57 court construction companies, 38 racquet brands, 20 ball producers, 32 accessory makers, and 25 clothing/textile firms.
---
## 3. ITALY
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Mondo S.p.A.** | Alba (HQ) | mondoworldwide.com | Via website | World's leading padel turf. Official FIP/Premier Padel partner. |
| **Italgreen** | Italy | italgreen.org | Via website | 40+ years. Patented fiberglass structure. FIP sponsor. Iron, Full Panoramic, V-PRO courts. Own padel turf lines (Padel Pro, Padel Fib). |
| **Limonta Sport** | Italy | limontasport.com | Via website | Premium padel turf, FEP approved, CONI partner. |
| **Padel Factory SRL** | Near Rome | padelfactorysrl.com | Via website | 1,000+ courts. Innovative hybrid wood-steel design. 10+ European countries. |
| **Padel Corporation** | Italy | padelcorporation.com | +39 339 731 2152 | 10+ years, 20+ countries. 100% made in Italy, ITF compliant. Full turnkey. Zambrotta ambassador. |
| **Italian Padel** | Italy | italianpadel.it | info@italianpadel.it | 3,000+ courts in 28 countries. Up to 180 courts/month. CE certified. |
| **Italia Team Padel** | Pesaro-Urbino, Marche | italiateampadel.com | +39 0721 571 588 | Supplied first court at Foro Italico. Basic, Vision Pro, Full Vision models. |
| **Campidapadel.it (Madrid SRL)** | Lesmo (MB) | campidapadel.it | Via website | Design, supply, installation, plus financial and marketing consultancy. |
| **Favaretti Padel** | Bagnoli di Sopra (PD) | favarettipadel.it | Via website | Turnkey, official Dunlop partner. |
| **WIP Padel** | Southern Italy | wippadel.it | Via website | CE marked, 40+ years in sports. |
| **Merli Sport** | Ravenna | merlisport.com | Via website | 500+ courts, "The Wall" showroom. |
| **NXPadel** | Italy | nxpadel.com | Via website | Patented fiberglass technology. |
| **Edil Padel S.R.L.** | Italy | edilpadel.it | Via website | Construction/installation, wood/steel. |
| **Durocem Italia** | Italy | durocem.it/padel/ | Via website | Civil works & court installation, Padel Technologies distributor. |
| **Top Padel Italia** | Italy | toppadelitalia.it | Via website | Turnkey, from €15,800. |
| **Toro Padel** | Italy | toro-padel.it | Via website | 100+ courts since 2019, patented lighting. |
---
## 4. FRANCE
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **EPS Concept** | Moutiers, Brittany | eps-concept.com | +33 2 99 96 42 61 / contact@eps-concept.com | French manufacturer, 2,000+ courts, FFT PQP certified. |
| **Padel 360** | Bischheim (67) | padel360.fr | +33 7 80 91 69 43 / direction@padel360.fr | FFT PQP® certified. Turnkey with 10-year warranty. Automated club solutions and video scoring. |
| **France Padel** | Paris/Bidart | france-padel.fr | +33 5 35 45 55 00 / contact@france-padel.fr | Only French company 100% padel-dedicated. Premium, innovation-focused. |
| **100% Padel** | France | centpourcentpadel.fr | +33 6 69 78 18 47 | Family company managed by pro player Jérémy Scatena. French-manufactured. |
| **Constructeur Padel** | France | constructeur-padel.fr | +33 1 59 30 28 24 / contact@constructeur-padel.fr | 10+ years, 100+ clubs. Turnkey FIP-compliant courts. French/ecological materials. |
| **Le Padel Français** | France | lepadelfrancais.fr | Via website | 100% made in France, eco-responsible. Qualisteelcoat C5 protection. Hydro'Way permeable flooring. |
| **Metal Padel (Groupe Metal Laser)** | Rousset (13) | padel.metal-laser.com | Via website | First French padel manufacturer. FFT approved. Installs in France, Sweden, UK, Mauritius. |
| **SMC2 Construction** | Mornant, near Lyon | smc2-construction.com | +33 4 78 67 60 56 / contact@smc2-construction.com | Covered halls specialist, wood & textile membrane. Largest athletics hall in Southern Europe. |
| **Univers Construction** | Bouc-Bel-Air | universconstruction.com | +33 6 69 02 08 09 | French manufacturer/installer. Showcase center with 9 courts. 20+ years. |
| **3S Sport Systems** | Montpellier | sportsystems.fr | Via website | 3mm steel, Saint-Gobain Securit glass. 20-year structure warranty. |
| **WeOui Padel** | Valence | terrain-padel.com | Via website | High-end courts, FFT compliant. Custom furniture, decoration, accessories, maintenance. |
| **KIP Sport** | France | kipsport.fr | Via website | FFT Qualisport, 30 years sports infra. |
| **Storkeo** | France | storkeo.com | Via website | Turnkey including real estate & financing. |
| **VW Sports Padel** | Noisy-le-Grand (93) | vwsports.fr | +33 1 48 45 04 29 / contact@vwsports.fr | Historic tennis company, now padel, French manufacturing. |
| **Lauralu Industrie** | South-West France | lauralu.com | Via website | Padel court covers/halls specialist, FFT certified, 20+ years. |
| **ACS Production** | Near Nantes | Via website | +33 2 40 45 94 94 / contact@acs-production.com | Court construction & coverage, 20+ years, 25-yr membrane warranty. |
| **Concasport** | France | Via website | Via website | French manufacturer, custom designs. |
| **FieldTurf (Tarkett)** | France/Global | fieldturf.com | Via website | 25+ years synthetic turf for tennis/padel. 1,000,000+ m² installed. FIFA Preferred Producer. |
| **Losberger De Boer** | International | losbergerdeboer.com | Via website | Semi-permanent modular padel structures. Aluminum/wood frames with canvas roofing. |
| **Infinite Padel Courts** | France/North Africa | infinitepadelcourts.com | infinitepadelcourts@gmail.com | Custom courts, height-adjustable, manufactured in Alicante. |
---
## 5. PORTUGAL
| Company | Location | Website | Description |
|---------|----------|---------|-------------|
| **inCourts Padel** | Lisbon | incourtspadel.com | Robotic manufacturing, factory in North Portugal. |
| **Greenpark** | Portugal | greenpark.com.pt | First Portuguese-made padel court manufacturer. |
| **Sports Evolution** | Portugal | sports-evolution.pt | Builder/installer, also manufactures covers. |
| **Sports Partner** | Portugal | sportspartner.pt | Equipment supplier, multi-sport. |
---
## 6. UNITED KINGDOM
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **PRO Padel Courts** | UK | propadelcourts.com | Via website | Times 100 Ones to Watch 2025. Italian-engineered, patented anti-noise system. 50-year lifespan. MejorSet master distributor UK. |
| **Portico Sport UK** | Congleton, Manchester | porticosport.com/uk | Via website | From £15,000. Part of Portico Sport global network. |
| **Padel Tech** | UK | padeltech.co.uk | Via website | Leading UK supplier/installer. Exclusive AFP Courts/adidas UK distributor. 150+ courts. |
| **Hexa Padel** | Woodford Green, Essex | hexapadel.co.uk | Via website | One of UK's largest builders. Courts, canopies, booking software, maintenance, academy. |
| **iPadel Ltd** | UK | ipadel.co.uk | Via website | Independent consultancy. Free quotes from multiple suppliers. Planning and investment matchmaking. |
| **SG Padel** | UK | sgpadel.co.uk | Via website | Turnkey, MejorSet distributor, SAPCA approved. |
| **SIS Pitches / SISTurf** | UK | sispitches.com | Via website | 25+ years elite sports surfaces. UK-based turf manufacturer. Design, manufacture, install, maintain. 65 years. |
| **Padel Magic UK** | Nationwide | padelmagic.co.uk | Via website | Proprietary "Magic Base" for uneven terrain. Custom covers/canopies. Nationwide. |
| **Padel Build UK** | North Lincolnshire | padelbuilduk.com | Via website | UK manufacturer, hot-dip galvanizing. |
| **Padel Systems** | UK | padelsystems.co.uk | Via website | Bespoke builder, partners with Italian Padel. Sister company: CopriSystems. |
| **Red Raven Solutions** | UK | redravensolutions.co.uk | Via website | Exclusive PadelCreations UK distributor, 500+ courts. |
| **Padel Works** | Whitchurch, Shropshire | padelworks.co.uk | Via website | FIP-approved courts. 10-year warranty on court and surface. |
| **Padel Galis UK** | Coventry | padelgalis.uk | info@padelgalis.uk / +44 7494 045287 | Exclusive UK supplier of Padel Galis. |
| **S&C Slatter** | UK | slattersportsconstruction.com | Via website | 30+ years sports construction. Partners with FieldTurf. In-house civil engineering. |
| **Fordingbridge** | West Sussex | fordingbridge.co.uk | info@fordingbridge.co.uk / +44 1243 554455 | UK's leading padel canopy specialist, 60+ years, 25-yr guarantee. |
| **Collinson Tensile** | UK | collinsontensile.co.uk | Via website | 20+ years tensile buildings. Exclusive UK partner for Best-Hall Finland. ISO 9001/45001. |
| **Rubb UK** | Gateshead | rubbuk.com | Via website | Fabric building specialists. Thermohall® insulation. Modular, relocatable. |
| **J & J Carter** | UK | jjcarter.com | Via website | Tensile sports halls, inflatable halls, frame/fabric structures. |
| **Padel Consulting** | London | padelconsulting.co.uk | Via website | Advisory firm, SAPCA member. |
---
## 7. NETHERLANDS
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Allesvoorpadel (Padelbouw)** | Biddinghuizen | allesvoorpadel.nl | info@allesvoorpadel.nl / +31 320 331 588 | Leading Dutch builder, 10+ years, NK Padel official rink builder. AFP Courts partner. Philips lighting, Padelkiosk vending. |
| **Padel Nederland B.V.** | Monster | padelnederland.nl | info@padelnederland.nl / +31 6 83 67 51 47 | Durable aluminum courts, 15-year warranty. KIWA/KNLTB certified. Solar canopy options, acoustic solutions. |
| **I-Padel** | Netherlands | i-padel.nl | Via website | Dutch manufacturer, in-house production. KIWA ISA Sport / NOC*NSF certified. 15-year warranty. Also offers Ping-Pong Padel. |
| **SkyPadel NL** | Zuid-Holland | skypadel.nl | info@skypadel.nl / +31 638 448 409 | 1,800+ courts, Babolat official, since 2002. |
| **Padel.nl** | Netherlands | padel.nl | Via website | Since 2003, KNLTB/KIWA certified, proprietary foundations. |
| **Orange Padel International** | Netherlands | orangepadel.nl | Via website | Premium Dutch-designed courts. 30 years experience. FEMEPA certification. |
| **Padel Solution NL** | Netherlands | padelsolution.nl | Via website | 3,000+ installed courts in 30+ countries. Full project support. |
| **World Padel** | Netherlands | worldpadel.nl | Via website | Court supplier. |
| **Frisomat** | Belgium (NL-active) | frisomat.com | Via website | Nearly 50 years steel construction. Cold-formed galvanized padel canopies/roofs. Modular, demountable. |
---
## 8. BELGIUM
| Company | Location | Website | Description |
|---------|----------|---------|-------------|
| **Padel Projects** | Belgium (Benelux) | padelprojects.eu | Court construction, 10+ years, 3,000+ courts, patented lighting. |
| **JM Padel** | Province of Liège | jmpadel.be | Installer, consultant, club management, IT/video. |
| **YoPadel SPRL** | Belgium | yopadel.be | Belgian manufacturer, Belgian materials, Lano Sports turf partner. |
| **Domo Sports Grass** | Belgium | Via website | Global artificial grass expert. Certified by major sports federations. |
---
## 9. SCANDINAVIA (Sweden, Denmark, Finland, Norway)
| Company | Country | Website | Contact | Description |
|---------|---------|---------|---------|-------------|
| **Padeltotal** | Sweden | padeltotal.se | Via website | Largest Nordic supplier. 1,600+ courts since 2013. Galvanized for Nordic conditions, 12mm glass. Duruss partnership. |
| **Padel Global** | Sweden (Jönköping) | padel-global.com | Via website | Manufacturer, own factory. |
| **Scandinavian Padel AB** | Sweden (Malmö, factory in Lysekil) | scandinavianpadel.co | info@scandinavianpadel.co / +46 761 309080 | 25+ years in steel/glass for Nordic climate. C3 "oil rig grade" steel. Own ScanTurf turf. |
| **Sweden Padel Master** | Sweden | swedenpadelmaster.se | Via website | SPM Grass Court Cut technology. |
| **Acenta Group** | Sweden/Norway | acenta.group | Via website | Major Scandinavian company, courts/service/digital/equipment. Fiberglass courts expanding to AU/NZ. |
| **Instantpadel (Instant Courts)** | Sweden | instantcourts.com | Via website | World-unique mobile court, setup in <4 hours. 150+ courts, 17 countries. Installations at Gleneagles, Soho Club London. |
| **Hallgruppen** | Sweden/Nordic | hallgruppen.com | Via website | Padel hall structures. Self-supporting steel frames (50-year lifespan). Rental, leasing, purchase. CE approved. |
| **Best-Hall** | Finland | Via website | Via website | 5,500+ buildings worldwide. 40+ years. Fabric structures for sports halls. UK partner: Collinson Tensile. |
| **ViPadel** | Denmark (+ Finland) | vipadel.dk | Via website | Total supplier, official Mondo dealer DK/FI. |
| **A-Sport** | Denmark | a-sport.dk | Via website | Supplier/installer, 250+ courts. |
| **Tiebreak International / PadelTotal DK** | Glostrup, Denmark | padeltotal.dk | info@tiebreakinternational.com / +45 36889900 | PadelTotal concept for Denmark, 200+ courts. |
| **Unisport** | Finland/Denmark | unisport.com | Via website | Court manufacturer, Saltex Tempo turf. |
---
## 10. OTHER EUROPEAN COUNTRIES
| Company | Country | Website | Description |
|---------|---------|---------|-------------|
| **Courtwall (Padelcourt Biz)** | Austria | padelcourt.biz | Courts since 1984 (squash) and padel since 2007. 1,200+ courts. ISO certified. |
| **BORGA** | Sweden/Austria | borga.at | 45+ years building steel halls. Padel hall solutions. |
| **DUOL** | Slovenia | duol.eu | Air-supported and fabric sports buildings. Nearly 30 years. Online configurator. |
| **Losberger De Boer** | Germany/International | losbergerdeboer.com | Semi-permanent modular padel structures. |
---
## 11. UNITED STATES
The US padel market reached 688 courts across 180 facilities in 31 states by mid-2025, with 51.5% YoY club growth and a projected trajectory toward 6,800 courts by 2030.
### 11.1 US-Based Manufacturers & Builders
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Absolute Padel** | Mohnton, PA | absolutepadelusa.com | +1 717 445 5036 / info@absolutepadelusa.com | Only North America-based manufacturer. 100+ projects. 50%+ of US courts. Unique Pickleball & Padel combo court. Custom court generator. |
| **The Padel Box** | Multi-state (CA, FL, NY, NJ, etc.) | thepadelbox.com | info@padelbox.com | US pioneer since 2012, licensed in CA, NV, AZ, UT, NC, NJ. 15+ states. Official MejorSet US/Canada distributor. Hurricane-rated to 180 mph. |
| **Sportsfield Specialties** | Delhi, NY | sportsfield.com | +1 607 746 8911 / +1 888 975 3343 | USPA-endorsed manufacturer, 100% Made in USA, PaDelhi™ courts. |
| **USA Padel Center** | Houston, TX | usapadel.com | +1 713 539 3110 / info@usapadelcenter.com | Manufacturer/consultant since 2007. |
| **Padel One Courts** | Florida (nationwide) | padelonecourts.com | Via website | Premium American-made courts. C5 anti-rust coating. Trusted by Pro Padel League and SVB Mouratoglou Academy. |
| **Bounce Padel Courts** | Canada (NA-wide) | bouncepadelcourts.com | Via website | Premier North American provider. SGCC/ANSI-certified glass. Hurricane-class anchoring. Converts tennis courts and ice rinks. |
| **Northeast Padel** | Pocasset, MA | northeastpadel.com | +1 508 759 5636 / info@northeastpadel.com | Division of Cape & Island Tennis & Track, most-awarded US court builder. 50+ facility awards from ASBA. |
| **Sport Surfaces / Mondo Padel** | West Palm Beach, FL | sportsurfaces.com / mondopadel.com | +1 888 423 1120 / info@mondopadel.com | 150+ years combined team experience. FL licensed. Builds from ground up or converts tennis courts. |
| **Keystone Sports Construction** | USA | keystonesportsconstruction.com | Via website | Full-service turnkey padel from design to installation. Sportsfield Specialties partner. |
| **MTJ Sports** | Chicago area | mtjsports.com | Via website | 20+ years sports courts. Padel, pickleball, soccer, tennis. Turnkey for clubs, hotels, municipalities. |
| **Capas Padel** | USA/Puerto Rico | capaspadel.com | Via website | Builder/consultant, GreenSet surfaces, Smart Padel Club. |
| **All Racquet Sports** | Sandy, UT / Ft. Myers, FL | allracquetsports.com | info@allracquetsports.com | Official adidas/AFP US distributor, 700+ courts network. |
### 11.2 European Manufacturers with US Operations
| Company | HQ | Website | Contact | Description |
|---------|------|---------|---------|-------------|
| **MejorSet USA** | Spain | mejorset.com/us-eng | info@mejorset.com / d.polerecky@mejorset.com | Official FIP 20252026 court. Bilingual US team. X-Treme model resists 165 mph winds. FL Building Code certified. |
| **Portico Sport USA** | Spain | porticosport.com/padel-court-usa | Via website | US since 2021. Projects in NY, TX, FL, CA, IL, MA. AISC-360 and ASCE SEI 7-16 compliant. 10-year guarantee. |
| **AFP Courts** | Spain/Portugal | afpcourts.com | Via website | Official PPL court. adidas and RedSport manufacturer. |
| **Padel Galis** | Spain | padelgalis.com | Via website | 10,000+ courts globally. Wilson partnership. |
| **Italgreen** | Italy | italgreen.org | Via website | Patented fiberglass courts (zero rust). FIP technical sponsor. Up to 10-year warranty. |
| **SkyPadel** | Spain | skypadel.com | Via website | 1,500+ courts in 40+ countries. |
### 11.3 US Padel Franchises & Operators
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Conquer Padel Club** | Lehi, UT (expanding) | conquerpadel.com | Via website | First US padel franchise, $1.1M$3M+ investment. |
| **Park Padel** | San Francisco area | parkpadel.com | hello@parkpadel.com | Franchise, pop-up courts, community-focused. |
| **Jungle Padel** | USA | junglepadel.com | Via website | Franchise, premium Mondo turf, academy. |
### 11.4 Padel Turf Suppliers (North America)
| Company | HQ | Website | Description |
|---------|------|---------|-------------|
| **Realturf** | Spain | realturf.com/us | Official USPA sponsor. Drive Pro, Match Play. FEP-compliant. |
| **CCGrass** | China | ccgrass.com | Three factories. FEP-compliant. FastPro and YEII products. Also complete court packages. |
| **FieldTurf (Tarkett)** | France/USA | fieldturf.com | 1,000,000+ m² tennis/padel installed. FIFA Preferred Producer. |
| **WinterGreen Synthetic Grass** | Dallas, TX | wintergreengrass.com | Pro-grade padel turf in DFW. "Padel Pro" surface (same as WPT). |
| **JCTurf** | China | jcturf.com | In-house fiber extrusion. FIP/FEP compliant. Also complete court solutions. |
| **MightyGrass** | China | mightygrass.com | Professional padel turf, FEP-level. Factory direct pricing. |
| **Laykold / Sport Group** | Global | laykold.com | Padel Turf Pro surface. US Open official surface brand. |
### 11.5 Padel Lighting Specialists (North America)
| Company | Location | Website | Description |
|---------|----------|---------|-------------|
| **LED Lighting Supply** | USA | ledlightingsupply.com | 15+ years, 25,000+ projects. 150W LED fixtures. Free photometric plans. 5-year warranty. |
| **Tweener USA** | USA (French-developed) | tweenerusa.com | Patented LED on existing fencing — no poles needed. Minimal light pollution. Dimmable. |
| **Brite Court** | USA | britecourt.com | 40+ years racquet sports lighting. 600+ facilities. 18+ fixture designs. Samsung LEDs. 10-year warranty. |
| **AEON LED Lighting** | USA | aeonledlighting.com | Patented luminaires. UGR below 19 (glare-free). 100,000-hour lifespan. DLC Premium listed. |
| **AGC Lighting** | China | agcled.com | SP11 linear sports light for padel. Smart controls (DALI 2, DMX). Supports 4K broadcasting. |
### 11.6 US Industry Organizations
The **USPA (United States Padel Association)** at padelusa.org endorses select manufacturers and partnered with the **American Sports Builders Association (ASBA)** on the Padel Courts Installation & Maintenance Manual 2025 — the first US construction standard.
---
## 12. MEXICO
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **American Padel (TCDL)** | Mexico City | americanpadel.com.mx | +52 55 5891 3350 / info@americanpadel.com.mx | FIP-compliant, 25 yrs metalwork. |
| **Padel Center México** | Aguascalientes | padelcenter.mx | Via website | FIP-certified. Clásica, Semipanorámica, Pro models. 20-30 day delivery. |
| **MG Canchas** | Monterrey | mgcanchas.com | Via website | Pioneer manufacturer in Monterrey. Also supplies synthetic turf. |
| **SicaSport** | Mexico | sicasport.com | Via website | Manufacturer/builder/installer. |
| **Gott Padel** | Mexico | gottpadel.com | Via website | Design, installation, construction. Also sells rackets and balls. |
| **AFP Courts México** | Mexico | afpcourts.mx | Via website | Official adidas licensee for Mexico. |
| **CanchasdePadel.com** | Mexico | canchasdepadel.com | Via website | FIP-certified. WPT-certified curly turf. |
| **Padel Works MX** | Mexico | padelworks.com.mx | Via website | High-quality custom courts. Full support from civil works to club growth. |
| **PadelStore.mx** | Mexico | padelstore.mx | Via website | Court accessories: fencing, nets, posts, turf, sand, LED, protective pads. Ships nationwide. |
---
## 13. MIDDLE EAST (UAE, Saudi Arabia, GCC)
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Padel Factory (ME)** | Dubai, UAE | padelfactory.me | info@padelfactory.me / +971 56 536 9494 | Top manufacturer/supplier. Super Panoramic, Panoramic, Challenger, Portable. 400+ courts. UAE/KSA/Kuwait/Bahrain/Oman. |
| **RedLine Padel** | Dubai, UAE | redlinepadel.com | Via website | Spanish manufacturer based in Dubai. 48-hour delivery across ME. UNE EN 1090. Also operates clubs. |
| **Cypex Group** | UAE | cypex-group.com | Via website | Represents Padel Factory ME. Exclusive LANO GRASS Belgium distributor for GCC. Projects across ME, India, Maldives, South Africa. |
| **APW Pools** | Dubai | apw-pools.com | +971 50 852 1161 | Padel supplier/installer, smart lighting, advanced materials. |
| **Mister Shade ME** | Dubai | mistershademe.com | Via website | 20+ years in flooring. Artificial turf and acrylic padel courts. All UAE emirates. |
| **Gebal Group** | Dubai/Abu Dhabi/Riyadh/Jeddah | gebalgroup.com | Dubai: +971 44519691 / Riyadh: +966 55 977 7146 | Turnkey builder across GCC (6 countries). FIP-compliant. |
| **Empower Sport Services** | UAE | empowersportservices.com | Via website | 2,500+ installations, FEP certified. |
| **Shades Galaxy** | Dubai | shadesgalaxy.com | Via website | Manufacturer/supplier, all UAE emirates. |
| **Fab Floorings** | Dubai | fabfloorings.ae | Via website | Turnkey: flooring/glass/lighting/branding. |
| **Al Mustaqbal Alsarea** | UAE | almustaqbalalsarea.com | +971 50 247 5749 | Gulf countries leader. |
| **PFS Gulf** | Saudi Arabia | pfsgulf.com | Via website | Infrastructure company. Padel courts across KSA (Riyadh, Jeddah, Dammam, Mecca, Medina). |
| **Portico Sport ME** | Spain (Dubai/Saudi) | porticosport.com/middle-east | Via website | Active across Dubai, Abu Dhabi, Riyadh, Jeddah, Kuwait. 10-year guarantee. |
---
## 14. TURKEY
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Mediterra Padel** | Antalya | mediterrapadel.com | info@mediterrapadel.com / +90 554 678 51 40 | Turkey's largest, 35+ countries. Active in Kenya, SA, Sierra Leone, Morocco, Nigeria. |
| **Integral Grass / Integral Spor** | Istanbul | integralgrass.com | info@integralgrass.com / +90 212 678 13 13 | 11 models, 70+ countries. |
| **Unix Padel** | Istanbul | unixpadel.com | Via website | 7,800+ courts. Wind/impact tested by Istanbul Technical University. FIP-standard, KIWA-tested. |
---
## 15. CHINA — Global Export Manufacturing Powerhouse
Complete court packages from China typically cost $10,400$12,700 including shipping and installation.
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **Legend Sports (Qifan)** | Yanshan, Hebei | legendsports.com | Via website | One of China's largest. 220+ employees, 32 engineers. 5,000+ courts in 60+ countries. 66,000 m² factory. |
| **Fortune Padel** | China (offices in Indonesia, Italy, Spain) | fortunepadel.com | Via website | ISO 9001:2015. 20+ models including electric roof. Ships to 50+ countries within 20 days. 99.99% quality pass rate. |
| **China Youngman Padel / Young Padel** | Hefei, Anhui | youngpadel.com / premierpadelchina.com | Via website | China's largest since 2010. SGS, CE, ISO9001. 8 models. Also produces roof covers. |
| **Wanhe Padel** | Huaian, Jiangsu | wanhesport.com | Via website | Follows FIP regulations. Courts, 12mm turf, LED lighting. Also padel rackets. |
| **Shengshi Sports Tech** | Tianjin | shengshitrade.en.made-in-china.com | Via website | 20+ years sports equipment. 10,000+ m² facility near Tianjin port. |
| **PadelCourt10** | Hebei | padelcourt10.com | Via website | 1,000 sets/year capacity. 25+ countries. 5-year warranty. DDU door-to-door service. |
| **Shanghai Super Power** | Shanghai | padelcourtfactory.cn | Via website | 200+ employees. 350 courts/month capacity. Aluminum frames for coastal regions. |
| **UNIPADEL** | Guangzhou | gzunipadel.com | Via website | 5,000+ courts worldwide. Panoramic, classic, portable, roofed, padbol. Active in Indonesia, ME, Africa, LATAM. |
| **ArtPadel** | China | artpadel.com | Via website | Panoramic/classic courts, patented technology. |
| **LDK China** | Shenzhen | ldkchina.com | info@ldkchina.com / +86 755 89896763 | Manufacturer/exporter. |
| **SANJING Group** | Linqu, Shandong | sanjingcourt.com | Via website | Glass specialist, 27 years, 300+ employees, 40+ countries. |
| **Luckin Padel** | China | luckinpadel.com | Via website | FIP-certified standards, educational focus. |
| **Saintyol Sports** | China | Alibaba | Via website | 15+ years. Specializes in padel turf and structures. 10,000+ m² facility. |
| **Nanjing Padelworker** | Nanjing | Alibaba | Via website | 67% client reorder rate. Courts, squash equipment, glass fittings, turf. |
| **Hebei Aohe Teaching Equipment** | Hebei | Made-in-China | Via website | Est. 2012. 80% repeat business. Also aluminum frame sports tents. |
| **Shandong Century Star** | Shandong | Alibaba | Via website | Large facility. Steel structure courts, panoramic models. |
| **CCGrass** | China (global) | ccgrass.com | Via website | Major turf manufacturer. FEP-compliant. FastPro and YEII. Also complete court packages. |
| **JCTurf** | China | jcturf.com | Via website | In-house fiber extrusion. FIP/FEP compliant. |
| **MightyGrass** | China | mightygrass.com | Via website | Professional padel turf, FEP-level. Factory direct. |
---
## 16. INDIA
India is projected to add 12,000 padel courts over the next 15 years.
| Company | Location | Website | Description |
|---------|----------|---------|-------------|
| **Asian Flooring India (AFI Padel)** | Thane/Mumbai | asianflooring.in / afipadel.com | India's largest padel manufacturer. FIP-standard. Standard, Panoramic, Ultra Panoramic, Kids. 25-day delivery. |
| **Apex Sport Surfaces (SMI Padel)** | Mumbai | apexsportsurfaces.in | FIP-compliant manufacturer/exporter. |
| **Sky Padel India** | Mumbai | skypadel.in | Local manufacturing, subsidiary of Spanish Sky Padel. |
| **PFS Sport India** | India | pfs.sport | Turf & surface manufacturer. |
| **PadelHaus India** | India | padelhaus.in | Courts for every budget. |
| **Portico Sport India** | India | porticosport.com/ind | Targets Mumbai, Delhi, Bengaluru, Hyderabad, Pune. Turnkey. |
---
## 17. ASIA (Non-China, Non-India)
| Company | Country | Website | Description |
|---------|---------|---------|-------------|
| **SmartPadel / SEARA Sports** | Southeast Asia | smartpadel.asia | Regional leader in SE Asia. Video recording, automated scoring. |
| **Olympia Courts** | Asia Pacific | olympiacourts.com | Premium courts. European know-how, Asian manufacturing. Partners with Asia Pacific Padel Tour. |
| **Indo Padel** | Indonesia (Bali) | indopadel.com | Spanish-Indonesian team. Courts manufactured in Indonesia. Active in Thailand, India. |
| **Padel Asia** | Thailand | padelasia.org | Courts meeting international standards. Also sells rackets, clothing. Operates courts in Bangkok. |
---
## 18. LATIN AMERICA
### 18.1 Brazil
| Company | Website | Contact | Description |
|---------|---------|---------|-------------|
| **Padel Master Brasil** | padelmaster.com.br | Via website | 1,500+ courts in 10+ countries. Official Pala Tour/WPT Americas court. 10-year warranty. One of Brazil's most modern plants. |
| **Sky Padel Brasil** | skypadel.com.br | Via website | 10+ years. FEP/FIP certified. VP PRO 2.0, SP PRO, Full View, rental, mobile. 15-day production. |
| **Smart Padel** | smart-padel.com | Via website | IoT-connected courts. Online monitoring, strategic management software. FIP/FEP certified. |
| **Flores Pádel** | florespadel.com.br | Via website | Community-based manufacturer. 10+ years. Turf certified by Spanish federation. Founded by national team athletes. |
| **FC Quadras** | fcquadras.com.br | Via website | São Paulo. Distributes Padelgest courts. Turnkey from terrain prep to finishing. |
| **Padel Prime** | padelprime.com.br | Via website | European-standard manufacturing. Co-founded by ex-footballer Edmílson. |
| **F4 Quadras** | Instagram: @f4quadras | +55 53 9 8447 8700 | Manufacturer/installer. |
### 18.2 Argentina
| Company | Website | Description |
|---------|---------|-------------|
| **Padel Courts Master** | padelcourtsmaster.ar | 1,500+ courts delivered worldwide, 8+ countries. Robotic welding. FIP-standard. 100+ padel experts. |
| **MS Pádel (Metalser)** | metalurgicametalser.com | +54 2314 407746. Professional panoramic courts. Full package: structure, glass, LED, turf. |
| **World Padel Court** | worldpadelcourt.com.ar | Led by Visión Deportiva (major event organizer). Top-quality courts. Portable court rental. |
| **Blue Court** | bluecourt.com.ar | 15+ years manufacturing. Established Argentine brand. |
| **Slavon Césped Sintético** | slavoncespedsintetico.com | Panoramic and full panoramic courts. Also provides synthetic turf. |
### 18.3 Mexico
| Company | Location | Website | Contact | Description |
|---------|----------|---------|---------|-------------|
| **American Padel (TCDL)** | Mexico City | americanpadel.com.mx | +52 55 5891 3350 / info@americanpadel.com.mx | FIP-compliant, 25 yrs metalwork. |
| **Padel Center México** | Aguascalientes | padelcenter.mx | Via website | FIP-certified. Clásica, Semipanorámica, Pro. |
| **MG Canchas** | Monterrey | mgcanchas.com | Via website | Pioneer manufacturer. Also synthetic turf. |
| **SicaSport** | Mexico | sicasport.com | Via website | Manufacturer/builder/installer. |
| **Gott Padel** | Mexico | gottpadel.com | Via website | Design, installation, construction. |
| **AFP Courts México** | Mexico | afpcourts.mx | Via website | Official adidas licensee for Mexico. |
| **CanchasdePadel.com** | Mexico | canchasdepadel.com | Via website | FIP-certified. WPT-certified turf. |
| **Padel Works MX** | Mexico | padelworks.com.mx | Via website | Custom courts, full civil works support. |
| **PadelStore.mx** | Mexico | padelstore.mx | Via website | Accessories: fencing, nets, turf, sand, LED, pads. |
---
## 19. AFRICA — South Africa Leads the Continent
South Africa has 150+ courts and multiple domestic manufacturers.
| Company | Country | Website | Description |
|---------|---------|---------|-------------|
| **Padel Nation** | South Africa | padelnation.co.za | SA's leading manufacturer. 150+ courts. Local hot-dip galvanized manufacturing. 4-week lead times. Up to 10-year warranty. |
| **Padel Build** | South Africa | padelbuild.co.za | Premier turnkey builder. Partnered with Spain's Padel Galis. First FlexiPadel Base in Africa. |
| **Techno Padel** | South Africa | technopadel.co.za | Premier supplier/installer. 57 day installation. |
| **Padel Solutions** | South Africa | padelsolutions.co.za | Proudly SA. Design, manufacture, install. Turnkey. |
| **Padel Quip** | South Africa | padelquip.co.za | Local manufacturer. Basic to Premium Plus models. Partnership and financial assistance. |
| **Padel Projects SA** | South Africa | padelprojects.co.za | Designed for local conditions. |
| **Trompie Sport** | South Africa | trompiesport.co.za | Builder, imported & local courts. |
| **Belgotex Sport** | South Africa | belgotexsport.co.za | SA-based turf manufacturer (Pietermaritzburg factory). UNE 147301:2018 compliant. 220+ installations. |
| **Africa Padel** | South Africa | africapadel.com | Largest club group in Africa. 21+ clubs across SA. Founded 2021. Events, corporate leagues. |
| **Portico Sport SA** | South Africa | porticosport.com/za | Completed Virgin Active Padel Club and Atlantic Padel Cape Town. Force 80 hurricane-resistant courts. |
| **Padel Africa** | Sub-Saharan | padel.africa | Bringing padel to Ghana and Rwanda. Team has started 100+ companies and sold 2,000 courts. |
| **Technotrade Sports** | Egypt | technotradesports.com | Contractor, one of the best in Arab world. |
| **Turkan Company** | Egypt/Saudi Arabia | turkan-eg.com | Manufacturer, one of first in Egypt. |
---
## 20. AUSTRALIA & OCEANIA
| Company | Location | Website | Description |
|---------|----------|---------|-------------|
| **Laykold Padel / APT Asia Pacific** | Melbourne | aptasiapacific.com.au | Asia Pacific's largest sports surfaces company. Australian-made turf. Aluminium 6061-T6 frames. AS/NZS certified. Pop-up courts. |
| **Synthetic Padel Courts / Synthetic Sports Group** | Australia | syntheticpadelcourts.com.au / syntheticsportsgroup.com.au | Built the first padel court in Australia. Preferred installer for Indoor Padel Australia, Sydney Racquet Club. A$65K$100K per court. |
| **Padel in One Australia** | Australia | padelinone.com.au | Turnkey specialist. 8+ years. Management, marketing, operations consulting. Mission to consolidate padel in Oceania. |
| **PadelVolt** | Australia/Pacific | padelvolt.com | End-to-end premium service. MejorSet distributor across Oceania and Pacific Islands. Extreme weather designs. |
| **NXPadel / Acenta Group** | Sweden → Australia | acenta.group | Swedish Acenta Group expanded fiberglass courts to Australia/NZ (Jan 2026). Positioned for Brisbane 2032 Olympics. |
| **Padel 360 Australia** | Australia | padel360.com.au | Developer/builder/manager, Gimpadel partner. |
| **AS Lodge Tennis Courts** | Melbourne, VIC | asltenniscourts.com.au | Builder, $90K$130K per court. |
| **All Sport Projects** | QLD & NSW | allsportprojects.com.au | Non-rust aluminum courts, 715 yr warranties. |
| **SPORTENG** | Australia | sporteng.com.au | Consulting/design, developed official Padel Australia Guidelines. |
---
## 21. PAN-EUROPEAN / GLOBAL BOOKING SOFTWARE & TECHNOLOGY
| Company | HQ | Website | Description |
|---------|------|---------|-------------|
| **Playtomic** | Spain (Madrid) | playtomic.com | World's largest racket sports platform. 6,700+ clubs, 4M+ players, 52+ countries. €56M raised. Published Global Padel Report 2025. |
| **MATCHi / Matchpoint** | Sweden | tpcmatchpoint.com | 1,600+ venues. Multi-sport booking, memberships, leagues, coaching. |
| **Padel Mates** | Europe | Via website | All-in-one platform. Won Rocket Padel (Europe's largest indoor chain). Gamification features. |
| **SmashClub** | Europe | smashclub.cloud | Padel CRM and club management. Integrates with Playtomic, MATCHi, Padel Mates. |
| **Taykus** | Spain | Via website | Software specifically for padel clubs. Online booking, payment, communication automation. |
| **Playbypoint** | USA | playbypoint.com | Official tech partner of 2025 US Open Padel. Custom branded app per club. |
| **CourtReserve** | USA | Via website | Leading US platform alongside Playbypoint and Playtomic. |
| **360Player** | International | en-us.360player.com | Club management with website builder, video analysis, player development tools. |
| **Booklux** | International | booklux.com | Customizable booking system. Stripe payments. Google Analytics integration. |
| **SetTime** | International | settime.io | Free padel booking software. Google/Apple Calendar sync. Analytics for utilization. |
| **ProPadelKit** | International | propadelkit.com | Turnkey court solutions, Classic & Fusion models. |
---
## 22. KEY CERTIFICATIONS & INDUSTRY BODIES
| Body | Description |
|------|-------------|
| **FIP (International Padel Federation)** | Sets global court standards. Current official suppliers: MejorSet (courts), Mondo (turf). |
| **International Padel Cluster (CIP)** | Madrid. 132+ member companies, €2B+ combined turnover. clusterpadel.com |
| **FEP (Spanish Padel Federation)** | Approves manufacturers for world's largest domestic market. |
| **USPA (US Padel Association)** | Endorses US manufacturers. Partnered with ASBA on first US construction standard (2025). |
| **FFT PQP (French Tennis Federation)** | Quality certification for French market. |
| **KIWA / NOC*NSF** | Dutch quality standard for sports equipment. |
| **DPV (Deutscher Padel Verband)** | German federation quality criteria. |
| **SAPCA** | UK Sports and Play Construction Association. |
---
*Directory compiled February 2025. The padel industry is evolving rapidly — new entrants appear monthly. The global court count is projected to reach ~84,000 by end of 2026. For the most current information, check the International Padel Cluster directory (clusterpadel.com) and regional federation websites.*

View File

@@ -3,7 +3,9 @@ APP_NAME=Padelnomics
SECRET_KEY=change-me-generate-a-real-secret SECRET_KEY=change-me-generate-a-real-secret
BASE_URL=http://localhost:5000 BASE_URL=http://localhost:5000
DEBUG=true DEBUG=true
ADMIN_PASSWORD=admin
# Admin access — comma-separated emails that get the admin role on login
ADMIN_EMAILS=dev@localhost
# Database # Database
DATABASE_PATH=data/app.db DATABASE_PATH=data/app.db

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "articles" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;"> <div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">&larr; Back to articles</a> <a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">&larr; Back to articles</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1> <h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
@@ -79,5 +79,4 @@
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button> <button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
</form> </form>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "articles" %}
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Articles</h1> <h1 class="text-2xl">Articles</h1>
@@ -73,5 +73,4 @@
<p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p> <p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p>
{% endif %} {% endif %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "feedback" %}
{% block title %}Feedback - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Feedback - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Feedback</h1> <h1 class="text-2xl">Feedback</h1>
@@ -47,5 +47,4 @@
<p class="text-slate">No feedback yet.</p> <p class="text-slate">No feedback yet.</p>
</div> </div>
{% endif %} {% endif %}
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<div style="max-width: 32rem; margin: 0 auto;"> <div style="max-width: 32rem; margin: 0 auto;">
<a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">&larr; Back to {{ template.name }}</a> <a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">&larr; Back to {{ template.name }}</a>
<h1 class="text-2xl mt-4 mb-2">Generate Articles</h1> <h1 class="text-2xl mt-4 mb-2">Generate Articles</h1>
@@ -43,5 +43,4 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "dashboard" %}
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %} {% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Admin Dashboard</h1> <h1 class="text-2xl">Admin Dashboard</h1>
@@ -17,9 +17,9 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<form method="post" action="{{ url_for('admin.logout') }}"> <form method="post" action="{{ url_for('auth.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline">Logout</button> <button type="submit" class="btn-outline">Sign Out</button>
</form> </form>
</header> </header>
@@ -46,68 +46,55 @@
</div> </div>
<!-- Lead Funnel --> <!-- Lead Funnel -->
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8"> <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
<p class="text-xs text-slate">Planner Users</p> <p class="text-xs text-slate">Planner Users</p>
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p> <p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
<p class="text-xs text-slate">Total Leads</p> <p class="text-xs text-slate">Total Leads</p>
<p class="text-xl font-bold text-navy">{{ stats.leads_total }}</p> <p class="text-xl font-bold text-navy">{{ stats.leads_total }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
<p class="text-xs text-slate">New</p> <p class="text-xs text-slate">New</p>
<p class="text-xl font-bold text-navy">{{ stats.leads_new }}</p> <p class="text-xl font-bold text-navy">{{ stats.leads_new }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
<p class="text-xs text-slate">Verified</p> <p class="text-xs text-slate">Verified</p>
<p class="text-xl font-bold text-navy">{{ stats.leads_verified }}</p> <p class="text-xl font-bold text-navy">{{ stats.leads_verified }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
<p class="text-xs text-slate">Unlocked</p> <p class="text-xs text-slate">Unlocked</p>
<p class="text-xl font-bold text-navy">{{ stats.leads_unlocked }}</p> <p class="text-xl font-bold text-navy">{{ stats.leads_unlocked }}</p>
</div> </div>
</div> </div>
<!-- Supplier Stats --> <!-- Supplier Stats -->
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8"> <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
<p class="text-xs text-slate">Claimed Suppliers</p> <p class="text-xs text-slate">Claimed Suppliers</p>
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p> <p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
<p class="text-xs text-slate">Growth Tier</p> <p class="text-xs text-slate">Growth Tier</p>
<p class="text-xl font-bold text-navy">{{ stats.suppliers_growth }}</p> <p class="text-xl font-bold text-navy">{{ stats.suppliers_growth }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
<p class="text-xs text-slate">Pro Tier</p> <p class="text-xs text-slate">Pro Tier</p>
<p class="text-xl font-bold text-navy">{{ stats.suppliers_pro }}</p> <p class="text-xl font-bold text-navy">{{ stats.suppliers_pro }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
<p class="text-xs text-slate">Credits Spent</p> <p class="text-xs text-slate">Credits Spent</p>
<p class="text-xl font-bold text-navy">{{ stats.total_credits_spent }}</p> <p class="text-xl font-bold text-navy">{{ stats.total_credits_spent }}</p>
</div> </div>
<div class="card text-center" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
<p class="text-xs text-slate">Leads Forwarded</p> <p class="text-xs text-slate">Leads Forwarded</p>
<p class="text-xl font-bold text-navy">{{ stats.leads_unlocked_by_suppliers }}</p> <p class="text-xl font-bold text-navy">{{ stats.leads_unlocked_by_suppliers }}</p>
</div> </div>
</div> </div>
<!-- Quick Links -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-4">
<a href="{{ url_for('admin.leads') }}" class="btn text-center">Leads</a>
<a href="{{ url_for('admin.suppliers') }}" class="btn text-center">Suppliers</a>
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
<a href="{{ url_for('admin.tasks') }}" class="btn-outline text-center">Task Queue</a>
<a href="{{ url_for('admin.feedback') }}" class="btn-outline text-center">Feedback</a>
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
</div>
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-10">
<a href="{{ url_for('admin.templates') }}" class="btn-outline text-center">Templates</a>
<a href="{{ url_for('admin.scenarios') }}" class="btn-outline text-center">Scenarios</a>
<a href="{{ url_for('admin.articles') }}" class="btn-outline text-center">Articles</a>
</div>
<div class="grid-2"> <div class="grid-2">
<!-- Recent Users --> <!-- Recent Users -->
<section> <section>
@@ -174,5 +161,4 @@
</div> </div>
</section> </section>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% 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 content %} {% block admin_content %}
<main class="container-page py-12">
<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">&larr; All Leads</a> <a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a>
@@ -109,7 +109,7 @@
<tbody> <tbody>
{% for f in lead.forwards %} {% for f in lead.forwards %}
<tr> <tr>
<td><a href="{{ url_for('directory.index') }}">{{ f.supplier_name }}</a></td> <td><a href="{{ url_for('admin.supplier_detail', supplier_id=f.supplier_id) }}">{{ f.supplier_name }}</a></td>
<td>{{ f.credit_cost }}</td> <td>{{ f.credit_cost }}</td>
<td><span class="badge">{{ f.status }}</span></td> <td><span class="badge">{{ f.status }}</span></td>
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td> <td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
@@ -120,5 +120,4 @@
</div> </div>
</section> </section>
{% endif %} {% endif %}
</main>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "leads" %}
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12" style="max-width:640px"> <div style="max-width:640px">
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a> <a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a>
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1> <h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
@@ -113,5 +114,5 @@
<button type="submit" class="btn" style="width:100%">Create Lead</button> <button type="submit" class="btn" style="width:100%">Create Lead</button>
</form> </form>
</main> </div>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "leads" %}
{% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Lead Management</h1> <h1 class="text-2xl">Lead Management</h1>
@@ -64,5 +64,4 @@
<div id="lead-results"> <div id="lead-results">
{% include "admin/partials/lead_results.html" %} {% include "admin/partials/lead_results.html" %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block title %}Admin Login - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8">
<h1 class="text-2xl mb-6">Admin Login</h1>
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="password" class="form-label">Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
required
autofocus
>
</div>
<button type="submit" class="btn w-full">Login</button>
</form>
</div>
</main>
{% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "scenarios" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Scenario - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}{% if editing %}Edit{% else %}New{% endif %} Scenario - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;"> <div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">&larr; Back to scenarios</a> <a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">&larr; Back to scenarios</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Published Scenario</h1> <h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Published Scenario</h1>
@@ -179,5 +179,4 @@
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update &amp; Recalculate{% else %}Create Scenario{% endif %}</button> <button type="submit" class="btn" style="width: 100%;">{% if editing %}Update &amp; Recalculate{% else %}Create Scenario{% endif %}</button>
</form> </form>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "scenarios" %}
{% block title %}Preview: {{ scenario.title }} - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Preview: {{ scenario.title }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;"> <div style="max-width: 48rem; margin: 0 auto;">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">&larr; Back to scenarios</a> <a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">&larr; Back to scenarios</a>
@@ -29,5 +29,4 @@
<h2 class="text-lg mb-4 mt-8">Returns &amp; Financing</h2> <h2 class="text-lg mb-4 mt-8">Returns &amp; Financing</h2>
{% include "partials/scenario_returns.html" %} {% include "partials/scenario_returns.html" %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "scenarios" %}
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Published Scenarios</h1> <h1 class="text-2xl">Published Scenarios</h1>
@@ -52,5 +52,4 @@
<p class="text-slate text-sm">No published scenarios yet.</p> <p class="text-slate text-sm">No published scenarios yet.</p>
{% endif %} {% endif %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "suppliers" %}
{% block title %}Supplier Management - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Supplier Management - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Supplier Management</h1> <h1 class="text-2xl">Supplier Management</h1>
@@ -59,5 +59,4 @@
<div id="supplier-results"> <div id="supplier-results">
{% include "admin/partials/supplier_results.html" %} {% include "admin/partials/supplier_results.html" %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "tasks" %}
{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<h1 class="text-2xl">Task Queue</h1> <h1 class="text-2xl">Task Queue</h1>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a> <a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a>
@@ -106,5 +106,4 @@
{% endif %} {% endif %}
</div> </div>
</section> </section>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a> <a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
@@ -100,5 +100,4 @@
<p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p> <p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p>
{% endif %} {% endif %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;"> <div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a> <a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1> <h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1>
@@ -67,5 +67,4 @@
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button> <button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button>
</form> </form>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Article Templates</h1> <h1 class="text-2xl">Article Templates</h1>
@@ -48,5 +48,4 @@
<p class="text-slate text-sm">No templates yet. Create one to get started.</p> <p class="text-slate text-sm">No templates yet. Create one to get started.</p>
{% endif %} {% endif %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "users" %}
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<h1 class="text-2xl">{{ user.email }}</h1> <h1 class="text-2xl">{{ user.email }}</h1>
<a href="{{ url_for('admin.users') }}" class="btn-outline btn-sm">&larr; Users</a> <a href="{{ url_for('admin.users') }}" class="btn-outline btn-sm">&larr; Users</a>
@@ -55,10 +55,10 @@
<dt class="text-slate">Status</dt> <dt class="text-slate">Status</dt>
<dd>{{ user.sub_status or 'N/A' }}</dd> <dd>{{ user.sub_status or 'N/A' }}</dd>
</div> </div>
{% if user.paddle_customer_id %} {% if user.provider_customer_id %}
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-slate">Paddle Customer</dt> <dt class="text-slate">Paddle Customer</dt>
<dd class="mono">{{ user.paddle_customer_id }}</dd> <dd class="mono">{{ user.provider_customer_id }}</dd>
</div> </div>
{% endif %} {% endif %}
</dl> </dl>
@@ -73,5 +73,4 @@
<button type="submit" class="btn-secondary">Impersonate User</button> <button type="submit" class="btn-secondary">Impersonate User</button>
</form> </form>
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "users" %}
{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<h1 class="text-2xl">Users</h1> <h1 class="text-2xl">Users</h1>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a> <a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a>
@@ -74,5 +74,4 @@
<p class="text-slate text-sm">No users found.</p> <p class="text-slate text-sm">No users found.</p>
{% endif %} {% endif %}
</div> </div>
</main>
{% endblock %} {% endblock %}

View File

@@ -46,14 +46,40 @@ def create_app() -> Quart:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response return response
# Load current user before each request # Load current user + subscription + roles before each request
@app.before_request @app.before_request
async def load_user(): async def load_user():
g.user = None g.user = None
g.subscription = None
user_id = session.get("user_id") user_id = session.get("user_id")
if user_id: if user_id:
from .auth.routes import get_user_by_id from .core import fetch_one as _fetch_one
g.user = await get_user_by_id(user_id) row = await _fetch_one(
"""SELECT u.*,
bc.provider_customer_id,
(SELECT GROUP_CONCAT(role) FROM user_roles WHERE user_id = u.id) AS roles_csv,
s.id AS sub_id, s.plan, s.status AS sub_status,
s.provider_subscription_id, s.current_period_end
FROM users u
LEFT JOIN billing_customers bc ON bc.user_id = u.id
LEFT JOIN subscriptions s ON s.id = (
SELECT id FROM subscriptions
WHERE user_id = u.id
ORDER BY created_at DESC LIMIT 1
)
WHERE u.id = ? AND u.deleted_at IS NULL""",
(user_id,),
)
if row:
g.user = dict(row)
g.user["roles"] = row["roles_csv"].split(",") if row["roles_csv"] else []
if row["sub_id"]:
g.subscription = {
"id": row["sub_id"], "plan": row["plan"],
"status": row["sub_status"],
"provider_subscription_id": row["provider_subscription_id"],
"current_period_end": row["current_period_end"],
}
# Template context globals # Template context globals
@app.context_processor @app.context_processor
@@ -62,6 +88,8 @@ def create_app() -> Quart:
return { return {
"config": config, "config": config,
"user": g.get("user"), "user": g.get("user"),
"subscription": g.get("subscription"),
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
"now": datetime.utcnow(), "now": datetime.utcnow(),
"csrf_token": get_csrf_token, "csrf_token": get_csrf_token,
} }

View File

@@ -103,6 +103,74 @@ def login_required(f):
return decorated return decorated
def role_required(*roles):
"""Require user to have at least one of the given roles."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if not g.get("user"):
await flash("Please sign in to continue.", "warning")
return redirect(url_for("auth.login", next=request.path))
user_roles = g.user.get("roles", [])
if not any(r in user_roles for r in roles):
await flash("You don't have permission to access that page.", "error")
return redirect(url_for("dashboard.index"))
return await f(*args, **kwargs)
return decorated
return decorator
async def grant_role(user_id: int, role: str) -> None:
"""Grant a role to a user (idempotent)."""
await execute(
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, ?)",
(user_id, role),
)
async def revoke_role(user_id: int, role: str) -> None:
"""Revoke a role from a user."""
await execute(
"DELETE FROM user_roles WHERE user_id = ? AND role = ?",
(user_id, role),
)
async def ensure_admin_role(user_id: int, email: str) -> None:
"""Grant admin role if email is in ADMIN_EMAILS."""
if email.lower() in config.ADMIN_EMAILS:
await grant_role(user_id, "admin")
def subscription_required(
plans: list[str] = None,
allowed: tuple[str, ...] = ("active", "on_trial", "cancelled"),
):
"""Require active subscription, optionally of specific plan(s) and/or statuses.
Reads from g.subscription (eager-loaded in load_user) — zero extra queries.
"""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if not g.get("user"):
await flash("Please sign in to continue.", "warning")
return redirect(url_for("auth.login"))
sub = g.get("subscription")
if not sub or sub["status"] not in allowed:
await flash("Please subscribe to access this feature.", "warning")
return redirect(url_for("billing.pricing"))
if plans and sub["plan"] not in plans:
await flash(f"This feature requires a {' or '.join(plans)} plan.", "warning")
return redirect(url_for("billing.pricing"))
return await f(*args, **kwargs)
return decorated
return decorator
# ============================================================================= # =============================================================================
# Routes # Routes
# ============================================================================= # =============================================================================
@@ -209,9 +277,12 @@ async def verify():
# Set session # Set session
session.permanent = True session.permanent = True
session["user_id"] = token_data["user_id"] session["user_id"] = token_data["user_id"]
# Auto-grant admin role if email is in ADMIN_EMAILS
await ensure_admin_role(token_data["user_id"], token_data["email"])
await flash("Successfully signed in!", "success") await flash("Successfully signed in!", "success")
# Redirect to intended page or dashboard # Redirect to intended page or dashboard
next_url = request.args.get("next", url_for("dashboard.index")) next_url = request.args.get("next", url_for("dashboard.index"))
return redirect(next_url) return redirect(next_url)
@@ -249,7 +320,10 @@ async def dev_login():
session.permanent = True session.permanent = True
session["user_id"] = user_id session["user_id"] = user_id
# Auto-grant admin role if email is in ADMIN_EMAILS
await ensure_admin_role(user_id, email)
await flash(f"Dev login as {email}", "success") await flash(f"Dev login as {email}", "success")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))

View File

@@ -1,5 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Free Financial Planner - {{ config.APP_NAME }}{% endblock %} {% block title %}Free Padel Court Financial Planner - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="The most sophisticated padel court business plan calculator — completely free. 60+ variables, sensitivity analysis, cash flow projections, and supplier connections.">
<meta property="og:title" content="Free Padel Court Financial Planner - {{ config.APP_NAME }}">
<meta property="og:description" content="Plan your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. No signup required. Completely free.">
{% endblock %}
{% block content %} {% block content %}
<main class="container-page py-12"> <main class="container-page py-12">

View File

@@ -10,7 +10,28 @@
{% if article.og_image_url %} {% if article.og_image_url %}
<meta property="og:image" content="{{ article.og_image_url }}"> <meta property="og:image" content="{{ article.og_image_url }}">
{% endif %} {% endif %}
<link rel="canonical" href="{{ config.BASE_URL }}{{ article.url_path }}"> <script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": {{ article.title | tojson }},
"description": {{ (article.meta_description or '') | tojson }},
{% if article.og_image_url %}"image": {{ article.og_image_url | tojson }},{% endif %}
"datePublished": "{{ article.published_at[:10] if article.published_at else '' }}",
"author": {
"@type": "Organization",
"name": "Padelnomics"
},
"publisher": {
"@type": "Organization",
"name": "Padelnomics",
"logo": {
"@type": "ImageObject",
"url": "{{ url_for('static', filename='images/logo.png', _external=True) }}"
}
}
}
</script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@@ -6,7 +6,6 @@
<meta name="description" content="Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data."> <meta name="description" content="Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.">
<meta property="og:title" content="Padel Markets - {{ config.APP_NAME }}"> <meta property="og:title" content="Padel Markets - {{ config.APP_NAME }}">
<meta property="og:description" content="Explore padel court costs, revenue projections, and investment returns by city."> <meta property="og:description" content="Explore padel court costs, revenue projections, and investment returns by city.">
<link rel="canonical" href="{{ config.BASE_URL }}/markets">
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@@ -54,6 +54,9 @@ class Config:
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "") RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io") EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
ADMIN_EMAIL: str = _env("ADMIN_EMAIL", "leads@padelnomics.io") ADMIN_EMAIL: str = _env("ADMIN_EMAIL", "leads@padelnomics.io")
ADMIN_EMAILS: list[str] = [
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
]
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "") RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))

View File

@@ -35,6 +35,14 @@ async def get_user_stats(user_id: int) -> dict:
@bp.route("/") @bp.route("/")
@login_required @login_required
async def index(): async def index():
# Supplier users go straight to the supplier dashboard
supplier = await fetch_one(
"SELECT id FROM suppliers WHERE claimed_by = ? AND tier IN ('growth', 'pro')",
(g.user["id"],),
)
if supplier:
return redirect(url_for("suppliers.dashboard"))
stats = await get_user_stats(g.user["id"]) stats = await get_user_stats(g.user["id"])
return await render_template("index.html", stats=stats) return await render_template("index.html", stats=stats)

View File

@@ -4,7 +4,7 @@ Supplier directory: public, searchable listing of padel court suppliers.
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from quart import Blueprint, redirect, render_template, request, url_for from quart import Blueprint, 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
@@ -322,4 +322,6 @@ async def results():
page = max(1, int(request.args.get("page", "1") or "1")) page = max(1, int(request.args.get("page", "1") or "1"))
ctx = await _build_directory_query(q, country, category, region, page) ctx = await _build_directory_query(q, country, category, region, page)
return await render_template("partials/results.html", **ctx) resp = await make_response(await render_template("partials/results.html", **ctx))
resp.headers["X-Robots-Tag"] = "noindex"
return resp

View File

@@ -4,6 +4,8 @@
{% block head %} {% block head %}
<meta name="description" content="Browse {{ total_suppliers }}+ padel court suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and software. Find the right partner for your project."> <meta name="description" content="Browse {{ total_suppliers }}+ padel court suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and software. Find the right partner for your project.">
<meta property="og:title" content="Padel Court Supplier Directory - {{ config.APP_NAME }}">
<meta property="og:description" content="Browse {{ total_suppliers }}+ padel court suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and software.">
<style> <style>
:root { :root {
--dir-green: #15803D; --dir-green: #15803D;

View File

@@ -2,6 +2,30 @@
{% block title %}{{ supplier.name }} - Supplier Directory - {{ config.APP_NAME }}{% endblock %} {% block title %}{{ supplier.name }} - Supplier Directory - {{ config.APP_NAME }}{% endblock %}
{% block head %} {% block head %}
{% set _sup_country = country_labels.get(supplier.country_code, supplier.country_code) %}
{% set _sup_category = category_labels.get(supplier.category, supplier.category) %}
{% set _sup_desc = supplier.tagline if supplier.tagline else supplier.name ~ " — " ~ _sup_category ~ " in " ~ _sup_country %}
{% set _sup_image = (config.BASE_URL ~ "/static/uploads/covers/" ~ supplier.cover_image_file) if supplier.cover_image_file else (supplier.logo_url if supplier.logo_url else url_for('static', filename='images/logo.png', _external=True)) %}
<meta name="description" content="{{ _sup_desc | truncate(155, True, '...') }}">
<meta property="og:title" content="{{ supplier.name }} — {{ _sup_category }} | {{ config.APP_NAME }}">
<meta property="og:description" content="{{ _sup_desc | truncate(155, True, '...') }}">
<meta property="og:type" content="profile">
<meta property="og:image" content="{{ _sup_image }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": {{ supplier.name | tojson }},
{% if supplier.website %}"url": {{ supplier.website | tojson }},{% endif %}
{% if supplier.logo_url %}"logo": {{ supplier.logo_url | tojson }},{% endif %}
"description": {{ _sup_desc | tojson }},
"address": {
"@type": "PostalAddress",
"addressCountry": {{ supplier.country_code | tojson }}
{% if supplier.city %}, "addressLocality": {{ supplier.city | tojson }}{% endif %}
}
}
</script>
<style> <style>
/* ── Hero ─────────────────────────────────────────────────── */ /* ── Hero ─────────────────────────────────────────────────── */
.sp-hero { .sp-hero {

View File

@@ -169,7 +169,7 @@ QUOTE_STEPS = [
{"n": 6, "title": "Financing", "required": []}, {"n": 6, "title": "Financing", "required": []},
{"n": 7, "title": "About You", "required": ["stakeholder_type"]}, {"n": 7, "title": "About You", "required": ["stakeholder_type"]},
{"n": 8, "title": "Services Needed", "required": []}, {"n": 8, "title": "Services Needed", "required": []},
{"n": 9, "title": "Contact Details", "required": ["contact_name", "contact_email"]}, {"n": 9, "title": "Contact Details", "required": ["contact_name", "contact_email", "contact_phone"]},
] ]
@@ -269,6 +269,8 @@ async def quote_request():
errors.append("Full name is required") errors.append("Full name is required")
if not form.get("contact_email", "").strip(): if not form.get("contact_email", "").strip():
errors.append("Email is required") errors.append("Email is required")
if not form.get("contact_phone", "").strip():
errors.append("Phone number is required")
if errors: if errors:
if is_json: if is_json:
return jsonify({"ok": False, "errors": errors}), 422 return jsonify({"ok": False, "errors": errors}), 422

View File

@@ -32,8 +32,9 @@
</div> </div>
<div class="q-field-group"> <div class="q-field-group">
<label class="q-label" for="contact_phone">Phone <span style="color:#94A3B8;font-weight:400">(optional)</span></label> <label class="q-label" for="contact_phone">Phone <span class="required">*</span></label>
<input type="tel" id="contact_phone" name="contact_phone" class="q-input" value="{{ data.get('contact_phone', '') }}"> {% if 'contact_phone' in errors %}<p class="q-error-hint">Phone number is required</p>{% endif %}
<input type="tel" id="contact_phone" name="contact_phone" class="q-input {% if 'contact_phone' in errors %}q-input--error{% endif %}" value="{{ data.get('contact_phone', '') }}" required>
</div> </div>
<div class="q-field-group"> <div class="q-field-group">

View File

@@ -13,8 +13,11 @@ def up(conn):
" RENAME COLUMN lemonsqueezy_subscription_id" " RENAME COLUMN lemonsqueezy_subscription_id"
" TO paddle_subscription_id" " TO paddle_subscription_id"
) )
# Create index on whichever subscription ID column exists
# (paddle_subscription_id before 0011, provider_subscription_id after)
conn.execute("DROP INDEX IF EXISTS idx_subscriptions_provider") conn.execute("DROP INDEX IF EXISTS idx_subscriptions_provider")
conn.execute( if "paddle_subscription_id" in cols or "lemonsqueezy_subscription_id" in cols:
"CREATE INDEX IF NOT EXISTS idx_subscriptions_provider" conn.execute(
" ON subscriptions(paddle_subscription_id)" "CREATE INDEX IF NOT EXISTS idx_subscriptions_provider"
) " ON subscriptions(paddle_subscription_id)"
)

View File

@@ -68,8 +68,6 @@ _SUPPLIERS = [
"Textile membrane sport halls — glass court walls serve as lower facade with membrane roof above. Meets German building norms. 10-year guarantee.", "hall_builder", None), "Textile membrane sport halls — glass court walls serve as lower facade with membrane roof above. Meets German building norms. 10-year guarantee.", "hall_builder", None),
("Padberg Projektbau", "DE", None, "padberg-projektbau.de", ("Padberg Projektbau", "DE", None, "padberg-projektbau.de",
"Turnkey padel hall specialist. Expertise in minimum 6-7m ceiling height, glass wall statics, LED lighting. Assists with permits and grant funding.", "hall_builder", None), "Turnkey padel hall specialist. Expertise in minimum 6-7m ceiling height, glass wall statics, LED lighting. Assists with permits and grant funding.", "hall_builder", None),
("Planeco Building", "DE", None, "planecobuilding.de",
"Building permit consultancy for converting warehouses/halls into padel facilities. Fire safety, structural analysis, change-of-use permits.", "consultant", None),
("BORGA", "SE", None, "borga.at", ("BORGA", "SE", None, "borga.at",
"45+ years building steel halls. 3 standardized padel hall solutions plus custom. Sandwich panels, mezzanine floors, court layout optimization.", "hall_builder", None), "45+ years building steel halls. 3 standardized padel hall solutions plus custom. Sandwich panels, mezzanine floors, court layout optimization.", "hall_builder", None),
@@ -95,20 +93,6 @@ _SUPPLIERS = [
("AS LED Lighting", "DE", "Upper Bavaria", None, ("AS LED Lighting", "DE", "Upper Bavaria", None,
"Upper Bavaria-based LED specialist for padel.", "lighting", None), "Upper Bavaria-based LED specialist for padel.", "lighting", None),
# ---- Germany: Industry Organizations (1.6) ----
("Deutscher Padel Verband", "DE", None, "dpv-padel.de",
"Germany's official padel federation. Provides quality criteria for court purchasing.", "industry_body", None),
("mypadel.de", "DE", None, "mypadel.de",
"German Tennis Federation's padel portal. Court finder, leagues, partners.", "industry_body", None),
("padel-test.de", "DE", None, "padel-test.de",
"Comprehensive German directory with builder comparisons and filterable listings.", "industry_body", None),
("Padelinsider.de", "DE", None, "padelinsider.de",
"German padel information platform with booking system overviews.", "industry_body", None),
("Sportstättenrechner", "DE", None, "sportstaettenrechner.de",
"Cost calculators, supplier info, and funding guidance for padel projects.", "industry_body", None),
("Padel Lands", "DE", None, "padellands.com",
"International directory listing 60+ German clubs.", "industry_body", None),
# ---- Spain: Court Manufacturers (2.1) ---- # ---- Spain: Court Manufacturers (2.1) ----
("MejorSet", "ES", "Crevillente", "mejorset.com", ("MejorSet", "ES", "Crevillente", "mejorset.com",
"Official court of Premier Padel & FIP. 10,000+ courts in 70+ countries. Part of LeDap group. Pioneer of panoramic design.", "manufacturer", "+34 966 374 289"), "Official court of Premier Padel & FIP. 10,000+ courts in 70+ countries. Part of LeDap group. Pioneer of panoramic design.", "manufacturer", "+34 966 374 289"),
@@ -197,10 +181,6 @@ _SUPPLIERS = [
("Ledkia", "ES", None, "ledkia.com", ("Ledkia", "ES", None, "ledkia.com",
"LED padel court floodlight solutions, 50W-1,250W.", "lighting", None), "LED padel court floodlight solutions, 50W-1,250W.", "lighting", None),
# ---- Spain: Industry Body (2.4) ----
("International Padel Cluster", "ES", "Madrid", "clusterpadel.com",
"World's largest padel industry association. 132+ member companies, 165 brands, €2B+ combined turnover.", "industry_body", None),
# ---- Italy (3) ---- # ---- Italy (3) ----
("Mondo S.p.A.", "IT", "Alba", "mondoworldwide.com", ("Mondo S.p.A.", "IT", "Alba", "mondoworldwide.com",
"World's leading padel turf manufacturer. Official FIP/Premier Padel turf partner. 13,000+ courts globally.", "turf", None), "World's leading padel turf manufacturer. Official FIP/Premier Padel turf partner. 13,000+ courts globally.", "turf", None),
@@ -294,8 +274,6 @@ _SUPPLIERS = [
"Leading UK supplier/installer. Exclusive AFP Courts/adidas UK distributor. 150+ courts.", "turnkey", None), "Leading UK supplier/installer. Exclusive AFP Courts/adidas UK distributor. 150+ courts.", "turnkey", None),
("Hexa Padel", "GB", "Woodford Green", "hexapadel.co.uk", ("Hexa Padel", "GB", "Woodford Green", "hexapadel.co.uk",
"One of UK's largest builders. Courts, canopies, booking software, maintenance, academy.", "manufacturer", None), "One of UK's largest builders. Courts, canopies, booking software, maintenance, academy.", "manufacturer", None),
("iPadel Ltd", "GB", None, "ipadel.co.uk",
"Independent consultancy. Free quotes from multiple suppliers. Planning and investment matchmaking.", "consultant", None),
("SG Padel", "GB", None, "sgpadel.co.uk", ("SG Padel", "GB", None, "sgpadel.co.uk",
"Turnkey, MejorSet distributor, SAPCA approved.", "turnkey", None), "Turnkey, MejorSet distributor, SAPCA approved.", "turnkey", None),
("SIS Pitches", "GB", None, "sispitches.com", ("SIS Pitches", "GB", None, "sispitches.com",
@@ -322,9 +300,6 @@ _SUPPLIERS = [
"Fabric building specialists. Thermohall insulation. Modular, relocatable.", "hall_builder", None), "Fabric building specialists. Thermohall insulation. Modular, relocatable.", "hall_builder", None),
("J & J Carter", "GB", None, "jjcarter.com", ("J & J Carter", "GB", None, "jjcarter.com",
"Tensile sports halls, inflatable halls, frame/fabric structures.", "hall_builder", None), "Tensile sports halls, inflatable halls, frame/fabric structures.", "hall_builder", None),
("Padel Consulting", "GB", "London", "padelconsulting.co.uk",
"Advisory firm, SAPCA member.", "consultant", None),
# ---- Netherlands (7) ---- # ---- Netherlands (7) ----
("Allesvoorpadel", "NL", "Biddinghuizen", "allesvoorpadel.nl", ("Allesvoorpadel", "NL", "Biddinghuizen", "allesvoorpadel.nl",
"Leading Dutch builder, 10+ years, NK Padel official rink builder. AFP Courts partner. Philips lighting.", "manufacturer", "info@allesvoorpadel.nl"), "Leading Dutch builder, 10+ years, NK Padel official rink builder. AFP Courts partner. Philips lighting.", "manufacturer", "info@allesvoorpadel.nl"),
@@ -439,10 +414,6 @@ _SUPPLIERS = [
("AGC Lighting", "CN", None, "agcled.com", ("AGC Lighting", "CN", None, "agcled.com",
"SP11 linear sports light for padel. Smart controls (DALI 2, DMX). Supports 4K broadcasting.", "lighting", None), "SP11 linear sports light for padel. Smart controls (DALI 2, DMX). Supports 4K broadcasting.", "lighting", None),
# ---- USA: Industry (11.6) ----
("USPA", "US", None, "padelusa.org",
"United States Padel Association. Endorses select manufacturers. Partnered with ASBA on first US construction standard.", "industry_body", None),
# ---- Mexico (12) ---- # ---- Mexico (12) ----
("American Padel", "MX", "Mexico City", "americanpadel.com.mx", ("American Padel", "MX", "Mexico City", "americanpadel.com.mx",
"FIP-compliant, 25 yrs metalwork.", "manufacturer", "+52 55 5891 3350"), "FIP-compliant, 25 yrs metalwork.", "manufacturer", "+52 55 5891 3350"),
@@ -602,8 +573,6 @@ _SUPPLIERS = [
"SA-based turf manufacturer. UNE 147301:2018 compliant. 220+ installations.", "turf", None), "SA-based turf manufacturer. UNE 147301:2018 compliant. 220+ installations.", "turf", None),
("Africa Padel", "ZA", None, "africapadel.com", ("Africa Padel", "ZA", None, "africapadel.com",
"Largest club group in Africa. 21+ clubs across SA. Founded 2021. Events, corporate leagues.", "franchise", None), "Largest club group in Africa. 21+ clubs across SA. Founded 2021. Events, corporate leagues.", "franchise", None),
("Padel Africa", "ZA", None, "padel.africa",
"Bringing padel to Ghana and Rwanda. Team has started 100+ companies and sold 2,000 courts.", "consultant", None),
("Technotrade Sports", "EG", None, "technotradesports.com", ("Technotrade Sports", "EG", None, "technotradesports.com",
"Contractor, one of the best in Arab world.", "manufacturer", None), "Contractor, one of the best in Arab world.", "manufacturer", None),
("Turkan Company", "EG", None, "turkan-eg.com", ("Turkan Company", "EG", None, "turkan-eg.com",
@@ -624,9 +593,6 @@ _SUPPLIERS = [
"Builder, $90K-$130K per court.", "manufacturer", None), "Builder, $90K-$130K per court.", "manufacturer", None),
("All Sport Projects", "AU", None, "allsportprojects.com.au", ("All Sport Projects", "AU", None, "allsportprojects.com.au",
"Non-rust aluminum courts, 7-15 yr warranties.", "manufacturer", None), "Non-rust aluminum courts, 7-15 yr warranties.", "manufacturer", None),
("SPORTENG", "AU", None, "sporteng.com.au",
"Consulting/design, developed official Padel Australia Guidelines.", "consultant", None),
# ---- Software & Technology (21) ---- # ---- Software & Technology (21) ----
("Playtomic", "ES", "Madrid", "playtomic.com", ("Playtomic", "ES", "Madrid", "playtomic.com",
"World's largest racket sports platform. 6,700+ clubs, 4M+ players, 52+ countries. €56M raised.", "software", None), "World's largest racket sports platform. 6,700+ clubs, 4M+ players, 52+ countries. €56M raised.", "software", None),

View File

@@ -6,9 +6,9 @@
<meta property="og:title" content="Padel Court Financial Planner - {{ config.APP_NAME }}"> <meta property="og:title" content="Padel Court Financial Planner - {{ config.APP_NAME }}">
<meta property="og:description" content="Plan your padel court investment with our 60+ variable financial planner. Calculate ROI, CAPEX, cash flow, and more."> <meta property="og:description" content="Plan your padel court investment with our 60+ variable financial planner. Calculate ROI, CAPEX, cash flow, and more.">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:image" content="{{ url_for('static', filename='images/og-planner.png', _external=True) }}"> <meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script> <script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@@ -3,6 +3,8 @@ Public domain: landing page, marketing pages, legal pages, feedback.
""" """
from pathlib import Path from pathlib import Path
from datetime import UTC, datetime
from quart import Blueprint, Response, render_template, request, session from quart import Blueprint, Response, render_template, request, session
from ..core import check_rate_limit, config, csrf_protect, execute, fetch_all, fetch_one from ..core import check_rate_limit, config, csrf_protect, execute, fetch_all, fetch_one
@@ -94,35 +96,63 @@ async def suppliers():
) )
@bp.route("/robots.txt")
async def robots_txt():
base = config.BASE_URL.rstrip("/")
body = (
"User-agent: *\n"
"Disallow: /admin/\n"
"Disallow: /auth/\n"
"Disallow: /dashboard/\n"
"Disallow: /billing/\n"
"Disallow: /directory/results\n"
f"Sitemap: {base}/sitemap.xml\n"
)
return Response(body, content_type="text/plain")
@bp.route("/sitemap.xml") @bp.route("/sitemap.xml")
async def sitemap(): async def sitemap():
base = config.BASE_URL.rstrip("/") base = config.BASE_URL.rstrip("/")
urls = [ today = datetime.now(UTC).strftime("%Y-%m-%d")
f"{base}/",
f"{base}/features", # (loc, lastmod) pairs
f"{base}/about", entries: list[tuple[str, str]] = [
f"{base}/planner/", (f"{base}/", today),
f"{base}/directory/", (f"{base}/features", today),
f"{base}/suppliers", (f"{base}/about", today),
f"{base}/billing/pricing", (f"{base}/planner/", today),
f"{base}/terms", (f"{base}/directory/", today),
f"{base}/privacy", (f"{base}/suppliers", today),
f"{base}/markets", (f"{base}/billing/pricing", today),
(f"{base}/terms", today),
(f"{base}/privacy", today),
(f"{base}/markets", today),
] ]
# Add published articles (only those with published_at <= now) # Add published articles with lastmod
articles = await fetch_all( articles = await fetch_all(
"""SELECT url_path FROM articles """SELECT url_path, COALESCE(updated_at, published_at) as lastmod
FROM articles
WHERE status = 'published' AND published_at <= datetime('now') WHERE status = 'published' AND published_at <= datetime('now')
ORDER BY published_at DESC""" ORDER BY published_at DESC"""
) )
for article in articles: for article in articles:
urls.append(f"{base}{article['url_path']}") lastmod = article["lastmod"][:10] if article["lastmod"] else today
entries.append((f"{base}{article['url_path']}", lastmod))
# Add supplier detail pages with lastmod
suppliers = await fetch_all(
"SELECT slug, created_at FROM suppliers ORDER BY name LIMIT 5000"
)
for supplier in suppliers:
lastmod = supplier["created_at"][:10] if supplier["created_at"] else today
entries.append((f"{base}/directory/{supplier['slug']}", lastmod))
xml = '<?xml version="1.0" encoding="UTF-8"?>\n' xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url in urls: for loc, lastmod in entries:
xml += f" <url><loc>{url}</loc></url>\n" xml += f" <url><loc>{loc}</loc><lastmod>{lastmod}</lastmod></url>\n"
xml += "</urlset>" xml += "</urlset>"
return Response(xml, content_type="application/xml") return Response(xml, content_type="application/xml")

View File

@@ -1,6 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}About - {{ config.APP_NAME }}{% endblock %} {% block title %}About Padelnomics — Padel Court Investment Platform{% endblock %}
{% block head %}
<meta name="description" content="Padelnomics is a free financial planning platform for padel entrepreneurs. Model your investment, find suppliers, and plan your padel court business with professional-grade tools.">
<meta property="og:title" content="About Padelnomics — Padel Court Investment Platform">
<meta property="og:description" content="Built for padel entrepreneurs who need professional financial tools without consulting fees. Free planner, 60+ variables, supplier directory, and more.">
{% endblock %}
{% block content %} {% block content %}
<main class="container-page py-12"> <main class="container-page py-12">

View File

@@ -4,6 +4,9 @@
{% block head %} {% block head %}
<meta name="description" content="60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment."> <meta name="description" content="60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.">
<meta property="og:title" content="Features - Padel Court Financial Planner | {{ config.APP_NAME }}">
<meta property="og:description" content="60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.">
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@@ -3,131 +3,260 @@
{% block title %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endblock %} {% block title %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endblock %}
{% block head %} {% block head %}
<meta name="description" content="Plan your padel court investment in minutes. Financial planner with 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models."> <meta name="description" content="Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.">
<meta property="og:title" content="Padelnomics - Padel Court Financial Planner"> <meta property="og:title" content="Padelnomics - Padel Court Financial Planner">
<meta property="og:description" content="The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections."> <meta property="og:description" content="The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="{{ config.BASE_URL }}"> <meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
<link rel="canonical" href="{{ config.BASE_URL }}">
<style> <style>
/* Hero two-column grid */ /* Hero dark section */
.hero-dark {
background: #0F172A;
background-image: radial-gradient(ellipse at 68% 44%, rgba(29,78,216,0.12) 0%, transparent 55%);
position: relative;
overflow: hidden;
}
.hero-dark::after {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
pointer-events: none;
}
.hero-inner {
max-width: 72rem;
margin: 0 auto;
padding: 88px 1.5rem 80px;
position: relative;
z-index: 1;
}
.hero-grid { .hero-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: center;
} }
.hero-badge { .hero-badge {
display: inline-flex; align-items: center; gap: 8px; display: inline-flex; align-items: center; gap: 8px;
background: #F0FDF4; border: 1px solid #DCFCE7; border-radius: 999px; background: rgba(22,163,74,0.12); border: 1px solid rgba(22,163,74,0.22);
padding: 5px 14px; margin-bottom: 20px; font-size: 0.75rem; font-weight: 600; color: #16A34A; border-radius: 999px; padding: 6px 16px; margin-bottom: 24px;
font-size: 0.75rem; font-weight: 600; color: #4ADE80;
} }
.hero-title { .hero-title {
font-size: clamp(30px, 4.5vw, 48px); font-weight: 800; letter-spacing: -0.035em; font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
line-height: 1.1; margin: 0 0 18px; font-size: clamp(38px, 5.5vw, 60px); font-weight: 800; letter-spacing: -0.035em;
line-height: 1.06; margin: 0 0 22px; color: #fff;
} }
.hero-title .accent { color: #60A5FA; }
.hero-desc { .hero-desc {
font-size: 1.0625rem; color: #64748B; line-height: 1.65; margin: 0 0 28px; max-width: 460px; font-size: 1.0625rem; color: rgba(255,255,255,0.6); line-height: 1.7;
margin: 0 0 32px; max-width: 440px;
} }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; } .hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
.hero-actions .btn { padding: 14px 26px; font-size: 1rem; font-weight: 700; border-radius: 10px; } .btn-hero {
.hero-actions .btn-outline { padding: 14px 22px; font-size: 1rem; border-radius: 10px; } padding: 14px 28px; font-size: 1rem; font-weight: 700; border-radius: 12px;
.hero-bullets { display: flex; gap: 20px; font-size: 0.8125rem; color: #94A3B8; } display: inline-flex; align-items: center; gap: 6px;
.hero-check { color: #16A34A; font-weight: 700; } background: #fff; color: #0F172A; border: none; cursor: pointer;
box-shadow: 0 4px 20px rgba(255,255,255,0.12);
transition: transform 0.15s, box-shadow 0.15s; text-decoration: none;
}
.btn-hero:hover {
color: #0F172A; transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(255,255,255,0.18);
}
.btn-hero-outline {
padding: 14px 24px; font-size: 1rem; font-weight: 600; border-radius: 12px;
display: inline-flex; align-items: center;
border: 1.5px solid rgba(255,255,255,0.2); color: rgba(255,255,255,0.8);
background: transparent; cursor: pointer; transition: all 0.15s; text-decoration: none;
}
.btn-hero-outline:hover {
border-color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.06); color: #fff;
}
.hero-bullets { display: flex; gap: 24px; font-size: 0.8125rem; color: rgba(255,255,255,0.4); }
.hero-check { color: #4ADE80; font-weight: 700; margin-right: 2px; }
/* Quick ROI Calculator */ /* ROI Calculator (white card on dark bg) */
.roi-calc { .roi-calc {
background: #FFFFFF; border: 1.5px solid #E2E8F0; border-radius: 20px; background: #fff; border-radius: 20px; padding: 32px;
padding: 28px; box-shadow: 0 8px 40px rgba(0,0,0,0.06); box-shadow: 0 20px 60px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.06);
} }
.roi-calc__title { font-size: 0.875rem; font-weight: 700; margin-bottom: 4px; } .roi-calc__title {
.roi-calc__sub { font-size: 0.75rem; color: #94A3B8; margin-bottom: 22px; } font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
.roi-calc__sliders { display: grid; gap: 18px; } font-size: 1rem; font-weight: 700; color: #0F172A; margin-bottom: 4px;
}
.roi-calc__sub { font-size: 0.75rem; color: #94A3B8; margin-bottom: 24px; }
.roi-calc__sliders { display: grid; gap: 20px; }
.roi-calc__row label { .roi-calc__row label {
display: flex; justify-content: space-between; font-size: 0.8125rem; display: flex; justify-content: space-between; font-size: 0.8125rem;
font-weight: 500; color: #64748B; margin-bottom: 6px; font-weight: 500; color: #64748B; margin-bottom: 8px;
} }
.roi-calc__row label span { .roi-calc__row label span {
color: #1D4ED8; font-weight: 700; font-size: 0.875rem; color: #1D4ED8; font-weight: 700; font-size: 0.875rem;
font-family: 'Commit Mono', 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
} }
.roi-calc__row input[type="range"] { .roi-calc__row input[type="range"] {
width: 100%; accent-color: #1D4ED8; cursor: pointer; height: 6px; width: 100%; accent-color: #1D4ED8; cursor: pointer; height: 6px;
} }
.roi-metrics { .roi-metrics {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
border-top: 1px solid #E2E8F0; padding-top: 18px; margin-top: 24px; border-top: 1px solid #E2E8F0; padding-top: 20px; margin-top: 24px;
} }
.roi-metric { padding: 0; background: none; box-shadow: none; text-align: left; }
.roi-metric__label { .roi-metric__label {
font-size: 0.6875rem; color: #94A3B8; text-transform: uppercase; font-size: 0.6875rem; color: #94A3B8; text-transform: uppercase;
letter-spacing: 0.04em; margin-bottom: 3px; letter-spacing: 0.06em; font-weight: 500; margin-bottom: 4px;
} }
.roi-metric__val { .roi-metric__val {
font-size: 1.125rem; font-weight: 700; color: #0F172A; font-size: 1.25rem; font-weight: 700; color: #0F172A;
font-family: 'Commit Mono', 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
} }
.roi-metric__val--green { color: #1D4ED8; } .roi-metric__val--green { color: #1D4ED8; }
.roi-metric__val--red { color: #DC2626; } .roi-metric__val--red { color: #DC2626; }
.roi-calc__note { .roi-calc__note { font-size: 0.6875rem; color: #94A3B8; margin-top: 16px; line-height: 1.5; }
font-size: 0.6875rem; color: #94A3B8; margin-top: 14px; line-height: 1.5;
}
.roi-calc__cta { .roi-calc__cta {
display: block; width: 100%; text-align: center; margin-top: 16px; display: block; width: 100%; text-align: center; margin-top: 18px;
background: #1D4ED8; color: #fff; border-radius: 10px; padding: 12px; background: #1D4ED8; color: #fff; border-radius: 12px; padding: 13px;
font-size: 0.875rem; font-weight: 700; text-decoration: none; font-size: 0.875rem; font-weight: 700; text-decoration: none;
box-shadow: 0 2px 8px rgba(29,78,216,0.2); transition: background 0.15s; box-shadow: 0 2px 10px rgba(29,78,216,0.25); transition: background 0.15s;
} }
.roi-calc__cta:hover { background: #1E40AF; color: #fff; } .roi-calc__cta:hover { background: #1E40AF; color: #fff; }
/* Supplier matching section */ /* Journey timeline */
.journey-section { padding: 5rem 0 4rem; }
.journey-section h2 { text-align: center; font-size: 1.75rem; margin-bottom: 3.5rem; }
.journey-track {
display: grid; grid-template-columns: repeat(5, 1fr);
position: relative; padding: 0 1rem;
}
.journey-track::after {
content: ''; position: absolute; top: 23px; left: 12%; right: 12%;
height: 2px; background: #E2E8F0; z-index: 0;
}
.journey-step {
display: flex; flex-direction: column; align-items: center;
text-align: center; position: relative; z-index: 1;
}
.journey-step__num {
width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-weight: 800; font-size: 0.875rem;
background: #fff; border: 2px solid #E2E8F0; color: #CBD5E1;
margin-bottom: 1rem; transition: all 0.2s;
}
.journey-step--active .journey-step__num {
background: #1D4ED8; border-color: #1D4ED8; color: #fff;
box-shadow: 0 4px 16px rgba(29,78,216,0.3);
}
.journey-step__title {
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-size: 0.9375rem; font-weight: 700; color: #0F172A; margin-bottom: 0.375rem;
}
.journey-step__desc {
font-size: 0.8125rem; color: #64748B; max-width: 170px; line-height: 1.5;
}
.journey-step--upcoming { opacity: 0.45; }
.journey-step--upcoming .journey-step__title { color: #64748B; }
.badge-soon {
display: inline-block; background: rgba(29,78,216,0.08); color: #1D4ED8;
font-size: 0.625rem; font-weight: 700; padding: 2px 8px; border-radius: 999px;
margin-left: 4px; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle;
}
/* Supplier matching */
.match-grid { .match-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem;
max-width: 800px; margin: 0 auto; max-width: 800px; margin: 0 auto;
} }
.match-step { text-align: center; } .match-step { text-align: center; }
.match-step__num { .match-step__num {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 48px; height: 48px; border-radius: 50%; width: 56px; height: 56px; border-radius: 50%;
background: #EFF6FF; color: #1D4ED8; font-weight: 700; font-size: 1.25rem; background: rgba(29,78,216,0.06); color: #1D4ED8;
margin-bottom: 0.75rem; font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-weight: 800; font-size: 1.375rem; margin-bottom: 1rem;
} }
.match-step h3 { font-size: 1rem; margin-bottom: 0.25rem; } .match-step h3 { font-size: 1rem; margin-bottom: 0.375rem; }
.match-step p { font-size: 0.8125rem; color: #64748B; } .match-step p { font-size: 0.8125rem; color: #64748B; line-height: 1.5; }
/* FAQ */ /* FAQ */
.faq { max-width: 640px; margin: 0 auto; } .faq { max-width: 640px; margin: 0 auto; }
.faq details { border-bottom: 1px solid #E2E8F0; padding: 1rem 0; } .faq details { border-bottom: 1px solid #E2E8F0; padding: 1.125rem 0; }
.faq summary { .faq summary {
font-weight: 600; cursor: pointer; font-size: 0.9375rem; color: #1E293B; font-weight: 600; cursor: pointer; font-size: 0.9375rem; color: #1E293B;
list-style: none; list-style: none; display: flex; align-items: center; gap: 10px;
} }
.faq summary::-webkit-details-marker { display: none; } .faq summary::-webkit-details-marker { display: none; }
.faq summary::before { content: "+ "; color: #1D4ED8; font-weight: 700; } .faq summary::before {
.faq details[open] summary::before { content: "- "; } content: "+"; color: #1D4ED8; font-weight: 700; font-size: 1.125rem;
.faq p { color: #64748B; font-size: 0.875rem; margin-top: 0.5rem; line-height: 1.6; } font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
width: 20px; flex-shrink: 0;
}
.faq details[open] summary::before { content: "\2212"; }
.faq p { color: #64748B; font-size: 0.875rem; margin-top: 0.625rem; line-height: 1.65; padding-left: 30px; }
/* Dark CTA card */
.cta-card {
background: #0F172A;
background-image: radial-gradient(ellipse at 50% 120%, rgba(29,78,216,0.15) 0%, transparent 60%);
border-radius: 24px; padding: 5rem 2rem; text-align: center;
position: relative; overflow: hidden;
}
.cta-card::after {
content: ''; position: absolute; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.02'/%3E%3C/svg%3E");
pointer-events: none;
}
.cta-card h2 { color: #fff; font-size: 2rem; margin-bottom: 0.625rem; position: relative; z-index: 1; }
.cta-card p { color: rgba(255,255,255,0.55); margin-bottom: 2rem; max-width: 480px; margin-left: auto; margin-right: auto; position: relative; z-index: 1; }
.cta-card__btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 15px 32px; background: #fff; color: #0F172A; border-radius: 12px;
font-weight: 700; font-size: 1rem; border: none; cursor: pointer;
box-shadow: 0 4px 20px rgba(255,255,255,0.1);
transition: transform 0.15s, box-shadow 0.15s;
position: relative; z-index: 1; text-decoration: none;
}
.cta-card__btn:hover {
color: #0F172A; transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(255,255,255,0.16);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.hero-grid { grid-template-columns: 1fr; gap: 32px; } .hero-grid { grid-template-columns: 1fr; gap: 40px; }
.hero-title { font-size: clamp(28px, 6vw, 36px); } .hero-title { font-size: clamp(32px, 8vw, 44px); }
.hero-bullets { flex-wrap: wrap; gap: 12px; } .hero-bullets { flex-wrap: wrap; gap: 12px; }
.roi-metrics { grid-template-columns: 1fr 1fr; } .roi-metrics { grid-template-columns: 1fr 1fr; }
.journey-track { grid-template-columns: 1fr; gap: 2rem; padding: 0; }
.journey-track::after { display: none; }
.journey-step {
display: grid; grid-template-columns: 48px 1fr;
column-gap: 1rem; text-align: left; align-items: start;
}
.journey-step__num { grid-row: 1 / 3; margin-bottom: 0; }
.journey-step__desc { max-width: none; }
.match-grid { grid-template-columns: 1fr; } .match-grid { grid-template-columns: 1fr; }
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="container-page"> <!-- Hero: dark section, full-width -->
<!-- Hero: two-column with calculator --> <section class="hero-dark">
<section style="padding: 72px 0 56px"> <div class="hero-inner">
<div class="hero-grid"> <div class="hero-grid">
<div> <div>
<div class="hero-badge">&#x1F3BE; Padel court financial planner</div> <div class="hero-badge">&#x1F3BE; Padel court financial planner</div>
<h1 class="hero-title">Plan Your Padel<br>Business in Minutes,<br><span class="text-electric">Not Months</span></h1> <h1 class="hero-title">
Plan Your Padel<br>
Business in Minutes,<br>
<span class="accent">Not Months</span>
</h1>
<p class="hero-desc"> <p class="hero-desc">
Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers. Model your padel court investment with 60+ variables, sensitivity analysis,
and professional-grade projections. Then get matched with verified suppliers.
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a href="{{ url_for('planner.index') }}" class="btn">Plan Your Padel Business &rarr;</a> <a href="{{ url_for('planner.index') }}" class="btn-hero">Plan Your Padel Business &rarr;</a>
<a href="{{ url_for('directory.index') }}" class="btn-outline">Browse Suppliers</a> <a href="{{ url_for('directory.index') }}" class="btn-hero-outline">Browse Suppliers</a>
</div> </div>
<div class="hero-bullets"> <div class="hero-bullets">
<span><span class="hero-check">&#x2713;</span> No signup required</span> <span><span class="hero-check">&#x2713;</span> No signup required</span>
@@ -154,19 +283,19 @@
</div> </div>
</div> </div>
<div class="roi-metrics"> <div class="roi-metrics">
<div class="roi-metric"> <div>
<div class="roi-metric__label">Investment</div> <div class="roi-metric__label">Investment</div>
<div class="roi-metric__val" id="roiCapex">&euro;330K</div> <div class="roi-metric__val" id="roiCapex">&euro;330K</div>
</div> </div>
<div class="roi-metric"> <div>
<div class="roi-metric__label">Monthly Cash Flow</div> <div class="roi-metric__label">Monthly Cash Flow</div>
<div class="roi-metric__val" id="roiCf">&euro;7K</div> <div class="roi-metric__val" id="roiCf">&euro;7K</div>
</div> </div>
<div class="roi-metric"> <div>
<div class="roi-metric__label">Payback Period</div> <div class="roi-metric__label">Payback Period</div>
<div class="roi-metric__val" id="roiPayback">3.9 yr</div> <div class="roi-metric__val" id="roiPayback">3.9 yr</div>
</div> </div>
<div class="roi-metric"> <div>
<div class="roi-metric__label">Annual ROI</div> <div class="roi-metric__label">Annual ROI</div>
<div class="roi-metric__val" id="roiReturn">26%</div> <div class="roi-metric__val" id="roiReturn">26%</div>
</div> </div>
@@ -175,31 +304,38 @@
<a href="{{ url_for('planner.index') }}" class="roi-calc__cta">Plan Your Padel Business &rarr;</a> <a href="{{ url_for('planner.index') }}" class="roi-calc__cta">Plan Your Padel Business &rarr;</a>
</div> </div>
</div> </div>
</section> </div>
</section>
<!-- The Journey --> <main class="container-page">
<section class="py-12"> <!-- Journey Timeline -->
<h2 class="text-2xl text-center mb-8">Your Journey</h2> <section class="journey-section">
<div class="grid-5"> <h2>Your Journey</h2>
<div class="card border-l-4 border-l-electric"> <div class="journey-track">
<p class="font-semibold text-navy mb-2">&#x1F50D; Explore <span class="badge">Coming Soon</span></p> <div class="journey-step journey-step--upcoming">
<p class="text-sm text-slate-dark">Market demand analysis, whitespace mapping, location scoring. Is padel viable in your area?</p> <div class="journey-step__num">01</div>
<h3 class="journey-step__title">Explore <span class="badge-soon">Soon</span></h3>
<p class="journey-step__desc">Market demand analysis, whitespace mapping, location scoring.</p>
</div> </div>
<div class="card border-l-4 border-l-accent"> <div class="journey-step journey-step--active">
<p class="font-semibold text-navy mb-2">&#x1F4CA; Plan</p> <div class="journey-step__num">02</div>
<p class="text-sm text-slate-dark">Model your investment with our financial planner. CAPEX, operating costs, cash flow, returns, sensitivity analysis.</p> <h3 class="journey-step__title">Plan</h3>
<p class="journey-step__desc">Model your investment with 60+ variables, charts, and sensitivity analysis.</p>
</div> </div>
<div class="card border-l-4 border-l-warning"> <div class="journey-step journey-step--upcoming">
<p class="font-semibold text-navy mb-2">&#x1F4B0; Finance <span class="badge">Coming Soon</span></p> <div class="journey-step__num">03</div>
<p class="text-sm text-slate-dark">Connect with banks and investors experienced in sports facility loans. Your planner data becomes your business case.</p> <h3 class="journey-step__title">Finance <span class="badge-soon">Soon</span></h3>
<p class="journey-step__desc">Connect with banks and investors. Your planner becomes your business case.</p>
</div> </div>
<div class="card border-l-4 border-l-danger"> <div class="journey-step journey-step--active">
<p class="font-semibold text-navy mb-2">&#x1F3D7;&#xFE0F; Build</p> <div class="journey-step__num">04</div>
<p class="text-sm text-slate-dark">Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched based on your project specs.</p> <h3 class="journey-step__title">Build</h3>
<p class="journey-step__desc">Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched to your specs.</p>
</div> </div>
<div class="card border-l-4 border-l-slate"> <div class="journey-step journey-step--upcoming">
<p class="font-semibold text-navy mb-2">&#x1F4C8; Grow <span class="badge">Coming Soon</span></p> <div class="journey-step__num">05</div>
<p class="text-sm text-slate-dark">Launch playbook, performance benchmarking, operational KPIs, and expansion analytics for your venue.</p> <h3 class="journey-step__title">Grow <span class="badge-soon">Soon</span></h3>
<p class="journey-step__desc">Launch playbook, performance benchmarks, and expansion analytics.</p>
</div> </div>
</div> </div>
</section> </section>
@@ -281,7 +417,7 @@
</details> </details>
<details> <details>
<summary>Is the supplier directory free?</summary> <summary>Is the supplier directory free?</summary>
<p>Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans (Growth at &euro;149/mo, Pro at &euro;399/mo) unlock full descriptions, logos, verified badges, and priority placement.</p> <p>Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans (Basic at &euro;39/mo, Growth at &euro;199/mo, Pro at &euro;499/mo) unlock enquiry forms, full descriptions, logos, verified badges, and priority placement.</p>
</details> </details>
<details> <details>
<summary>How accurate are the financial projections?</summary> <summary>How accurate are the financial projections?</summary>
@@ -303,11 +439,13 @@
</div> </div>
</section> </section>
<!-- Final CTA --> <!-- Final CTA — dark card -->
<section class="text-center py-12"> <section style="padding: 2rem 0 4rem">
<h2 class="text-2xl mb-2">Start Planning Today</h2> <div class="cta-card">
<p class="text-slate mb-6">Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.</p> <h2>Start Planning Today</h2>
<a href="{{ url_for('planner.index') }}" class="btn">Plan Your Padel Business</a> <p>Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.</p>
<a href="{{ url_for('planner.index') }}" class="cta-card__btn">Plan Your Padel Business &rarr;</a>
</div>
</section> </section>
</main> </main>
{% endblock %} {% endblock %}
@@ -379,4 +517,62 @@
update(); update();
})(); })();
</script> </script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Padelnomics",
"url": "{{ config.BASE_URL }}",
"logo": "{{ url_for('static', filename='images/logo.png', _external=True) }}",
"description": "Professional padel court investment planning platform. Financial planner, supplier directory, and market intelligence for padel entrepreneurs."
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What does the planner calculate?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The planner produces a complete financial model: CAPEX breakdown, monthly operating costs, cash flow projections, debt service, IRR, MOIC, DSCR, payback period, break-even utilization, and sensitivity analysis. It covers indoor/outdoor, rent/buy, and all major cost and revenue variables."
}
},
{
"@type": "Question",
"name": "Do I need to sign up?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports."
}
},
{
"@type": "Question",
"name": "How does supplier matching work?",
"acceptedAnswer": {
"@type": "Answer",
"text": "When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals."
}
},
{
"@type": "Question",
"name": "Is the supplier directory free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans unlock full descriptions, logos, verified badges, and priority placement."
}
},
{
"@type": "Question",
"name": "How accurate are the financial projections?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The model uses real-world defaults based on European market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes."
}
}
]
}
</script>
{% endblock %} {% endblock %}

View File

@@ -2,6 +2,10 @@
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %} {% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Privacy Policy for Padelnomics. Learn how we collect, use, and protect your data. GDPR compliant. We never sell your personal information.">
{% endblock %}
{% block content %} {% block content %}
<main class="container-page py-12"> <main class="container-page py-12">
<div class="card max-w-3xl mx-auto"> <div class="card max-w-3xl mx-auto">

View File

@@ -3,13 +3,16 @@
{% block title %}For Suppliers - Reach Padel Entrepreneurs | {{ config.APP_NAME }}{% endblock %} {% block title %}For Suppliers - Reach Padel Entrepreneurs | {{ config.APP_NAME }}{% endblock %}
{% block head %} {% block head %}
<meta name="description" content="Get listed on Padelnomics. Reach entrepreneurs actively planning padel court projects. Growth and Pro plans available."> <meta name="description" content="Get listed on Padelnomics. Reach entrepreneurs who've already built a financial model for their padel project. Basic, Growth and Pro plans from €39/mo.">
<style> <style>
/* Hero */
.sup-hero { text-align: center; padding: 3rem 0 2rem; } .sup-hero { text-align: center; padding: 3rem 0 2rem; }
.sup-hero h1 { font-size: 2.25rem; line-height: 1.3; } .sup-hero h1 { font-size: 2.25rem; line-height: 1.3; }
.sup-hero p { color: #64748B; font-size: 1.125rem; max-width: 560px; margin: 0.75rem auto 0; } .sup-hero p { color: #64748B; font-size: 1.125rem; max-width: 560px; margin: 0.75rem auto 0; }
.sup-hero .btn { margin-top: 1.5rem; padding: 14px 32px; font-size: 1rem; } .sup-hero .btn { margin-top: 1.5rem; padding: 14px 32px; font-size: 1rem; }
.sup-hero__proof { font-size: 0.8125rem; color: #94A3B8; margin-top: 0.75rem; }
/* Stats */
.sup-stats { .sup-stats {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem;
max-width: 720px; margin: 0 auto; padding: 1.5rem 0 2rem; max-width: 720px; margin: 0 auto; padding: 1.5rem 0 2rem;
@@ -21,10 +24,27 @@
.sup-stat-card strong { display: block; font-size: 1.75rem; color: #1E293B; font-weight: 800; } .sup-stat-card strong { display: block; font-size: 1.75rem; color: #1E293B; font-weight: 800; }
.sup-stat-card span { font-size: 0.8125rem; color: #64748B; } .sup-stat-card span { font-size: 0.8125rem; color: #64748B; }
/* Sections */
.sup-section { padding: 2.5rem 0; } .sup-section { padding: 2.5rem 0; }
.sup-section h2 { text-align: center; font-size: 1.5rem; margin-bottom: 0.5rem; } .sup-section h2 { text-align: center; font-size: 1.5rem; margin-bottom: 0.5rem; }
.sup-section .sub { text-align: center; color: #64748B; margin-bottom: 2rem; } .sup-section .sub { text-align: center; color: #64748B; margin-bottom: 2rem; }
/* Problem section */
.sup-problem { background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px; padding: 2rem; }
.sup-problem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-top: 1.5rem; }
.sup-problem-card {
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;
background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.sup-problem-card .stat { font-size: 2rem; font-weight: 800; color: #DC2626; line-height: 1; margin-bottom: 0.5rem; }
.sup-problem-card h3 { font-size: 0.9375rem; margin-bottom: 0.25rem; color: #1E293B; }
.sup-problem-card p { font-size: 0.8125rem; color: #64748B; margin: 0; }
.sup-problem-transition {
text-align: center; font-size: 1rem; color: #334155; font-style: italic;
margin-top: 1.5rem; font-weight: 500;
}
/* How it works */
.sup-steps { .sup-steps {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem;
max-width: 800px; margin: 0 auto; max-width: 800px; margin: 0 auto;
@@ -45,9 +65,7 @@
padding: 1.5rem; max-width: 600px; margin: 2rem auto 0; padding: 1.5rem; max-width: 600px; margin: 2rem auto 0;
} }
.credit-explainer h3 { font-size: 1rem; margin-bottom: 1rem; text-align: center; } .credit-explainer h3 { font-size: 1rem; margin-bottom: 1rem; text-align: center; }
.credit-tiers { .credit-tiers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem;
}
.credit-tier { .credit-tier {
text-align: center; padding: 0.75rem; background: white; text-align: center; padding: 0.75rem; background: white;
border: 1px solid #E2E8F0; border-radius: 10px; border: 1px solid #E2E8F0; border-radius: 10px;
@@ -59,7 +77,7 @@
.heat-warm { color: #D97706; } .heat-warm { color: #D97706; }
.heat-cool { color: #3B82F6; } .heat-cool { color: #3B82F6; }
/* Lead preview cards */ /* Lead preview */
.sup-lead-preview { .sup-lead-preview {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
padding: 1.5rem; max-width: 720px; margin: 2rem auto 0; padding: 1.5rem; max-width: 720px; margin: 2rem auto 0;
@@ -70,9 +88,7 @@
letter-spacing: 0.04em; margin-bottom: 1rem; letter-spacing: 0.04em; margin-bottom: 1rem;
} }
.lead-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; } .lead-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
.lead-preview-card { .lead-preview-card { background: white; border: 1px solid #E2E8F0; border-radius: 10px; padding: 1rem; }
background: white; border: 1px solid #E2E8F0; border-radius: 10px; padding: 1rem;
}
.lead-preview-card .lp-heat { .lead-preview-card .lp-heat {
font-size: 0.6875rem; font-weight: 700; padding: 2px 8px; font-size: 0.6875rem; font-weight: 700; padding: 2px 8px;
border-radius: 999px; display: inline-block; margin-bottom: 0.5rem; border-radius: 999px; display: inline-block; margin-bottom: 0.5rem;
@@ -82,20 +98,52 @@
.lead-preview-card dl { font-size: 0.8125rem; } .lead-preview-card dl { font-size: 0.8125rem; }
.lead-preview-card dt { color: #94A3B8; font-size: 0.6875rem; text-transform: uppercase; margin-top: 0.5rem; } .lead-preview-card dt { color: #94A3B8; font-size: 0.6875rem; text-transform: uppercase; margin-top: 0.5rem; }
.lead-preview-card dd { color: #1E293B; font-weight: 500; margin: 0; } .lead-preview-card dd { color: #1E293B; font-weight: 500; margin: 0; }
.lead-preview-card .lp-blur { .lead-preview-card .lp-blur { color: transparent; text-shadow: 0 0 8px rgba(30,41,59,0.5); user-select: none; }
color: transparent; text-shadow: 0 0 8px rgba(30,41,59,0.5);
user-select: none; /* Why section */
.sup-why { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
.sup-why-card {
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.04);
} }
.sup-why-card h3 { font-size: 0.9375rem; margin-bottom: 0.25rem; }
.sup-why-card p { font-size: 0.8125rem; color: #64748B; }
/* Billing toggle — CSS-only via radio sibling trick */
input[name="billing"] { position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; }
.billing-toggle {
display: flex; align-items: center; justify-content: center;
margin: 0 auto 2rem; width: fit-content;
background: #F1F5F9; border-radius: 999px; padding: 4px;
}
.billing-toggle label {
padding: 8px 20px; border-radius: 999px; cursor: pointer;
font-size: 0.875rem; font-weight: 500; color: #64748B; transition: all 0.15s;
white-space: nowrap;
}
.billing-toggle .save-badge {
background: #DCFCE7; color: #16A34A; font-size: 0.6875rem;
font-weight: 700; padding: 2px 6px; border-radius: 999px; margin-left: 4px;
}
#billing-monthly:checked ~ .billing-toggle label[for="billing-monthly"],
#billing-yearly:checked ~ .billing-toggle label[for="billing-yearly"] {
background: white; color: #1E293B; box-shadow: 0 1px 4px rgba(0,0,0,0.12);
}
/* Default: monthly visible, yearly hidden */
.price-yearly { display: none; }
/* When yearly is selected */
#billing-yearly:checked ~ .pricing-grid .price-monthly { display: none; }
#billing-yearly:checked ~ .pricing-grid .price-yearly { display: block; }
.yearly-note { display: block; font-size: 0.6875rem; color: #16A34A; margin-bottom: 0.25rem; }
/* Pricing cards */ /* Pricing cards */
.pricing-grid { .pricing-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem;
max-width: 720px; margin: 0 auto; max-width: 960px; margin: 0 auto;
} }
.pricing-card { .pricing-card {
border: 1px solid #E2E8F0; border-radius: 16px; padding: 1.5rem; border: 1px solid #E2E8F0; border-radius: 16px; padding: 1.5rem;
background: white; position: relative; background: white; position: relative; display: flex; flex-direction: column;
display: flex; flex-direction: column;
} }
.pricing-card--highlight { .pricing-card--highlight {
border-color: #1D4ED8; border-width: 2px; border-color: #1D4ED8; border-width: 2px;
@@ -105,12 +153,13 @@
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
background: #1D4ED8; color: white; font-size: 0.6875rem; font-weight: 700; background: #1D4ED8; color: white; font-size: 0.6875rem; font-weight: 700;
padding: 4px 14px; border-radius: 999px; text-transform: uppercase; padding: 4px 14px; border-radius: 999px; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em; white-space: nowrap;
} }
.pricing-card h3 { font-size: 1.25rem; margin-bottom: 0.25rem; } .pricing-card h3 { font-size: 1.25rem; margin-bottom: 0.25rem; }
.pricing-card .price { font-size: 1.75rem; font-weight: 800; color: #1E293B; margin-bottom: 0.25rem; } .pricing-card .price { font-size: 1.75rem; font-weight: 800; color: #1E293B; margin-bottom: 0.25rem; }
.pricing-card .price span { font-size: 0.875rem; font-weight: 400; color: #64748B; } .pricing-card .price span { font-size: 0.875rem; font-weight: 400; color: #64748B; }
.pricing-card .credits-inc { font-size: 0.8125rem; color: #1D4ED8; font-weight: 600; margin-bottom: 1rem; } .pricing-card .credits-inc { font-size: 0.8125rem; color: #1D4ED8; font-weight: 600; margin-bottom: 1rem; }
.pricing-card .credits-inc--muted { color: #64748B; }
.pricing-card ul { list-style: none; padding: 0; margin: 0 0 1.5rem; flex-grow: 1; } .pricing-card ul { list-style: none; padding: 0; margin: 0 0 1.5rem; flex-grow: 1; }
.pricing-card li { .pricing-card li {
font-size: 0.8125rem; color: #475569; padding: 4px 0; font-size: 0.8125rem; color: #475569; padding: 4px 0;
@@ -124,67 +173,89 @@
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem; max-width: 720px; margin: 1.5rem auto 0; gap: 0.75rem; max-width: 720px; margin: 1.5rem auto 0;
} }
.boost-card { .boost-card { border: 1px solid #E2E8F0; border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.8125rem; }
border: 1px solid #E2E8F0; border-radius: 10px; padding: 0.75rem 1rem;
font-size: 0.8125rem;
}
.boost-card strong { color: #1E293B; display: block; margin-bottom: 2px; } .boost-card strong { color: #1E293B; display: block; margin-bottom: 2px; }
.boost-card .boost-price { color: #1D4ED8; font-weight: 700; font-size: 0.75rem; } .boost-card .boost-price { color: #1D4ED8; font-weight: 700; font-size: 0.75rem; }
.sup-why { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; } /* Comparison table */
.sup-why-card { .comparison-wrap { overflow-x: auto; max-width: 900px; margin: 0 auto; }
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem; .comparison-table {
text-align: center; width: 100%; border-collapse: collapse; font-size: 0.875rem; min-width: 580px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.comparison-table th, .comparison-table td {
padding: 0.75rem 1rem; border-bottom: 1px solid #E2E8F0; text-align: left;
}
.comparison-table thead th { font-weight: 700; color: #1E293B; font-size: 0.8125rem; }
.comparison-table thead .col-us { color: #1D4ED8; }
.comparison-table tbody tr:nth-child(even) td { background: #F8FAFC; }
.comparison-table td { color: #475569; }
.comparison-table td.col-us { color: #1D4ED8; font-weight: 600; }
.comparison-table td:first-child { font-weight: 600; color: #1E293B; white-space: nowrap; }
.comparison-table .check { color: #16A34A; font-weight: 700; }
.comparison-table .dash { color: #94A3B8; }
.comparison-footnote {
font-size: 0.75rem; color: #94A3B8; text-align: center;
margin: 0.75rem auto 0; max-width: 560px;
} }
.sup-why-card h3 { font-size: 0.9375rem; margin-bottom: 0.25rem; }
.sup-why-card p { font-size: 0.8125rem; color: #64748B; }
.sup-faq { max-width: 640px; margin: 0 auto; } /* Social proof */
.sup-faq details { .sup-proof-grid {
border-bottom: 1px solid #E2E8F0; padding: 1rem 0; display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;
max-width: 720px; margin: 0 auto;
} }
.sup-proof-card {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
padding: 1.75rem; text-align: left; position: relative;
}
.sup-proof-card::before {
content: "\201C"; font-size: 4rem; color: #DBEAFE; line-height: 1;
position: absolute; top: 1rem; left: 1.25rem; font-family: Georgia, serif;
}
.sup-proof-card blockquote {
font-size: 0.9375rem; color: #334155; font-style: italic;
line-height: 1.6; margin: 1.5rem 0 0.75rem; padding: 0;
}
.sup-proof-card cite { font-size: 0.8125rem; color: #94A3B8; font-style: normal; }
/* FAQ */
.sup-faq { max-width: 640px; margin: 0 auto; }
.sup-faq details { border-bottom: 1px solid #E2E8F0; padding: 1rem 0; }
.sup-faq summary { .sup-faq summary {
font-weight: 600; cursor: pointer; font-size: 0.9375rem; color: #1E293B; font-weight: 600; cursor: pointer; font-size: 0.9375rem; color: #1E293B; list-style: none;
list-style: none;
} }
.sup-faq summary::-webkit-details-marker { display: none; } .sup-faq summary::-webkit-details-marker { display: none; }
.sup-faq summary::before { content: "+ "; color: #1D4ED8; font-weight: 700; } .sup-faq summary::before { content: "+ "; color: #1D4ED8; font-weight: 700; }
.sup-faq details[open] summary::before { content: "- "; } .sup-faq details[open] summary::before { content: "- "; }
.sup-faq p { color: #64748B; font-size: 0.875rem; margin-top: 0.5rem; line-height: 1.6; } .sup-faq p { color: #64748B; font-size: 0.875rem; margin-top: 0.5rem; line-height: 1.6; }
.sup-cta { text-align: center; padding: 3rem 0; } /* Final CTA */
.sup-cta h2 { font-size: 1.5rem; margin-bottom: 0.5rem; } .sup-cta {
.sup-cta p { color: #64748B; margin-bottom: 1.5rem; } text-align: center; padding: 4rem 2rem; margin: 2rem 0;
background: #0F172A; border-radius: 20px;
/* Social proof */
.sup-proof {
max-width: 720px; margin: 0 auto; text-align: center;
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
padding: 2rem;
} }
.sup-proof blockquote { .sup-cta h2 { font-size: 1.75rem; margin-bottom: 0.75rem; color: white; }
font-size: 1rem; color: #334155; font-style: italic; .sup-cta p { color: #94A3B8; margin-bottom: 1.5rem; font-size: 1rem; }
line-height: 1.6; margin: 0 0 0.75rem;
}
.sup-proof cite { font-size: 0.8125rem; color: #94A3B8; font-style: normal; }
@media (max-width: 640px) { @media (max-width: 640px) {
.sup-stats { grid-template-columns: repeat(2, 1fr); } .sup-stats { grid-template-columns: repeat(2, 1fr); }
.sup-steps, .sup-why { grid-template-columns: 1fr; } .sup-steps, .sup-why, .sup-problem-grid, .sup-proof-grid { grid-template-columns: 1fr; }
.pricing-grid { grid-template-columns: 1fr; } .pricing-grid { grid-template-columns: 1fr; }
.credit-tiers { grid-template-columns: 1fr; } .credit-tiers { grid-template-columns: 1fr; }
.lead-preview-grid { grid-template-columns: 1fr; } .lead-preview-grid { grid-template-columns: 1fr; }
.sup-hero h1 { font-size: 1.75rem; }
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="container-page"> <main class="container-page">
<!-- Hero -->
<div class="sup-hero"> <div class="sup-hero">
<h1>Reach Entrepreneurs<br>Actively Planning Padel Projects</h1> <h1>Stop Chasing Cold Leads.<br>Meet Buyers Who Already Have a Business Plan.</h1>
<p>Padelnomics connects you with qualified leads who've already modeled their investment. No cold outreach. No wasted time.</p> <p>Every lead on Padelnomics has modeled their CAPEX, projected revenue, and calculated ROI &mdash; before they contact you. No tire-kickers. No &ldquo;just browsing.&rdquo;</p>
<a href="#pricing" class="btn">See Plans</a> <a href="#pricing" class="btn">See Plans &amp; Pricing</a>
<p class="sup-hero__proof">Trusted by suppliers in {{ total_countries }} countries</p>
</div> </div>
<!-- Live Stats --> <!-- Live Stats -->
@@ -207,6 +278,32 @@
</div> </div>
</div> </div>
<!-- Problem section -->
<section class="sup-section">
<div class="sup-problem">
<h2 style="margin-bottom:0.5rem">The Problem With Finding Padel Clients Today</h2>
<p style="text-align:center;color:#64748B;margin-bottom:0">Most channels waste your time and budget before you talk to a single serious buyer.</p>
<div class="sup-problem-grid">
<div class="sup-problem-card">
<div class="stat">&euro;10K+</div>
<h3>Trade Shows</h3>
<p>Per event. You meet hundreds of people. Maybe 3 are serious about building a padel facility.</p>
</div>
<div class="sup-problem-card">
<div class="stat">&euro;20&ndash;80</div>
<h3>Google Ads</h3>
<p>Per click. Most visitors are researching padel, not ready to spend &euro;300K+ on a construction project.</p>
</div>
<div class="sup-problem-card">
<div class="stat">&lt;2%</div>
<h3>Cold Outreach</h3>
<p>Response rate. Hours of emails and calls to reach entrepreneurs who aren&rsquo;t ready to buy yet.</p>
</div>
</div>
<p class="sup-problem-transition">What if every lead came with a complete project brief and a financial model?</p>
</div>
</section>
<!-- How it works --> <!-- How it works -->
<section class="sup-section"> <section class="sup-section">
<h2>How It Works</h2> <h2>How It Works</h2>
@@ -214,18 +311,18 @@
<div class="sup-steps"> <div class="sup-steps">
<div class="sup-step"> <div class="sup-step">
<div class="sup-step__num">1</div> <div class="sup-step__num">1</div>
<h3>Choose Your Plan</h3> <h3>Claim Your Listing</h3>
<p>Your company is already in our directory. Pick a plan to upgrade your listing and start receiving leads.</p> <p>Your company is already in our directory. Pick a plan to upgrade your listing and unlock access to the lead feed.</p>
</div> </div>
<div class="sup-step"> <div class="sup-step">
<div class="sup-step__num">2</div> <div class="sup-step__num">2</div>
<h3>Unlock Leads with Credits</h3> <h3>Browse Pre-Qualified Leads</h3>
<p>Browse verified leads in your region. Spend credits to unlock full project details and contact info.</p> <p>Every lead includes project specs, budget, timeline, and a financial model they built themselves. Spend credits only on leads that match your services.</p>
</div> </div>
<div class="sup-step"> <div class="sup-step">
<div class="sup-step__num">3</div> <div class="sup-step__num">3</div>
<h3>Close Deals</h3> <h3>Win Projects Faster</h3>
<p>You receive pre-qualified leads with full project details: venue type, court count, budget, timeline, and more.</p> <p>Contact the entrepreneur directly. You already know their budget, timeline, and financing status &mdash; no discovery call needed.</p>
</div> </div>
</div> </div>
@@ -328,7 +425,7 @@
<div class="sup-why"> <div class="sup-why">
<div class="sup-why-card"> <div class="sup-why-card">
<h3>Pre-Qualified</h3> <h3>Pre-Qualified</h3>
<p>Leads come through our financial planner. They've modeled CAPEX, revenue, and ROI before contacting you.</p> <p>Leads come through our financial planner. They&rsquo;ve modeled CAPEX, revenue, and ROI before contacting you.</p>
</div> </div>
<div class="sup-why-card"> <div class="sup-why-card">
<h3>Full Project Brief</h3> <h3>Full Project Brief</h3>
@@ -343,22 +440,60 @@
<!-- Pricing --> <!-- Pricing -->
<section id="pricing" class="sup-section"> <section id="pricing" class="sup-section">
<!-- Hidden radio inputs MUST come before the elements they control (CSS sibling selector) -->
<input type="radio" id="billing-monthly" name="billing" checked>
<input type="radio" id="billing-yearly" name="billing">
<h2>Plans &amp; Pricing</h2> <h2>Plans &amp; Pricing</h2>
<p class="sub">Choose the plan that fits your growth goals.</p> <p class="sub">Choose the plan that fits your growth goals.</p>
<div class="billing-toggle">
<label for="billing-monthly">Monthly</label>
<label for="billing-yearly">Yearly <span class="save-badge">Save up to 26%</span></label>
</div>
<div class="pricing-grid"> <div class="pricing-grid">
<!-- Basic -->
<div class="pricing-card">
<h3>Basic</h3>
<div class="price-monthly">
<div class="price">&euro;39 <span>/mo</span></div>
</div>
<div class="price-yearly">
<div class="price">&euro;29 <span>/mo</span></div>
<span class="yearly-note">&euro;349 billed yearly</span>
</div>
<div class="credits-inc credits-inc--muted">Directory listing</div>
<ul>
<li>Verified &#10003; badge</li>
<li>Company logo</li>
<li>Full description &amp; tagline</li>
<li>Website &amp; contact details</li>
<li>Services offered checklist</li>
<li>Enquiry form on listing page</li>
</ul>
<a href="{{ url_for('suppliers.signup') }}?plan=supplier_basic" class="btn-outline" style="display:block;text-align:center">Get Listed</a>
</div>
<!-- Growth --> <!-- Growth -->
<div class="pricing-card pricing-card--highlight"> <div class="pricing-card pricing-card--highlight">
<div class="pricing-card__popular">Most Popular</div> <div class="pricing-card__popular">Most Popular</div>
<h3>Growth</h3> <h3>Growth</h3>
<div class="price">&euro;199 <span>/mo</span></div> <div class="price-monthly">
<div class="price">&euro;199 <span>/mo</span></div>
</div>
<div class="price-yearly">
<div class="price">&euro;150 <span>/mo</span></div>
<span class="yearly-note">&euro;1,799 billed yearly</span>
</div>
<div class="credits-inc">30 credits/mo included</div> <div class="credits-inc">30 credits/mo included</div>
<ul> <ul>
<li>Company name &amp; category badge</li> <li>Everything in Basic</li>
<li>City &amp; country shown</li>
<li>Description visible</li>
<li>"Growth" badge</li>
<li>Priority over free listings</li>
<li>Access to lead feed</li> <li>Access to lead feed</li>
<li>&ldquo;Growth&rdquo; badge on listing</li>
<li>Priority over free listings</li>
<li>30 lead credits per month</li>
<li>Buy additional credit packs</li>
</ul> </ul>
<a href="{{ url_for('suppliers.signup') }}?plan=supplier_growth" class="btn" style="display:block;text-align:center">Get Started</a> <a href="{{ url_for('suppliers.signup') }}?plan=supplier_growth" class="btn" style="display:block;text-align:center">Get Started</a>
</div> </div>
@@ -366,15 +501,21 @@
<!-- Pro --> <!-- Pro -->
<div class="pricing-card"> <div class="pricing-card">
<h3>Pro</h3> <h3>Pro</h3>
<div class="price">&euro;499 <span>/mo</span></div> <div class="price-monthly">
<div class="price">&euro;499 <span>/mo</span></div>
</div>
<div class="price-yearly">
<div class="price">&euro;375 <span>/mo</span></div>
<span class="yearly-note">&euro;4,499 billed yearly</span>
</div>
<div class="credits-inc">100 credits/mo included</div> <div class="credits-inc">100 credits/mo included</div>
<ul> <ul>
<li>Everything in Growth</li> <li>Everything in Growth</li>
<li>Logo &amp; cover photo</li> <li>Cover photo on listing</li>
<li>Full stats (projects, years, area)</li> <li>Full stats (projects, years, area)</li>
<li>Verified &#10003; badge</li>
<li>Featured card border &amp; glow</li> <li>Featured card border &amp; glow</li>
<li>Priority placement</li> <li>Priority placement in directory</li>
<li>100 lead credits per month</li>
</ul> </ul>
<a href="{{ url_for('suppliers.signup') }}?plan=supplier_pro" class="btn-outline" style="display:block;text-align:center">Get Started</a> <a href="{{ url_for('suppliers.signup') }}?plan=supplier_pro" class="btn-outline" style="display:block;text-align:center">Get Started</a>
</div> </div>
@@ -407,13 +548,83 @@
</div> </div>
</section> </section>
<!-- Comparison table -->
<section class="sup-section">
<h2>How We Compare</h2>
<p class="sub">Your prospects are already weighing these alternatives. Here&rsquo;s the honest comparison.</p>
<div class="comparison-wrap">
<table class="comparison-table">
<thead>
<tr>
<th></th>
<th class="col-us">Padelnomics Growth</th>
<th>Trade Show Booth</th>
<th>Google Ads</th>
<th>Cold Directory</th>
</tr>
</thead>
<tbody>
<tr>
<td>Annual cost</td>
<td class="col-us">&euro;1,799/yr</td>
<td>&euro;10,000+/event</td>
<td>&euro;5,000+/yr*</td>
<td>&euro;600/yr</td>
</tr>
<tr>
<td>Lead quality</td>
<td class="col-us">Pre-qualified with business plan</td>
<td>Mixed, mostly browsing</td>
<td>Cold, searching</td>
<td>None (listing only)</td>
</tr>
<tr>
<td>Leads included</td>
<td class="col-us">30 credits/mo</td>
<td class="dash">&mdash;</td>
<td class="dash">Pay per click</td>
<td class="dash">&mdash;</td>
</tr>
<tr>
<td>Project details</td>
<td class="col-us">Full specs + financial model</td>
<td>Business cards only</td>
<td>None</td>
<td>None</td>
</tr>
<tr>
<td>Time to first lead</td>
<td class="col-us">Same day</td>
<td>Months away</td>
<td>Days</td>
<td>Never</td>
</tr>
<tr>
<td>Matches your services</td>
<td class="col-us check">&#10003; Filtered by category</td>
<td class="dash">&mdash;</td>
<td class="dash">&mdash;</td>
<td class="dash">&mdash;</td>
</tr>
</tbody>
</table>
</div>
<p class="comparison-footnote">*Google Ads estimate based on &euro;20&ndash;80 CPC for padel construction keywords at 5&ndash;10 clicks/day.</p>
</section>
<!-- Social proof --> <!-- Social proof -->
<section class="sup-section"> <section class="sup-section">
<h2>Trusted by Padel Industry Leaders</h2> <h2>Trusted by Padel Industry Leaders</h2>
<p class="sub">Suppliers across {{ total_countries }} countries use Padelnomics to reach new customers.</p> <p class="sub">{{ calc_requests }}+ business plans created &middot; {{ total_suppliers }}+ suppliers &middot; {{ total_countries }} countries</p>
<div class="sup-proof"> <div class="sup-proof-grid">
<blockquote>"Padelnomics sends us leads that are already serious about building. The project briefs are more detailed than what we get from trade shows."</blockquote> <div class="sup-proof-card">
<cite>&mdash; European padel court manufacturer</cite> <blockquote>&ldquo;Padelnomics sends us leads that are already serious about building. The project briefs are more detailed than what we get from trade shows.&rdquo;</blockquote>
<cite>&mdash; European padel court manufacturer</cite>
</div>
<div class="sup-proof-card">
<blockquote>&ldquo;Finally a platform that understands the padel construction market. We know the budget, the timeline, and the venue type before we even make first contact.&rdquo;</blockquote>
<cite>&mdash; Padel court installation company, Scandinavia</cite>
</div>
</div> </div>
</section> </section>
@@ -423,11 +634,19 @@
<div class="sup-faq"> <div class="sup-faq">
<details> <details>
<summary>How do I claim my listing?</summary> <summary>How do I claim my listing?</summary>
<p>Find your company in our <a href="{{ url_for('directory.index') }}">directory</a> and click "Is this your company?" We'll verify your identity and give you access to choose a plan and upgrade your profile.</p> <p>Find your company in our <a href="{{ url_for('directory.index') }}">directory</a> and click &ldquo;Is this your company?&rdquo; We&rsquo;ll verify your identity and give you access to choose a plan and upgrade your profile.</p>
</details> </details>
<details> <details>
<summary>How much does it cost?</summary> <summary>How much does it cost?</summary>
<p>We offer two plans: Growth (&euro;149/mo, 30 credits) with description, badge, and priority placement; and Pro (&euro;399/mo, 100 credits) with logo, website, verified badge, and maximum visibility. Optional boost add-ons are available on top.</p> <p>We offer three plans: Basic (&euro;39/mo) for a verified directory listing with enquiry form; Growth (&euro;199/mo, 30 credits) with full lead access and priority placement; and Pro (&euro;499/mo, 100 credits) for maximum visibility and lead volume. Yearly billing saves up to 26% &mdash; Basic at &euro;349/yr, Growth at &euro;1,799/yr, Pro at &euro;4,499/yr. Optional boost add-ons are available on top.</p>
</details>
<details>
<summary>What makes Padelnomics leads different from other platforms?</summary>
<p>Every lead on Padelnomics has used our financial planning tool to model their project &mdash; CAPEX, revenue projections, ROI, and debt service coverage &mdash; before reaching out. This means they&rsquo;re serious, they have a realistic budget, and they&rsquo;re ready to talk to suppliers. You&rsquo;re not getting cold enquiries; you&rsquo;re getting pre-qualified project briefs.</p>
</details>
<details>
<summary>How does pricing compare to alternatives?</summary>
<p>A trade show booth costs &euro;10,000+ per event and delivers mostly browsing contacts. Google Ads for padel construction keywords run &euro;20&ndash;80 per click &mdash; that&rsquo;s &euro;5,000+/yr before you talk to a single prospect. A typical cold directory listing charges ~&euro;600/yr with no leads at all. Padelnomics Growth at &euro;1,799/yr includes 30 lead credits per month with full project briefs.</p>
</details> </details>
<details> <details>
<summary>How do credits work?</summary> <summary>How do credits work?</summary>
@@ -443,24 +662,25 @@
</details> </details>
<details> <details>
<summary>Which countries do you cover?</summary> <summary>Which countries do you cover?</summary>
<p>Padelnomics has suppliers listed across {{ total_countries }} countries. Our strongest coverage is in Europe (Germany, Spain, Sweden, UK, Netherlands, Italy) but we're growing globally as padel expands.</p> <p>Padelnomics has suppliers listed across {{ total_countries }} countries. Our strongest coverage is in Europe (Germany, Spain, Sweden, UK, Netherlands, Italy) but we&rsquo;re growing globally as padel expands.</p>
</details> </details>
<details> <details>
<summary>Can I cancel anytime?</summary> <summary>Can I cancel anytime?</summary>
<p>Yes. You can cancel your subscription at any time from your dashboard. Your listing stays active until the end of the current billing period. Unused credits are forfeited upon cancellation.</p> <p>Yes. You can cancel your subscription at any time from your dashboard. Your listing stays active until the end of the current billing period. Unused credits are forfeited upon cancellation.</p>
</details> </details>
<details> <details>
<summary>My company isn't listed. How do I get added?</summary> <summary>My company isn&rsquo;t listed. How do I get added?</summary>
<p>Email us at {{ config.ADMIN_EMAIL }} with your company details and we'll add you to the directory within 48 hours.</p> <p>Email us at {{ config.ADMIN_EMAIL }} with your company details and we&rsquo;ll add you to the directory within 48 hours.</p>
</details> </details>
</div> </div>
</section> </section>
<!-- Final CTA --> <!-- Final CTA -->
<section class="sup-cta"> <section class="sup-cta">
<h2>Ready to Receive Qualified Leads?</h2> <h2>Your Next Client Is Already Building a Business Plan</h2>
<p>Choose a plan and start getting matched with padel entrepreneurs today.</p> <p>They&rsquo;ve modeled the ROI. They know their budget. They&rsquo;re looking for a supplier like you.</p>
<a href="#pricing" class="btn">See Plans</a> <a href="#pricing" class="btn">See Plans &amp; Pricing</a>
</section> </section>
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -2,6 +2,10 @@
{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %} {% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Terms of Service for Padelnomics — the padel court investment planning platform. Read our usage terms, disclaimer, and liability policy.">
{% endblock %}
{% block content %} {% block content %}
<main class="container-page py-12"> <main class="container-page py-12">
<div class="card max-w-3xl mx-auto"> <div class="card max-w-3xl mx-auto">

View File

@@ -368,6 +368,17 @@ def main():
(owner_id, now.isoformat(), sid), (owner_id, now.isoformat(), sid),
) )
# Create billing customer record
existing_bc = conn.execute(
"SELECT id FROM billing_customers WHERE user_id = ?", (owner_id,)
).fetchone()
if not existing_bc:
conn.execute(
"""INSERT INTO billing_customers (user_id, provider_customer_id, created_at)
VALUES (?, ?, ?)""",
(owner_id, f"ctm_dev_{slug}", now.isoformat()),
)
# Create active subscription # Create active subscription
existing_sub = conn.execute( existing_sub = conn.execute(
"SELECT id FROM subscriptions WHERE user_id = ?", (owner_id,) "SELECT id FROM subscriptions WHERE user_id = ?", (owner_id,)
@@ -375,10 +386,10 @@ def main():
if not existing_sub: if not existing_sub:
conn.execute( conn.execute(
"""INSERT INTO subscriptions """INSERT INTO subscriptions
(user_id, plan, status, paddle_customer_id, paddle_subscription_id, (user_id, plan, status, provider_subscription_id,
current_period_end, created_at) current_period_end, created_at)
VALUES (?, ?, 'active', ?, ?, ?, ?)""", VALUES (?, ?, 'active', ?, ?, ?)""",
(owner_id, plan, f"ctm_dev_{slug}", f"sub_dev_{slug}", (owner_id, plan, f"sub_dev_{slug}",
period_end, now.isoformat()), period_end, now.isoformat()),
) )
print(f" {slug} -> owner {email} ({plan})") print(f" {slug} -> owner {email} ({plan})")
@@ -465,8 +476,8 @@ def main():
conn.close() conn.close()
print(f"\nDone! Seed data written to {db_path}") print(f"\nDone! Seed data written to {db_path}")
print(" Login: dev@localhost (use magic link or admin impersonation)") print(" Login: /auth/dev-login?email=dev@localhost")
print(" Admin: /admin with password 'admin'") print(" Admin: set ADMIN_EMAILS=dev@localhost in .env, then dev-login grants admin role")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -18,7 +18,8 @@
/* ── Brand Theme ── */ /* ── Brand Theme ── */
@theme { @theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; --font-display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
--font-sans: "DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Commit Mono", ui-monospace, monospace; --font-mono: "Commit Mono", ui-monospace, monospace;
--color-navy: #0F172A; --color-navy: #0F172A;
@@ -26,6 +27,7 @@
--color-electric: #1D4ED8; --color-electric: #1D4ED8;
--color-electric-hover: #1E40AF; --color-electric-hover: #1E40AF;
--color-accent: #16A34A; --color-accent: #16A34A;
--color-forest: #064E3B;
--color-soft-white: #F8FAFC; --color-soft-white: #F8FAFC;
--color-light-gray: #E2E8F0; --color-light-gray: #E2E8F0;
--color-mid-gray: #CBD5E1; --color-mid-gray: #CBD5E1;
@@ -42,6 +44,7 @@
@apply bg-soft-white text-slate-dark font-sans antialiased; @apply bg-soft-white text-slate-dark font-sans antialiased;
} }
h1, h2, h3 { h1, h2, h3 {
font-family: var(--font-display);
@apply text-navy font-bold tracking-tight; @apply text-navy font-bold tracking-tight;
} }
h4, h5, h6 { h4, h5, h6 {
@@ -62,8 +65,10 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
background: #ffffff; background: rgba(255, 255, 255, 0.82);
border-bottom: 1px solid #E2E8F0; backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(226, 232, 240, 0.7);
} }
.nav-inner { .nav-inner {
max-width: 72rem; max-width: 72rem;

View File

@@ -27,7 +27,7 @@
--cta-bg: #EFF6FF; --cta-bg: #EFF6FF;
--cta-glow: rgba(29,78,216,0.10); --cta-glow: rgba(29,78,216,0.10);
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
font-size: 14px; font-size: 14px;
color: var(--txt); color: var(--txt);
background: var(--bg); background: var(--bg);
@@ -51,6 +51,7 @@
gap: 1rem; gap: 1rem;
} }
.planner-header h1 { .planner-header h1 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 800; font-weight: 800;
color: var(--head); color: var(--head);
@@ -71,7 +72,7 @@
font-size: 11px; font-size: 11px;
color: var(--txt-2); color: var(--txt-2);
margin-left: auto; margin-left: auto;
font-family: 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
} }
/* ── Scenario controls ── */ /* ── Scenario controls ── */
@@ -88,7 +89,7 @@
background: transparent; background: transparent;
color: var(--txt-2); color: var(--txt-2);
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
font-weight: 500; font-weight: 500;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -118,7 +119,7 @@
color: var(--txt-3); color: var(--txt-3);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -177,7 +178,7 @@
.metric-card__value { .metric-card__value {
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
font-family: 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
line-height: 1.2; line-height: 1.2;
} }
.metric-card__sub { .metric-card__sub {
@@ -272,7 +273,7 @@
padding: 4px 8px; padding: 4px 8px;
text-align: right; text-align: right;
font-size: 12px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
color: var(--head); color: var(--head);
outline: none; outline: none;
} }
@@ -330,7 +331,7 @@
color: var(--txt-3); color: var(--txt-3);
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
transition: all 0.15s; transition: all 0.15s;
} }
.toggle-btn--active { .toggle-btn--active {
@@ -376,7 +377,7 @@
color: var(--txt-3); color: var(--txt-3);
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
transition: all 0.15s; transition: all 0.15s;
} }
.pill-btn:hover { .pill-btn:hover {
@@ -426,11 +427,11 @@
} }
.data-table td { .data-table td {
padding: 6px 8px; padding: 6px 8px;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
color: var(--txt); color: var(--txt);
} }
.data-table td.mono { .data-table td.mono {
font-family: 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
text-align: right; text-align: right;
} }
.data-table tr:hover { .data-table tr:hover {
@@ -485,7 +486,7 @@
margin-left: 4px; margin-left: 4px;
flex-shrink: 0; flex-shrink: 0;
font-style: italic; font-style: italic;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
vertical-align: middle; vertical-align: middle;
} }
.ti .tp { .ti .tp {
@@ -684,7 +685,7 @@
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
box-shadow: 0 2px 10px rgba(29,78,216,0.25); box-shadow: 0 2px 10px rgba(29,78,216,0.25);
transition: background 0.15s; transition: background 0.15s;
} }
@@ -762,7 +763,7 @@
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
box-shadow: 0 2px 10px rgba(29,78,216,0.25); box-shadow: 0 2px 10px rgba(29,78,216,0.25);
transition: background 0.15s; transition: background 0.15s;
text-align: center; text-align: center;
@@ -788,7 +789,7 @@
font-size: 12px; font-size: 12px;
} }
.waterfall-row__label { color: var(--txt-2); } .waterfall-row__label { color: var(--txt-2); }
.waterfall-row__value { font-family: 'JetBrains Mono', monospace; font-weight: 600; } .waterfall-row__value { font-family: 'Commit Mono', ui-monospace, monospace; font-weight: 600; }
/* ── Court summary cards ── */ /* ── Court summary cards ── */
.court-summary { .court-summary {
@@ -898,7 +899,7 @@
background: transparent; background: transparent;
color: var(--txt-3); color: var(--txt-3);
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
transition: all 0.15s; transition: all 0.15s;
white-space: nowrap; white-space: nowrap;
} }
@@ -946,6 +947,7 @@
display: block; display: block;
} }
.wizard-step__title { .wizard-step__title {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 800; font-weight: 800;
color: var(--head); color: var(--head);
@@ -991,7 +993,7 @@
.wiz-preview__value { .wiz-preview__value {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
font-family: 'JetBrains Mono', monospace; font-family: 'Commit Mono', ui-monospace, monospace;
color: var(--head); color: var(--head);
line-height: 1.4; line-height: 1.4;
} }
@@ -1022,7 +1024,7 @@
color: var(--txt-2); color: var(--txt-2);
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
transition: all 0.15s; transition: all 0.15s;
} }
.wiz-btn--back:hover { .wiz-btn--back:hover {
@@ -1039,7 +1041,7 @@
color: #fff; color: #fff;
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
transition: all 0.15s; transition: all 0.15s;
box-shadow: 0 2px 10px var(--cta-shadow); box-shadow: 0 2px 10px var(--cta-shadow);
} }
@@ -1054,7 +1056,7 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
font-family: 'Inter', sans-serif; font-family: 'DM Sans', sans-serif;
padding: 4px; padding: 4px;
} }
.wiz-skip:hover { .wiz-skip:hover {

View File

@@ -21,7 +21,7 @@
.bst-boost__price { font-size: 0.8125rem; font-weight: 700; color: #1D4ED8; } .bst-boost__price { font-size: 0.8125rem; font-weight: 700; color: #1D4ED8; }
.bst-boost__status { font-size: 0.6875rem; font-weight: 700; color: #16A34A; } .bst-boost__status { font-size: 0.6875rem; font-weight: 700; color: #16A34A; }
.bst-credits-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; } .bst-credits-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.75rem; }
.bst-credit-card { .bst-credit-card {
border: 1px solid #E2E8F0; border-radius: 10px; padding: 1rem; text-align: center; border: 1px solid #E2E8F0; border-radius: 10px; padding: 1rem; text-align: center;
} }
@@ -53,7 +53,7 @@
{% set boost_monthly = 0 %} {% set boost_monthly = 0 %}
<div class="bst-layout"> <div class="bst-layout">
<div> <div style="max-width:720px">
<!-- Current Plan --> <!-- Current Plan -->
<div class="bst-section"> <div class="bst-section">
<h3>Current Plan</h3> <h3>Current Plan</h3>

View File

@@ -23,11 +23,17 @@
.dl-bidders { font-size: 0.6875rem; margin-top: 4px; } .dl-bidders { font-size: 0.6875rem; margin-top: 4px; }
.dl-bidders--first { color: #16A34A; font-weight: 600; } .dl-bidders--first { color: #16A34A; font-weight: 600; }
.dl-bidders--many { color: #94A3B8; } .dl-bidders--many { color: #94A3B8; }
.dl-search {
width: 100%; padding: 8px 12px 8px 36px; border: 1px solid #E2E8F0; border-radius: 10px;
font-size: 0.8125rem; font-family: inherit; margin-bottom: 0.75rem;
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394A3B8' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z'/%3E%3C/svg%3E") no-repeat 10px center;
background-size: 16px;
}
</style> </style>
<div class="dl-top"> <div class="dl-top">
<h2 style="font-size:1.25rem;margin:0">Lead Feed</h2> <h2 style="font-size:1.25rem;margin:0">Lead Feed</h2>
<div class="dl-balance"> <div class="dl-balance" id="dl-credit-balance">
{{ supplier.credit_balance }} credits {{ supplier.credit_balance }} credits
<a href="{{ url_for('suppliers.dashboard', tab='boosts') }}" <a href="{{ url_for('suppliers.dashboard', tab='boosts') }}"
hx-get="{{ url_for('suppliers.dashboard_boosts') }}" hx-get="{{ url_for('suppliers.dashboard_boosts') }}"
@@ -36,6 +42,13 @@
</div> </div>
</div> </div>
<input type="search" name="q" class="dl-search" placeholder="Search leads by country, type, details..."
value="{{ current_q if current_q is defined else '' }}"
hx-get="{{ url_for('suppliers.dashboard_leads') }}"
hx-trigger="input changed delay:300ms"
hx-target="#dashboard-content"
hx-include="[name='heat'],[name='country'],[name='timeline']">
<div class="dl-filters" <div class="dl-filters"
hx-target="#dashboard-content" hx-target="#dashboard-content"
hx-push-url="false"> hx-push-url="false">
@@ -55,15 +68,15 @@
<select class="dl-pill" style="appearance:auto;padding-right:24px" <select class="dl-pill" style="appearance:auto;padding-right:24px"
hx-get="{{ url_for('suppliers.dashboard_leads') }}" hx-get="{{ url_for('suppliers.dashboard_leads') }}"
hx-target="#dashboard-content" hx-target="#dashboard-content"
hx-include="[name='heat_hidden'],[name='timeline_hidden']" hx-include="[name='heat'],[name='timeline']"
name="country"> name="country">
<option value="">All countries</option> <option value="">All countries</option>
{% for c in countries %} {% for c in countries %}
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option> <option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
{% endfor %} {% endfor %}
</select> </select>
<input type="hidden" name="heat_hidden" value="{{ current_heat }}"> <input type="hidden" name="heat" value="{{ current_heat }}">
<input type="hidden" name="timeline_hidden" value="{{ current_timeline }}"> <input type="hidden" name="timeline" value="{{ current_timeline }}">
<div class="dl-sep"></div> <div class="dl-sep"></div>
@@ -130,7 +143,11 @@
.lf-card { .lf-card {
background: white; border: 1px solid #E2E8F0; border-radius: 14px; background: white; border: 1px solid #E2E8F0; border-radius: 14px;
padding: 1.25rem; transition: box-shadow 0.15s; padding: 1.25rem; transition: box-shadow 0.15s;
border-left: 3px solid #E2E8F0;
} }
.lf-card:has(.lf-card__heat--hot) { border-left-color: #DC2626; }
.lf-card:has(.lf-card__heat--warm) { border-left-color: #D97706; }
.lf-card:has(.lf-card__heat--cool) { border-left-color: #94A3B8; }
.lf-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); } .lf-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
.lf-card__heat { .lf-card__heat {
display: inline-block; font-size: 0.625rem; font-weight: 700; display: inline-block; font-size: 0.625rem; font-weight: 700;
@@ -149,12 +166,15 @@
.lf-unlock-btn { .lf-unlock-btn {
padding: 8px 20px; font-size: 0.75rem; font-weight: 700; border: none; padding: 8px 20px; font-size: 0.75rem; font-weight: 700; border: none;
background: #1D4ED8; color: white; border-radius: 8px; cursor: pointer; background: #1D4ED8; color: white; border-radius: 8px; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: inherit;
} }
.lf-unlock-btn:hover { background: #1E40AF; } .lf-unlock-btn:hover { background: #1E40AF; }
.lf-card--unlocked { border-color: #BBF7D0; background: #F0FDF4; } .lf-card--unlocked { border-color: #BBF7D0; background: #F0FDF4; }
.lf-contact { background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 8px; padding: 0.75rem; margin-top: 0.75rem; } .lf-contact { background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 8px; padding: 0.75rem; margin-top: 0.75rem; }
.lf-contact dt { color: #94A3B8; font-size: 0.6875rem; } .lf-contact dt { color: #94A3B8; font-size: 0.6875rem; }
.lf-contact dd { color: #1E293B; font-weight: 500; margin: 0 0 4px; font-size: 0.8125rem; } .lf-contact dd { color: #1E293B; font-weight: 500; margin: 0 0 4px; font-size: 0.8125rem; }
.lf-section { margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid #F1F5F9; }
.lf-section:last-of-type { border-bottom: none; }
.lf-section__title { font-size: 0.625rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #94A3B8; margin-bottom: 4px; }
.lf-error { color: #DC2626; font-size: 0.8125rem; padding: 8px; background: #FEF2F2; border-radius: 8px; text-align: center; } .lf-error { color: #DC2626; font-size: 0.8125rem; padding: 8px; background: #FEF2F2; border-radius: 8px; text-align: center; }
</style> </style>

View File

@@ -1,33 +1,74 @@
{# Human-readable labels for enum values #}
{% set timeline_labels = {'asap': 'ASAP', '3_6_months': '3-6 months', '6_12_months': '6-12 months', '12_plus': '12+ months', 'exploring': 'Exploring'} %}
{% set phase_labels = {'permit_granted': 'Permit granted', 'lease_signed': 'Lease signed', 'permit_pending': 'Permit pending', 'converting_existing': 'Converting existing', 'permit_not_filed': 'Permit not filed', 'location_found': 'Location found', 'searching': 'Searching'} %}
{% set financing_labels = {'self_funded': 'Self-funded', 'loan_approved': 'Loan approved', 'seeking': 'Seeking financing', 'not_started': 'Not started'} %}
{% set decision_labels = {'solo': 'Solo decision-maker', 'partners': 'With partners', 'board': 'Board/committee', 'investor': 'Investor-led'} %}
{% set contact_labels = {'received_quotes': 'Has received quotes', 'contacted': 'Has contacted suppliers', 'none': 'No prior contact'} %}
{% set stakeholder_labels = {'owner': 'Owner/Operator', 'investor': 'Investor', 'developer': 'Property Developer', 'club': 'Club/Association', 'other': 'Other'} %}
<div class="lf-card lf-card--unlocked"> <div class="lf-card lf-card--unlocked">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<span class="lf-card__heat lf-card__heat--{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span> <span class="lf-card__heat lf-card__heat--{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
<span style="font-size:0.6875rem;color:#16A34A;font-weight:600">&#10003; Unlocked</span> <span style="font-size:0.6875rem;color:#16A34A;font-weight:600">&#10003; Unlocked</span>
</div> </div>
<dl class="lf-card__meta"> {# --- Project --- #}
<dt>Facility</dt> <div class="lf-section">
<dd>{{ lead.facility_type or '-' }} ({{ lead.build_context or '-' }})</dd> <div class="lf-section__title">Project</div>
<dt>Courts</dt> <dl class="lf-card__meta">
<dd>{{ lead.court_count or '-' }} | Glass: {{ lead.glass_type or '-' }} | Lighting: {{ lead.lighting_type or '-' }}</dd> <dt>Facility</dt>
<dt>Location</dt> <dd>{{ lead.facility_type or '-' }}{% if lead.build_context %} ({{ lead.build_context }}){% endif %}</dd>
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd> <dt>Courts</dt>
<dt>Timeline</dt> <dd>{{ lead.court_count or '-' }}</dd>
<dd>{{ lead.timeline or '-' }}</dd> <dt>Glass</dt>
<dt>Budget</dt> <dd>{{ lead.glass_type or '-' }}</dd>
<dd>{% if lead.budget_estimate %}&euro;{{ lead.budget_estimate }}{% else %}-{% endif %}</dd> <dt>Lighting</dt>
<dt>Phase</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
<dd>{{ lead.location_status or '-' }}</dd> <dt>Budget</dt>
<dt>Financing</dt> <dd>{% if lead.budget_estimate %}&euro;{{ lead.budget_estimate }}{% else %}-{% endif %}</dd>
<dd>{{ lead.financing_status or '-' }}</dd> <dt>Services</dt>
<dt>Services</dt> <dd>{{ lead.services_needed or '-' }}</dd>
<dd>{{ lead.services_needed or '-' }}</dd> </dl>
</dl> </div>
{# --- Location & Timeline --- #}
<div class="lf-section">
<div class="lf-section__title">Location &amp; Timeline</div>
<dl class="lf-card__meta">
<dt>Location</dt>
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
<dt>Timeline</dt>
<dd>{{ timeline_labels.get(lead.timeline, lead.timeline) or '-' }}</dd>
<dt>Phase</dt>
<dd>{{ phase_labels.get(lead.location_status, lead.location_status) or '-' }}</dd>
</dl>
</div>
{# --- Readiness --- #}
<div class="lf-section">
<div class="lf-section__title">Readiness</div>
<dl class="lf-card__meta">
<dt>Financing</dt>
<dd>{{ financing_labels.get(lead.financing_status, lead.financing_status) or '-' }}</dd>
<dt>Wants financing help</dt>
<dd>{{ 'Yes' if lead.wants_financing_help else 'No' }}</dd>
<dt>Decision process</dt>
<dd>{{ decision_labels.get(lead.decision_process, lead.decision_process) or '-' }}</dd>
<dt>Prior supplier contact</dt>
<dd>{{ contact_labels.get(lead.previous_supplier_contact, lead.previous_supplier_contact) or '-' }}</dd>
</dl>
</div>
{% if lead.additional_info %} {% if lead.additional_info %}
<p style="font-size:0.75rem;color:#475569;margin:0.5rem 0;background:#F8FAFC;padding:8px;border-radius:6px">{{ lead.additional_info }}</p> <div class="lf-section">
<div class="lf-section__title">Notes</div>
<p style="font-size:0.75rem;color:#475569;background:#F8FAFC;padding:8px;border-radius:6px;margin:0">{{ lead.additional_info }}</p>
</div>
{% endif %} {% endif %}
{# --- Contact --- #}
<div class="lf-contact"> <div class="lf-contact">
<div class="lf-section__title" style="margin-bottom:6px">Contact</div>
<dl style="display:grid;grid-template-columns:80px 1fr;gap:2px 8px"> <dl style="display:grid;grid-template-columns:80px 1fr;gap:2px 8px">
<dt>Name</dt> <dt>Name</dt>
<dd>{{ lead.contact_name or '-' }}</dd> <dd>{{ lead.contact_name or '-' }}</dd>
@@ -38,11 +79,35 @@
<dt>Company</dt> <dt>Company</dt>
<dd>{{ lead.contact_company or '-' }}</dd> <dd>{{ lead.contact_company or '-' }}</dd>
<dt>Role</dt> <dt>Role</dt>
<dd>{{ lead.stakeholder_type or '-' }}</dd> <dd>{{ stakeholder_labels.get(lead.stakeholder_type, lead.stakeholder_type) or '-' }}</dd>
</dl> </dl>
</div> </div>
{# --- Scenario link --- #}
{% set sid = scenario_id if scenario_id is defined else (scenario_ids.get(lead.user_id) if scenario_ids is defined else none) %}
{% if sid %}
<a href="{{ url_for('planner.index') }}?scenario={{ sid }}" target="_blank"
style="display:block;text-align:center;margin-top:0.75rem;font-size:0.75rem;font-weight:600;color:#1D4ED8;text-decoration:none">
View their plan &rarr;
</a>
{% endif %}
{% if credit_cost is defined %} {% if credit_cost is defined %}
<p style="font-size:0.6875rem;color:#94A3B8;margin-top:0.5rem;text-align:center">{{ credit_cost }} credits used &middot; {{ supplier.credit_balance }} remaining</p> <p style="font-size:0.6875rem;color:#94A3B8;margin-top:0.5rem;text-align:center">{{ credit_cost }} credits used &middot; {{ supplier.credit_balance }} remaining</p>
{% endif %} {% endif %}
</div> </div>
{% if credit_cost is defined %}
{# OOB: update sidebar credits #}
<div id="sidebar-credits" hx-swap-oob="innerHTML">
{{ supplier.credit_balance }} credits
</div>
{# OOB: update header credits #}
<div id="dl-credit-balance" hx-swap-oob="innerHTML">
{{ supplier.credit_balance }} credits
<a href="{{ url_for('suppliers.dashboard', tab='boosts') }}"
hx-get="{{ url_for('suppliers.dashboard_boosts') }}"
hx-target="#dashboard-content"
hx-push-url="{{ url_for('suppliers.dashboard', tab='boosts') }}">Buy More</a>
</div>
{% endif %}

View File

@@ -11,7 +11,10 @@
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<!-- Fonts --> <!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/fontsource/fonts/commit-mono@latest/latin-400-normal.min.css" rel="stylesheet">
<!-- Tailwind (compiled) --> <!-- Tailwind (compiled) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
@@ -20,30 +23,41 @@
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"></script> <script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"></script>
<!-- Paddle.js --> <!-- Paddle.js -->
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script> <script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script> <script>
{% if config.PADDLE_ENVIRONMENT == "sandbox" %} document.addEventListener('DOMContentLoaded', function() {
Paddle.Environment.set("sandbox"); {% if config.PADDLE_ENVIRONMENT == "sandbox" %}
{% endif %} Paddle.Environment.set("sandbox");
{% if config.PADDLE_CLIENT_TOKEN %} {% endif %}
Paddle.Initialize({ {% if config.PADDLE_CLIENT_TOKEN %}
token: "{{ config.PADDLE_CLIENT_TOKEN }}", Paddle.Initialize({
eventCallback: function(ev) { token: "{{ config.PADDLE_CLIENT_TOKEN }}",
if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data); eventCallback: function(ev) {
}, if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data);
checkout: { },
settings: { checkout: {
displayMode: "overlay", settings: {
theme: "light", displayMode: "overlay",
locale: "en", theme: "light",
locale: "en",
}
} }
} });
{% else %}
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
{% endif %}
}); });
{% else %}
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
{% endif %}
</script> </script>
<!-- SEO defaults (child templates may override via {% block head %}) -->
<link rel="canonical" href="{{ config.BASE_URL }}{{ request.path }}">
<meta property="og:title" content="{{ config.APP_NAME }}">
<meta property="og:description" content="">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
<meta name="twitter:card" content="summary_large_image">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
@@ -56,8 +70,16 @@
</div> </div>
<!-- Center: logo --> <!-- Center: logo -->
<a href="{{ url_for('public.landing') }}" class="nav-logo"> <a href="{{ url_for('public.landing') }}" class="nav-logo" style="display:inline-flex;align-items:center;gap:6px;text-decoration:none">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}"> <svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="14" cy="12" r="10" stroke="#0F172A" stroke-width="2.2" fill="none"/>
<line x1="14" y1="2" x2="14" y2="22" stroke="#0F172A" stroke-width="1.2" opacity="0.35"/>
<line x1="4" y1="12" x2="24" y2="12" stroke="#0F172A" stroke-width="1.2" opacity="0.35"/>
<line x1="6.5" y1="4.5" x2="21.5" y2="19.5" stroke="#0F172A" stroke-width="1" opacity="0.2"/>
<line x1="21.5" y1="4.5" x2="6.5" y2="19.5" stroke="#0F172A" stroke-width="1" opacity="0.2"/>
<line x1="20" y1="20" x2="28" y2="30" stroke="#0F172A" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:1.125rem;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
</a> </a>
<!-- Right: supply side + auth --> <!-- Right: supply side + auth -->
@@ -86,7 +108,7 @@
<script>document.getElementById('feedback-page-url').value = window.location.pathname;</script> <script>document.getElementById('feedback-page-url').value = window.location.pathname;</script>
{% if user %} {% if user %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> <a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% if session.get('is_admin') %} {% if is_admin %}
<a href="{{ url_for('admin.index') }}" class="nav-badge">Admin</a> <a href="{{ url_for('admin.index') }}" class="nav-badge">Admin</a>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form"> <form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
@@ -119,7 +141,17 @@
<footer class="container-page mt-16 pt-8 border-t border-light-gray"> <footer class="container-page mt-16 pt-8 border-t border-light-gray">
<div class="grid-3 mb-8"> <div class="grid-3 mb-8">
<div> <div>
<div class="mb-1"><img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-6 w-auto"></div> <div class="mb-1" style="display:inline-flex;align-items:center;gap:5px">
<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="14" cy="12" r="10" stroke="#0F172A" stroke-width="2.2" fill="none"/>
<line x1="14" y1="2" x2="14" y2="22" stroke="#0F172A" stroke-width="1.2" opacity="0.35"/>
<line x1="4" y1="12" x2="24" y2="12" stroke="#0F172A" stroke-width="1.2" opacity="0.35"/>
<line x1="6.5" y1="4.5" x2="21.5" y2="19.5" stroke="#0F172A" stroke-width="1" opacity="0.2"/>
<line x1="21.5" y1="4.5" x2="6.5" y2="19.5" stroke="#0F172A" stroke-width="1" opacity="0.2"/>
<line x1="20" y1="20" x2="28" y2="30" stroke="#0F172A" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:0.9375rem;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
</div>
<p class="text-sm text-slate">Plan, finance, and build your padel business.</p> <p class="text-sm text-slate">Plan, finance, and build your padel business.</p>
</div> </div>
<div> <div>

View File

@@ -12,6 +12,7 @@ from padelnomics.billing.routes import (
get_subscription_by_provider_id, get_subscription_by_provider_id,
is_within_limits, is_within_limits,
update_subscription_status, update_subscription_status,
upsert_billing_customer,
upsert_subscription, upsert_subscription,
) )
from padelnomics.core import config from padelnomics.core import config
@@ -40,11 +41,11 @@ class TestGetSubscription:
class TestUpsertSubscription: class TestUpsertSubscription:
async def test_insert_new_subscription(self, db, test_user): async def test_insert_new_subscription(self, db, test_user):
await upsert_billing_customer(test_user["id"], "cust_abc")
sub_id = await upsert_subscription( sub_id = await upsert_subscription(
user_id=test_user["id"], user_id=test_user["id"],
plan="pro", plan="pro",
status="active", status="active",
provider_customer_id="cust_abc",
provider_subscription_id="sub_xyz", provider_subscription_id="sub_xyz",
current_period_end="2025-06-01T00:00:00Z", current_period_end="2025-06-01T00:00:00Z",
) )
@@ -52,33 +53,30 @@ class TestUpsertSubscription:
row = await get_subscription(test_user["id"]) row = await get_subscription(test_user["id"])
assert row["plan"] == "pro" assert row["plan"] == "pro"
assert row["status"] == "active" assert row["status"] == "active"
assert row["paddle_customer_id"] == "cust_abc" assert row["provider_subscription_id"] == "sub_xyz"
assert row["paddle_subscription_id"] == "sub_xyz"
assert row["current_period_end"] == "2025-06-01T00:00:00Z" assert row["current_period_end"] == "2025-06-01T00:00:00Z"
async def test_update_existing_subscription(self, db, test_user, create_subscription): async def test_update_existing_subscription(self, db, test_user, create_subscription):
original_id = await create_subscription( original_id = await create_subscription(
test_user["id"], plan="starter", status="active", test_user["id"], plan="starter", status="active",
paddle_subscription_id="sub_old", provider_subscription_id="sub_old",
) )
returned_id = await upsert_subscription( returned_id = await upsert_subscription(
user_id=test_user["id"], user_id=test_user["id"],
plan="pro", plan="pro",
status="active", status="active",
provider_customer_id="cust_new", provider_subscription_id="sub_old",
provider_subscription_id="sub_new",
) )
assert returned_id == original_id assert returned_id == original_id
row = await get_subscription(test_user["id"]) row = await get_subscription(test_user["id"])
assert row["plan"] == "pro" assert row["plan"] == "pro"
assert row["paddle_subscription_id"] == "sub_new" assert row["provider_subscription_id"] == "sub_old"
async def test_upsert_with_none_period_end(self, db, test_user): async def test_upsert_with_none_period_end(self, db, test_user):
await upsert_subscription( await upsert_subscription(
user_id=test_user["id"], user_id=test_user["id"],
plan="pro", plan="pro",
status="active", status="active",
provider_customer_id="cust_1",
provider_subscription_id="sub_1", provider_subscription_id="sub_1",
current_period_end=None, current_period_end=None,
) )
@@ -95,8 +93,8 @@ class TestGetSubscriptionByProviderId:
result = await get_subscription_by_provider_id("nonexistent") result = await get_subscription_by_provider_id("nonexistent")
assert result is None assert result is None
async def test_finds_by_paddle_subscription_id(self, db, test_user, create_subscription): async def test_finds_by_provider_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_findme") await create_subscription(test_user["id"], provider_subscription_id="sub_findme")
result = await get_subscription_by_provider_id("sub_findme") result = await get_subscription_by_provider_id("sub_findme")
assert result is not None assert result is not None
assert result["user_id"] == test_user["id"] assert result["user_id"] == test_user["id"]
@@ -108,14 +106,14 @@ class TestGetSubscriptionByProviderId:
class TestUpdateSubscriptionStatus: class TestUpdateSubscriptionStatus:
async def test_updates_status(self, db, test_user, create_subscription): async def test_updates_status(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_upd") await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_upd")
await update_subscription_status("sub_upd", status="cancelled") await update_subscription_status("sub_upd", status="cancelled")
row = await get_subscription(test_user["id"]) row = await get_subscription(test_user["id"])
assert row["status"] == "cancelled" assert row["status"] == "cancelled"
assert row["updated_at"] is not None assert row["updated_at"] is not None
async def test_updates_extra_fields(self, db, test_user, create_subscription): async def test_updates_extra_fields(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_extra") await create_subscription(test_user["id"], provider_subscription_id="sub_extra")
await update_subscription_status( await update_subscription_status(
"sub_extra", "sub_extra",
status="active", status="active",
@@ -128,7 +126,7 @@ class TestUpdateSubscriptionStatus:
assert row["current_period_end"] == "2026-01-01T00:00:00Z" assert row["current_period_end"] == "2026-01-01T00:00:00Z"
async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription): async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_known", status="active") await create_subscription(test_user["id"], provider_subscription_id="sub_known", status="active")
await update_subscription_status("sub_unknown", status="expired") await update_subscription_status("sub_unknown", status="expired")
row = await get_subscription(test_user["id"]) row = await get_subscription(test_user["id"])
assert row["status"] == "active" # unchanged assert row["status"] == "active" # unchanged
@@ -301,7 +299,7 @@ class TestLimitsHypothesis:
# Use upsert to avoid duplicate inserts across Hypothesis examples # Use upsert to avoid duplicate inserts across Hypothesis examples
await upsert_subscription( await upsert_subscription(
user_id=test_user["id"], plan="pro", status="active", user_id=test_user["id"], plan="pro", status="active",
provider_customer_id="cust_hyp", provider_subscription_id="sub_hyp", provider_subscription_id="sub_hyp",
) )
result = await is_within_limits(test_user["id"], "items", count) result = await is_within_limits(test_user["id"], "items", count)
assert result is True assert result is True

View File

@@ -82,7 +82,7 @@ class TestManageRoute:
assert response.status_code in (302, 303, 307) assert response.status_code in (302, 303, 307)
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription): async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test") await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_sub = MagicMock() mock_sub = MagicMock()
mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123" mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123"
@@ -108,7 +108,7 @@ class TestCancelRoute:
assert response.status_code in (302, 303, 307) assert response.status_code in (302, 303, 307)
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription): async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test") await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_client = MagicMock() mock_client = MagicMock()
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client): with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
@@ -123,7 +123,7 @@ class TestCancelRoute:
from quart import Blueprint # noqa: E402 from quart import Blueprint # noqa: E402
from padelnomics.billing.routes import subscription_required # noqa: E402 from padelnomics.auth.routes import subscription_required # noqa: E402
test_bp = Blueprint("test", __name__) test_bp = Blueprint("test", __name__)

View File

@@ -174,7 +174,7 @@ class TestWebhookSubscriptionActivated:
class TestWebhookSubscriptionUpdated: class TestWebhookSubscriptionUpdated:
async def test_updates_subscription_status(self, client, db, test_user, create_subscription): async def test_updates_subscription_status(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456") await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload( payload = make_webhook_payload(
"subscription.updated", "subscription.updated",
@@ -197,7 +197,7 @@ class TestWebhookSubscriptionUpdated:
class TestWebhookSubscriptionCanceled: class TestWebhookSubscriptionCanceled:
async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription): async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456") await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload( payload = make_webhook_payload(
"subscription.canceled", "subscription.canceled",
@@ -219,7 +219,7 @@ class TestWebhookSubscriptionCanceled:
class TestWebhookSubscriptionPastDue: class TestWebhookSubscriptionPastDue:
async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription): async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456") await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload( payload = make_webhook_payload(
"subscription.past_due", "subscription.past_due",
@@ -251,7 +251,7 @@ class TestWebhookSubscriptionPastDue:
]) ])
async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status): async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status):
if event_type != "subscription.activated": if event_type != "subscription.activated":
await create_subscription(test_user["id"], paddle_subscription_id="sub_test456") await create_subscription(test_user["id"], provider_subscription_id="sub_test456")
payload = make_webhook_payload(event_type, user_id=str(test_user["id"])) payload = make_webhook_payload(event_type, user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode() payload_bytes = json.dumps(payload).encode()

View File

@@ -744,11 +744,23 @@ class TestRouteRegistration:
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@pytest.fixture @pytest.fixture
async def admin_client(app): async def admin_client(app, db):
"""Test client with admin session.""" """Test client with admin user (has admin role)."""
from datetime import datetime
now = datetime.utcnow().isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c: async with app.test_client() as c:
async with c.session_transaction() as sess: async with c.session_transaction() as sess:
sess["is_admin"] = True sess["user_id"] = admin_id
yield c yield c

View File

@@ -314,6 +314,7 @@ class TestQuoteRequest:
"stakeholder_type": "entrepreneur", "stakeholder_type": "entrepreneur",
"contact_name": "Test User", "contact_name": "Test User",
"contact_email": "test@example.com", "contact_email": "test@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf, "csrf_token": csrf,
}, },
) )
@@ -345,6 +346,7 @@ class TestQuoteRequest:
"stakeholder_type": "entrepreneur", "stakeholder_type": "entrepreneur",
"contact_name": "Guest", "contact_name": "Guest",
"contact_email": "guest@example.com", "contact_email": "guest@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf, "csrf_token": csrf,
}, },
) )
@@ -374,6 +376,7 @@ class TestQuoteRequest:
"stakeholder_type": "entrepreneur", "stakeholder_type": "entrepreneur",
"contact_name": "Auth User", "contact_name": "Auth User",
"contact_email": "test@example.com", # matches test_user email "contact_email": "test@example.com", # matches test_user email
"contact_phone": "+491234567890",
"csrf_token": csrf, "csrf_token": csrf,
}, },
) )
@@ -404,6 +407,7 @@ class TestQuoteRequest:
"stakeholder_type": "developer", "stakeholder_type": "developer",
"contact_name": "Venue Search", "contact_name": "Venue Search",
"contact_email": "venue@example.com", "contact_email": "venue@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf, "csrf_token": csrf,
}, },
) )
@@ -432,6 +436,7 @@ class TestQuoteRequest:
"stakeholder_type": "tennis_club", "stakeholder_type": "tennis_club",
"contact_name": "Club Owner", "contact_name": "Club Owner",
"contact_email": "club@example.com", "contact_email": "club@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf, "csrf_token": csrf,
}, },
) )
@@ -460,6 +465,7 @@ class TestQuoteRequest:
"stakeholder_type": "entrepreneur", "stakeholder_type": "entrepreneur",
"contact_name": "Context Test", "contact_name": "Context Test",
"contact_email": "ctx@example.com", "contact_email": "ctx@example.com",
"contact_phone": "+491234567890",
"csrf_token": csrf, "csrf_token": csrf,
}, },
) )
@@ -487,7 +493,7 @@ class TestQuoteRequest:
assert resp.status_code == 422 assert resp.status_code == 422
data = await resp.get_json() data = await resp.get_json()
assert data["ok"] is False assert data["ok"] is False
assert len(data["errors"]) >= 3 # country, timeline, stakeholder_type + name + email assert len(data["errors"]) >= 3 # country, timeline, stakeholder_type + name + email + phone
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -505,6 +511,7 @@ class TestQuoteVerification:
"stakeholder_type": "entrepreneur", "stakeholder_type": "entrepreneur",
"contact_name": "Verify Test", "contact_name": "Verify Test",
"contact_email": "verify@example.com", "contact_email": "verify@example.com",
"contact_phone": "+491234567890",
} }
async def _submit_guest_quote(self, client, db, email="verify@example.com"): async def _submit_guest_quote(self, client, db, email="verify@example.com"):