add supplier tiers, directory redesign, CTA cleanup, and ROI fix

Phase 0 features: ungate planner, lead qualifier with heat scoring,
quote form (migrations 0002-0003), supplier directory with FTS5 search
(migration 0004), landing page redesign with ROI calculator and FAQ.

Phase 1 improvements: supplier tier system with Growth/Pro paid plans
(migration 0005), HTMX live directory search, three-tier card design,
Zillow-style sticky nav, "Get Matched" → "Get Quotes" CTA rename,
remove "Free" messaging site-wide, realistic ROI calculator defaults
(~3.9yr payback / ~26% ROI), mandatory form validation with 422 errors,
supplier pricing page with boost add-ons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-17 14:11:35 +01:00
parent 02d216bc94
commit fc410920d8
32 changed files with 4894 additions and 310 deletions

View File

@@ -6,6 +6,121 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added
- **Supplier tier system** — Migration 0005 adds `tier` (free/growth/pro),
`logo_url`, `is_verified`, `highlight`, `sticky_until`, `sticky_country`
columns to suppliers table for paid listing support
- **HTMX live search** — directory search input and filters update results
via `hx-get` with 300ms debounce; new `/directory/results` endpoint returns
swappable partial
- **Directory card tiers** — three-tier card design: Pro (green border, logo,
verified badge, website), Growth (description, blue badge), Free (muted,
unverified, "Is this your company?" CTA); sticky/featured suppliers pinned
to top with blue border
- **Supplier pricing page** — `/suppliers/` now shows Growth (€149/mo) and
Pro (€399/mo) plan cards with feature lists, boost add-ons grid (Logo,
Highlight, Verified Badge, Sticky Top, Newsletter Feature), updated FAQ
- **Mandatory form fields** — country, timeline, and stakeholder_type now
required on quote request form with server-side 422 validation
- **Validation test** — `test_quote_validation_rejects_missing_fields` verifies
server returns 422 JSON errors for missing mandatory fields
### Changed
- **Nav redesign** — Zillow-style sticky nav with backdrop-blur: demand-side
links (Planner, Directory) left, supply-side (For Suppliers, Help) after
separator, Sign In right; removed "Get Started Free" button
- **CTA text sweep** — "Get Matched" → "Get Quotes" across planner, landing,
and lead forms; removed all "Free" qualifiers from CTAs and badges
- **ROI calculator fix** — realistic cost model: €35K/court (was €25K),
staff costs, €8/sqm rent (was €4); payback and ROI now based on total
investment (was equity only); defaults: €40/hr rate, 35% utilization;
shows ~3.9yr payback, ~26% ROI (was 0.1yr/1255%)
- **Directory route refactor** — shared `_build_directory_query()` helper
with tier-based SQL ordering (sticky → pro → growth → free → alphabetical)
### Added
- **Supplier directory** — public searchable directory at `/directory/` with 279
padel court suppliers across 31 countries; FTS5 full-text search, country and
category filters, pagination, category-colored badges, unclaimed listing model
- **Supplier landing page** — `/suppliers/` marketing page for suppliers: hero,
how-it-works steps, example lead preview, FAQ, "Claim Your Listing" CTAs
- **Migration 0004** — creates `suppliers` table with FTS5 virtual table,
content-sync triggers, and seeds 279 suppliers from PadelDirectory.md
- **Quick ROI calculator** — landing page now features an interactive 3-slider
calculator (courts, rate, utilization) showing investment, monthly cash flow,
payback period, and annual ROI in real time
- **Supplier matching section** — landing page "Find the Right Suppliers" section
with 3-step flow and link to directory
- **FAQ accordion** — landing page FAQ covering planner features, signup
requirements, supplier matching, directory pricing, and projection accuracy
### Changed
- **Visual refresh** — adopted React prototype color palette and aesthetic
site-wide: royal blue primary (#1D4ED8), green (#16A34A), gold (#D97706);
elevated cards with soft shadows, rounder corners (rounded-2xl cards,
rounded-xl buttons/inputs), frosted-glass planner nav, highlighted CTA
regions with blue-tinted backgrounds, pill-shaped toggle/filter controls,
polished buttons with colored shadows, stronger hover lift on directory cards
- **Landing hero redesigned** — two-column layout with headline + CTAs on left
and interactive Quick ROI calculator on right (matching React prototype);
green badge pill, feature check bullets, "Open Full Planner" CTA inside
calculator card; responsive single-column on mobile
- **Landing page redesigned** — replaced screenshot card with Quick ROI
calculator; added supplier matching section, FAQ, and live supplier stats;
CTAs renamed "Open the Planner — Free"; Build journey card updated with
live supplier/country counts
- **Navbar** — Planner and Directory links now visible for all users (not just
logged-in); footer updated with Directory and For Suppliers links
- **Planner CTAs** — removed sticky "Get Builder Quotes" footer bar; CAPEX and
Returns tab CTAs now navigate to wizard step 5 (integrated lead qualifier)
instead of redirecting to standalone `/leads/quote` form
- **Sitemap** — added `/planner/`, `/directory/`, and `/suppliers/` URLs
### Changed
- **Planner wizard** — Assumptions tab reorganized into 5 guided steps
(Your Venue → Pricing → Costs → Finance → Get Matched) with live preview
bar and step navigation; reduces cognitive load from 60 sliders to ~6-15
per step
- **Integrated lead qualifier** — Step 5 "Get Matched" embeds the supplier
quote form directly in the planner; auto-fills venue, courts, glass,
lighting, country, budget from planner state; submits inline via fetch
- **JSON quote endpoint** — `POST /leads/quote` now accepts `application/json`
and returns `{"ok": true, "heat": "..."}` for inline planner submissions;
standalone HTML form unchanged
### Added — Phase 0 Round 2: Polish & Country-Specific Calculator
- **Country-specific calculator** — `country` selector (DE/ES/IT/FR/NL/SE/UK/Other) and `permitsCompliance` CAPEX item for Indoor Rent and Outdoor scenarios; country presets auto-adjust permit costs
- **Permits & Compliance** — new CAPEX line item for building permits, noise studies, and regulatory compliance (default €12K for Germany); excluded from Indoor Buy where Planning + Permits already covers this
- **Quote form redesign** — elevated white card on gradient background, green gradient CTA buttons, progress labels (Project/Details/Contact), privacy info box, mandatory consent checkbox
- **Project phase** (replaces location_status options) — 7-stage progression: still searching → location found → converting existing → lease signed → permit not filed → permit pending → permit granted; updated heat scoring
- **Stakeholder type** field — "You are..." selector (Entrepreneur, Tennis Club, Municipality, Developer, Operator, Architect) with `stakeholder_type` DB column (migration 0003)
- **Build context** — added "Need Help Finding a Venue / Land" (`venue_search`) option
- **Quote submitted page redesign** — "You're matched!" flow with next-steps timeline, email confirmation box, and signup CTA for guests
- **Migration 0003** — adds `stakeholder_type TEXT` column to `lead_requests`
### Changed
- **Landing page** — replaced teaser calculator with planner screenshot in browser-frame card + "Start Planning — Free" CTA; all CTAs now point to `/planner/` (no signup gate)
- **Heat score** — updated `calculate_heat_score()` for new project phase values (`permit_granted` +4, `lease_signed`/`permit_pending` +3, `converting_existing`/`permit_not_filed` +2, `location_found` +1)
- **Quote URL** — planner now passes `country` parameter to quote form prefill
- **Admin email** — includes stakeholder type and updated field labels
### Added — Phase 0: Ungate & Validate
- **Guest mode planner** — removed auth gate from `/planner/` and `/planner/calculate`; scenarios still require login
- **New calculator variables** — `budgetTarget` (budget vs CAPEX comparison), `glassType` (standard/panoramic, 1.4x multiplier), `lightingType` (LED standard/competition/natural, 1.5x/0x multipliers)
- **Pill select UI component** — reusable `pillSelect()` helper in planner.js with matching `.pill-btn` CSS for multi-option inputs
- **Budget indicator card** — shows over/under budget with variance amount and percentage on the Investment tab
- **3-step "Get Builder Quotes" flow** — `/leads/quote` with project specs, details, and contact steps; no login required
- **Lead heat scoring** — `calculate_heat_score()` rates leads as hot/warm/cool based on timeline, financing, location readiness, and budget signals
- **PDF export CTA** — "Export Business Plan (PDF) — €99" wired to Paddle checkout (`business_plan` price in PADDLE_PRICES)
- **SEO meta tags** — `<meta>` description, og:title, og:description, og:image on planner page
- **Migration 0002** — expands `lead_requests` with 17 new columns for quote qualification flow; makes `user_id` nullable for guest leads
- **Phase 0 test suite** (`tests/test_phase0.py`) — 47 tests covering guest mode, glass/lighting/budget variables, heat scoring, quote submission, schema validation
- Updated Hypothesis strategy in `test_calculator.py` with `budgetTarget`, `glassType`, `lightingType`
### Changed
- Planner CTA links now point to `/leads/quote` with pre-filled calculator state params (venue, courts, glass, lighting, budget)
- Sticky footer bar updated: "Get Builder Quotes" + "Export Business Plan (PDF)" replace old supplier/financing links
### Changed ### Changed
- Landing page journey section: renamed "From Idea to Operating Hall" → "Your Journey", expanded from 4 cards to 5 (Explore → Plan → Finance → Build → Grow) with "Coming Soon" badges on unreleased stages - Landing page journey section: renamed "From Idea to Operating Hall" → "Your Journey", expanded from 4 cards to 5 (Explore → Plan → Finance → Build → Grow) with "Coming Soon" badges on unreleased stages
- Added `.grid-5` CSS helper for 5-column grid layout - Added `.grid-5` CSS helper for 5-column grid layout

575
PadelDirectory.md Normal file
View File

@@ -0,0 +1,575 @@
# 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

@@ -81,6 +81,7 @@ def create_app() -> Quart:
from .auth.routes import bp as auth_bp from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp from .billing.routes import bp as billing_bp
from .dashboard.routes import bp as dashboard_bp from .dashboard.routes import bp as dashboard_bp
from .directory.routes import bp as directory_bp
from .leads.routes import bp as leads_bp from .leads.routes import bp as leads_bp
from .planner.routes import bp as planner_bp from .planner.routes import bp as planner_bp
from .public.routes import bp as public_bp from .public.routes import bp as public_bp
@@ -91,6 +92,7 @@ def create_app() -> Quart:
app.register_blueprint(billing_bp) app.register_blueprint(billing_bp)
app.register_blueprint(planner_bp) app.register_blueprint(planner_bp)
app.register_blueprint(leads_bp) app.register_blueprint(leads_bp)
app.register_blueprint(directory_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
# Request ID tracking # Request ID tracking

View File

@@ -45,6 +45,7 @@ class Config:
PADDLE_PRICES: dict = { PADDLE_PRICES: dict = {
"starter": os.getenv("PADDLE_PRICE_STARTER", ""), "starter": os.getenv("PADDLE_PRICE_STARTER", ""),
"pro": os.getenv("PADDLE_PRICE_PRO", ""), "pro": os.getenv("PADDLE_PRICE_PRO", ""),
"business_plan": os.getenv("PADDLE_PRICE_BUSINESS_PLAN", ""),
} }
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "") RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")

View File

@@ -0,0 +1,165 @@
"""
Supplier directory: public, searchable listing of padel court suppliers.
"""
from datetime import datetime, timezone
from pathlib import Path
from quart import Blueprint, render_template, request
from ..core import fetch_all, fetch_one
bp = Blueprint(
"directory",
__name__,
url_prefix="/directory",
template_folder=str(Path(__file__).parent / "templates"),
)
COUNTRY_LABELS = {
"DE": "Germany", "ES": "Spain", "IT": "Italy", "FR": "France",
"PT": "Portugal", "GB": "United Kingdom", "NL": "Netherlands",
"BE": "Belgium", "SE": "Sweden", "DK": "Denmark", "FI": "Finland",
"NO": "Norway", "AT": "Austria", "SI": "Slovenia", "IS": "Iceland",
"CH": "Switzerland", "EE": "Estonia",
"US": "United States", "CA": "Canada",
"MX": "Mexico", "BR": "Brazil", "AR": "Argentina",
"AE": "UAE", "SA": "Saudi Arabia", "TR": "Turkey",
"CN": "China", "IN": "India", "SG": "Singapore",
"ID": "Indonesia", "TH": "Thailand", "AU": "Australia",
"ZA": "South Africa", "EG": "Egypt",
}
CATEGORY_LABELS = {
"manufacturer": "Manufacturer",
"turnkey": "Turnkey Provider",
"consultant": "Consultant",
"hall_builder": "Hall Builder",
"turf": "Turf / Surfaces",
"lighting": "Lighting",
"software": "Software",
"industry_body": "Industry Body",
"franchise": "Franchise / Operator",
}
REGION_LABELS = {
"Europe": "Europe",
"North America": "North America",
"Latin America": "Latin America",
"Middle East": "Middle East",
"Asia Pacific": "Asia Pacific",
"Africa": "Africa",
}
async def _build_directory_query(q, country, category, region, page, per_page=24):
"""Shared query builder for directory index and HTMX results."""
now = datetime.now(timezone.utc).isoformat()
params: list = []
wheres: list[str] = []
if q:
terms = [t for t in q.split() if t]
if terms:
fts_q = " ".join(t + "*" for t in terms)
wheres.append(
"s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)"
)
params.append(fts_q)
if country:
wheres.append("s.country_code = ?")
params.append(country)
if category:
wheres.append("s.category = ?")
params.append(category)
if region:
wheres.append("s.region = ?")
params.append(region)
where = " AND ".join(wheres) if wheres else "1=1"
count_row = await fetch_one(
f"SELECT COUNT(*) as cnt FROM suppliers s WHERE {where}",
tuple(params),
)
total = count_row["cnt"] if count_row else 0
offset = (page - 1) * per_page
# Tier-based ordering: sticky first, then pro > growth > free, then name
order_params = [now, country or ""] + params + [per_page, offset]
suppliers = await fetch_all(
f"""SELECT * FROM suppliers s WHERE {where}
ORDER BY
CASE WHEN s.sticky_until > ? AND (s.sticky_country IS NULL OR s.sticky_country = '' OR s.sticky_country = ?) THEN 0 ELSE 1 END,
CASE s.tier WHEN 'pro' THEN 0 WHEN 'growth' THEN 1 ELSE 2 END,
s.name
LIMIT ? OFFSET ?""",
tuple(order_params),
)
total_pages = max(1, (total + per_page - 1) // per_page)
return {
"suppliers": suppliers,
"q": q,
"country": country,
"category": category,
"region": region,
"page": page,
"total_pages": total_pages,
"total": total,
"now": now,
"country_labels": COUNTRY_LABELS,
"category_labels": CATEGORY_LABELS,
}
@bp.route("/")
async def index():
q = request.args.get("q", "").strip()
country = request.args.get("country", "")
category = request.args.get("category", "")
region = request.args.get("region", "")
page = max(1, int(request.args.get("page", "1") or "1"))
ctx = await _build_directory_query(q, country, category, region, page)
country_counts = await fetch_all(
"SELECT country_code, COUNT(*) as cnt FROM suppliers"
" GROUP BY country_code ORDER BY cnt DESC"
)
category_counts = await fetch_all(
"SELECT category, COUNT(*) as cnt FROM suppliers"
" GROUP BY category ORDER BY cnt DESC"
)
total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
total_countries = await fetch_one(
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
)
return await render_template(
"directory.html",
**ctx,
country_counts=country_counts,
category_counts=category_counts,
total_suppliers=total_suppliers["cnt"] if total_suppliers else 0,
total_countries=total_countries["cnt"] if total_countries else 0,
)
@bp.route("/results")
async def results():
"""HTMX endpoint — returns only the results partial."""
q = request.args.get("q", "").strip()
country = request.args.get("country", "")
category = request.args.get("category", "")
region = request.args.get("region", "")
page = max(1, int(request.args.get("page", "1") or "1"))
ctx = await _build_directory_query(q, country, category, region, page)
return await render_template("partials/results.html", **ctx)

View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}Padel Court Supplier Directory - {{ config.APP_NAME }}{% endblock %}
{% 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.">
<style>
.dir-hero { text-align: center; padding: 2rem 0 1.5rem; }
.dir-hero h1 { font-size: 2rem; line-height: 1.3; }
.dir-hero p { color: #64748B; margin-top: 0.5rem; }
.dir-stats { display: flex; justify-content: center; gap: 2rem; margin-top: 1rem; font-size: 0.875rem; color: #64748B; }
.dir-stats strong { color: #1E293B; }
.dir-search { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.dir-search input[type="search"] {
flex: 1; min-width: 200px; padding: 10px 14px; border: 1px solid #CBD5E1;
border-radius: 12px; font-size: 0.875rem;
}
.dir-search input[type="search"]:focus { outline: none; border-color: #1D4ED8; box-shadow: 0 0 0 3px rgba(29,78,216,0.1); }
.dir-search select {
padding: 10px 12px; border: 1px solid #CBD5E1; border-radius: 12px;
font-size: 0.8125rem; background: white; min-width: 140px;
}
.dir-search button[type="submit"] {
padding: 10px 20px; background: #1D4ED8; color: white; border: none;
border-radius: 12px; font-weight: 600; cursor: pointer; font-size: 0.875rem;
box-shadow: 0 2px 10px rgba(29,78,216,0.25);
}
.dir-active-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1rem; }
.dir-filter-tag {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 999px; font-size: 0.75rem; color: #1D4ED8;
}
.dir-filter-tag a { color: #1D4ED8; text-decoration: none; font-weight: 700; }
.dir-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem; margin-bottom: 2rem;
}
/* Base card */
.dir-card {
background: white; border: 1px solid #E2E8F0; border-radius: 14px;
padding: 1.25rem; transition: box-shadow 0.15s;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative;
}
.dir-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
.dir-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.dir-card__name { font-size: 0.9375rem; font-weight: 600; color: #1E293B; margin: 0; }
.dir-card__badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.6875rem; font-weight: 600; white-space: nowrap;
background: #F1F5F9; color: #475569;
}
.dir-card__badge--manufacturer { background: #DBEAFE; color: #1D4ED8; }
.dir-card__badge--turnkey { background: #D1FAE5; color: #065F46; }
.dir-card__badge--turf { background: #FEF3C7; color: #92400E; }
.dir-card__badge--lighting { background: #FEE2E2; color: #991B1B; }
.dir-card__badge--software { background: #EDE9FE; color: #5B21B6; }
.dir-card__badge--hall_builder { background: #FFEDD5; color: #9A3412; }
.dir-card__badge--consultant { background: #F0FDFA; color: #134E4A; }
.dir-card__badge--franchise { background: #FCE7F3; color: #9D174D; }
.dir-card__loc { font-size: 0.8125rem; color: #64748B; margin-top: 4px; }
.dir-card__desc {
font-size: 0.8125rem; color: #475569; margin-top: 0.5rem;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
}
.dir-card__desc--3line { -webkit-line-clamp: 3; }
.dir-card__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; font-size: 0.75rem; }
.dir-card__web a { color: #1D4ED8; text-decoration: none; }
.dir-card__web a:hover { text-decoration: underline; }
.dir-card__claim { color: #94A3B8; }
.dir-card__claim a { color: #1D4ED8; text-decoration: none; font-weight: 500; }
.dir-card__logo { width: 32px; height: 32px; border-radius: 6px; object-fit: contain; }
.dir-card__logo-placeholder {
width: 32px; height: 32px; border-radius: 6px;
background: #EFF6FF; color: #1D4ED8; font-size: 0.6rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
/* Tier badges */
.dir-card__tier-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; margin-top: 4px;
}
.dir-card__tier-badge--verified { background: #D1FAE5; color: #065F46; }
.dir-card__tier-badge--growth { background: #DBEAFE; color: #1D4ED8; }
.dir-card__tier-badge--unverified { background: #F1F5F9; color: #94A3B8; }
/* Free card — muted */
.dir-card--free {
opacity: 0.7; background: #FAFBFC;
}
.dir-card--free:hover { opacity: 0.85; }
/* Pro card — green accent */
.dir-card--pro {
border-color: #16A34A; border-width: 1.5px;
}
/* Highlight glow */
.dir-card--highlight {
box-shadow: 0 0 0 2px rgba(22,163,74,0.15), 0 4px 20px rgba(22,163,74,0.1);
}
/* Sticky — blue top border */
.dir-card--sticky {
border-top: 3px solid #1D4ED8;
}
/* Featured badge */
.dir-card__featured-badge {
position: absolute; top: -1px; right: 12px;
background: #1D4ED8; color: white; font-size: 0.625rem;
font-weight: 700; padding: 3px 8px; border-radius: 0 0 6px 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
/* Example card */
.dir-card--example {
border: 2px dashed #93C5FD; background: #EFF6FF;
}
.dir-pagination { display: flex; justify-content: center; gap: 4px; margin: 2rem 0; }
.dir-pagination a, .dir-pagination span {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 10px; font-size: 0.8125rem;
text-decoration: none; border: 1px solid #E2E8F0; color: #475569;
}
.dir-pagination span.current { background: #1D4ED8; color: white; border-color: #1D4ED8; }
.dir-pagination a:hover { background: #F1F5F9; }
.dir-empty { text-align: center; padding: 3rem 1rem; color: #64748B; }
.dir-empty h3 { color: #1E293B; margin-bottom: 0.5rem; }
@media (max-width: 640px) {
.dir-search { flex-direction: column; }
.dir-grid { grid-template-columns: 1fr; }
.dir-stats { flex-direction: column; gap: 0.25rem; align-items: center; }
}
</style>
{% endblock %}
{% block content %}
<main class="container-page">
<div class="dir-hero">
<h1>Padel Court Supplier Directory</h1>
<p>Browse {{ total_suppliers }}+ suppliers across {{ total_countries }} countries. Find manufacturers, builders, and specialists for your project.</p>
<div class="dir-stats">
<span><strong>{{ total_suppliers }}</strong> suppliers</span>
<span><strong>{{ total_countries }}</strong> countries</span>
<span><strong>{{ category_counts | length }}</strong> categories</span>
</div>
</div>
<!-- Search + Filters -->
<form method="get" class="dir-search">
<input type="search" name="q" value="{{ q }}" placeholder="Search suppliers, countries, products..."
class="dir-search"
hx-get="{{ url_for('directory.results') }}"
hx-trigger="input changed delay:300ms"
hx-target="#dir-results"
hx-include=".dir-search">
<select name="country" class="dir-search"
hx-get="{{ url_for('directory.results') }}"
hx-trigger="change"
hx-target="#dir-results"
hx-include=".dir-search">
<option value="">All Countries</option>
{% for cc in country_counts %}
<option value="{{ cc.country_code }}" {{ 'selected' if country == cc.country_code }}>{{ country_labels.get(cc.country_code, cc.country_code) }} ({{ cc.cnt }})</option>
{% endfor %}
</select>
<select name="category" class="dir-search"
hx-get="{{ url_for('directory.results') }}"
hx-trigger="change"
hx-target="#dir-results"
hx-include=".dir-search">
<option value="">All Categories</option>
{% for cat in category_counts %}
<option value="{{ cat.category }}" {{ 'selected' if category == cat.category }}>{{ category_labels.get(cat.category, cat.category) }} ({{ cat.cnt }})</option>
{% endfor %}
</select>
{% if region %}<input type="hidden" name="region" value="{{ region }}">{% endif %}
<button type="submit">Search</button>
</form>
<!-- Active filters -->
{% if q or country or category or region %}
<div class="dir-active-filters">
{% if q %}<span class="dir-filter-tag">Search: "{{ q }}" <a href="{{ request.path }}?{{ request.query_string.decode().replace('q=' + q, 'q=') }}">&times;</a></span>{% endif %}
{% if country %}<span class="dir-filter-tag">{{ country_labels.get(country, country) }} <a href="{{ request.path }}?q={{ q }}&category={{ category }}">&times;</a></span>{% endif %}
{% if category %}<span class="dir-filter-tag">{{ category_labels.get(category, category) }} <a href="{{ request.path }}?q={{ q }}&country={{ country }}">&times;</a></span>{% endif %}
<a href="{{ request.path }}" style="font-size:0.75rem;color:#64748B;text-decoration:none;padding:4px 8px">Clear all</a>
</div>
{% endif %}
<!-- Results (swappable via HTMX) -->
<div id="dir-results">
{% include "partials/results.html" %}
</div>
<!-- Supplier CTA -->
<section style="text-align:center;padding:2rem 0;border-top:1px solid #E2E8F0;margin-top:1rem">
<h2 style="font-size:1.25rem;margin-bottom:0.5rem">Are you a padel court supplier?</h2>
<p style="color:#64748B;font-size:0.875rem;margin-bottom:1rem">Get listed and connect with entrepreneurs planning padel projects.</p>
<a href="{{ url_for('public.suppliers') }}" class="btn">Get Listed</a>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,118 @@
<p style="font-size:0.8125rem;color:#64748B;margin-bottom:1rem">
Showing {{ suppliers | length }} of {{ total }} supplier{{ 's' if total != 1 }}
{% if page > 1 %} (page {{ page }}){% endif %}
</p>
{% if suppliers %}
<div class="dir-grid">
{# Example card on first page with no active filters #}
{% if page == 1 and not q and not country and not category %}
<div class="dir-card dir-card--example">
<div class="dir-card__featured-badge">Example</div>
<div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px">
<div class="dir-card__logo-placeholder">Logo</div>
<h3 class="dir-card__name">Your Company</h3>
</div>
<span class="dir-card__badge dir-card__badge--manufacturer">Manufacturer</span>
</div>
<p class="dir-card__loc">Your City, Your Country</p>
<div class="dir-card__tier-badge dir-card__tier-badge--verified">Verified &#10003;</div>
<p class="dir-card__desc">Full description of your company, services, certifications, and experience. Pro listings get maximum visibility with logo, website, and priority placement.</p>
<div class="dir-card__foot">
<span class="dir-card__web"><a href="{{ url_for('public.suppliers') }}">yourcompany.com</a></span>
</div>
<p style="text-align:center;font-size:0.75rem;color:#1D4ED8;margin-top:0.5rem;font-weight:600">
<a href="{{ url_for('public.suppliers') }}" style="color:#1D4ED8;text-decoration:none">Your listing could look like this &rarr;</a>
</p>
</div>
{% endif %}
{% for s in suppliers %}
{# --- Pro tier card --- #}
{% if s.tier == 'pro' %}
<div class="dir-card dir-card--pro {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %} {% if s.highlight %}dir-card--highlight{% endif %}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px">
{% if s.logo_url %}<img src="{{ s.logo_url }}" alt="" class="dir-card__logo">{% endif %}
<h3 class="dir-card__name">{{ s.name }}</h3>
</div>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--verified">Verified &#10003;</div>
{% if s.description %}
<p class="dir-card__desc">{{ s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">
{% if s.website %}<a href="https://{{ s.website }}" target="_blank" rel="noopener">{{ s.website }}</a>{% endif %}
</span>
</div>
</div>
{# --- Growth tier card --- #}
{% elif s.tier == 'growth' %}
<div class="dir-card dir-card--growth {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head">
<h3 class="dir-card__name">{{ s.name }}</h3>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--growth">Growth</div>
{% if s.description %}
<p class="dir-card__desc dir-card__desc--3line">{{ s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span></span>
</div>
</div>
{# --- Free / unclaimed tier card --- #}
{% else %}
<div class="dir-card dir-card--free">
<div class="dir-card__head">
<h3 class="dir-card__name">{{ s.name }}</h3>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--unverified">Unverified</div>
<div class="dir-card__foot">
<span></span>
<span class="dir-card__claim"><a href="{{ url_for('public.suppliers') }}">Is this your company? &rarr;</a></span>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav class="dir-pagination">
{% if page > 1 %}
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ page - 1 }}">&laquo;</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p >= total_pages - 1 or (p >= page - 1 and p <= page + 1) %}
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ p }}">{{ p }}</a>
{% elif p == 4 or p == total_pages - 2 %}
<span style="border:none">&hellip;</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ page + 1 }}">&raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="dir-empty">
<h3>No suppliers found</h3>
<p>Try adjusting your search or filters.</p>
<a href="/directory/" style="color:#1D4ED8">Clear all filters</a>
</div>
{% endif %}

View File

@@ -1,10 +1,11 @@
""" """
Leads domain: capture interest in court suppliers and financing. Leads domain: capture interest in court suppliers and financing.
""" """
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from quart import Blueprint, flash, g, redirect, render_template, request, url_for from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
from ..auth.routes import login_required from ..auth.routes import login_required
from ..core import config, csrf_protect, execute, fetch_one, send_email from ..core import config, csrf_protect, execute, fetch_one, send_email
@@ -17,6 +18,52 @@ bp = Blueprint(
) )
# =============================================================================
# Heat Score Calculation
# =============================================================================
def calculate_heat_score(form: dict) -> str:
"""Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'."""
score = 0
if form.get("timeline") in ("asap", "3-6mo"):
score += 3
elif form.get("timeline") == "6-12mo":
score += 1
phase = form.get("location_status", "")
if phase in ("permit_granted",):
score += 4
elif phase in ("lease_signed", "permit_pending"):
score += 3
elif phase in ("converting_existing", "permit_not_filed"):
score += 2
elif phase in ("location_found",):
score += 1
if form.get("financing_status") in ("self_funded", "loan_approved"):
score += 3
elif form.get("financing_status") == "seeking":
score += 1
if form.get("decision_process") == "solo":
score += 2
elif form.get("decision_process") == "partners":
score += 1
if form.get("previous_supplier_contact") == "received_quotes":
score += 2
budget = int(form.get("budget_estimate", 0) or 0)
if budget >= 250000:
score += 2
elif budget >= 100000:
score += 1
if score >= 10:
return "hot"
if score >= 5:
return "warm"
return "cool"
# =============================================================================
# Routes
# =============================================================================
@bp.route("/suppliers", methods=["GET", "POST"]) @bp.route("/suppliers", methods=["GET", "POST"])
@login_required @login_required
@csrf_protect @csrf_protect
@@ -52,7 +99,6 @@ async def suppliers():
) )
prefill = {} prefill = {}
if scenario: if scenario:
import json
try: try:
state = json.loads(scenario["state_json"]) state = json.loads(scenario["state_json"])
prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0) prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0)
@@ -95,7 +141,6 @@ async def financing():
) )
prefill = {} prefill = {}
if scenario: if scenario:
import json
try: try:
state = json.loads(scenario["state_json"]) state = json.loads(scenario["state_json"])
prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0) prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0)
@@ -103,3 +148,122 @@ async def financing():
pass pass
return await render_template("financing.html", prefill=prefill) return await render_template("financing.html", prefill=prefill)
@bp.route("/quote", methods=["GET", "POST"])
@csrf_protect
async def quote_request():
"""3-step lead qualification flow. No login required — guests provide contact info."""
if request.method == "POST":
is_json = request.content_type and "application/json" in request.content_type
if is_json:
form = await request.get_json()
# Normalize: get_json returns a dict, wrap list access
services = form.get("services_needed", [])
if isinstance(services, str):
services = [services]
else:
form = await request.form
services = form.getlist("services_needed")
# Validate mandatory fields
errors = []
if not form.get("country"):
errors.append("Country is required")
if not form.get("timeline"):
errors.append("Timeline is required")
if not form.get("stakeholder_type"):
errors.append("Stakeholder type is required")
if not form.get("contact_name", "").strip():
errors.append("Full name is required")
if not form.get("contact_email", "").strip():
errors.append("Email is required")
if errors:
if is_json:
return jsonify({"ok": False, "errors": errors}), 422
await flash("; ".join(errors), "error")
return redirect(url_for("leads.quote_request"))
heat = calculate_heat_score(form)
services_json = json.dumps(services) if services else None
user_id = g.user["id"] if g.user else None
contact_email = form.get("contact_email", "")
await execute(
"""INSERT INTO lead_requests
(user_id, lead_type, court_count, budget_estimate,
facility_type, glass_type, lighting_type, build_context,
location, country, timeline, location_status,
financing_status, wants_financing_help, decision_process,
previous_supplier_contact, services_needed, additional_info,
contact_name, contact_email, contact_phone, contact_company,
stakeholder_type,
heat_score, status, created_at)
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?)""",
(
user_id,
form.get("court_count", 0),
form.get("budget_estimate", 0),
form.get("facility_type", ""),
form.get("glass_type", ""),
form.get("lighting_type", ""),
form.get("build_context", ""),
form.get("city", ""),
form.get("country", ""),
form.get("timeline", ""),
form.get("location_status", ""),
form.get("financing_status", ""),
1 if form.get("wants_financing_help") else 0,
form.get("decision_process", ""),
form.get("previous_supplier_contact", ""),
services_json,
form.get("additional_info", ""),
form.get("contact_name", ""),
contact_email,
form.get("contact_phone", ""),
form.get("contact_company", ""),
form.get("stakeholder_type", ""),
heat,
datetime.utcnow().isoformat(),
),
)
# Notify admin
await send_email(
config.ADMIN_EMAIL,
f"[{heat.upper()}] New quote request from {contact_email}",
f"<p><b>Heat:</b> {heat}<br>"
f"<b>Contact:</b> {form.get('contact_name')} &lt;{contact_email}&gt;<br>"
f"<b>Stakeholder:</b> {form.get('stakeholder_type')}<br>"
f"<b>Facility:</b> {form.get('facility_type')} / {form.get('court_count')} courts<br>"
f"<b>Glass:</b> {form.get('glass_type')} | <b>Lighting:</b> {form.get('lighting_type')}<br>"
f"<b>Phase:</b> {form.get('location_status')} | <b>Timeline:</b> {form.get('timeline')}<br>"
f"<b>Financing:</b> {form.get('financing_status')} | <b>Budget:</b> {form.get('budget_estimate')}<br>"
f"<b>City:</b> {form.get('city')} | <b>Country:</b> {form.get('country')}</p>",
)
if is_json:
return jsonify({"ok": True, "heat": heat})
return await render_template(
"quote_submitted.html",
heat=heat,
court_count=form.get("court_count", ""),
facility_type=form.get("facility_type", ""),
country=form.get("country", ""),
contact_email=contact_email,
)
# Pre-fill from query params (planner passes calculator state)
prefill = {
"facility_type": request.args.get("venue", ""),
"court_count": request.args.get("courts", ""),
"glass_type": request.args.get("glass", ""),
"lighting_type": request.args.get("lighting", ""),
"budget": request.args.get("budget", ""),
"country": request.args.get("country", ""),
}
return await render_template("quote_request.html", prefill=prefill)

View File

@@ -0,0 +1,339 @@
{% extends "base.html" %}
{% block title %}Get Builder Quotes - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<style>
.quote-flow { max-width: 680px; margin: 0 auto; }
.quote-progress { display: flex; gap: 4px; margin-bottom: 0.5rem; }
.quote-progress__step {
flex: 1; height: 4px; border-radius: 2px;
background: #E2E8F0; transition: background 0.3s;
}
.quote-progress__step.active { background: #3B82F6; }
.quote-progress__labels {
display: flex; justify-content: space-between;
margin-bottom: 2rem; font-size: 0.75rem; font-weight: 600;
color: #94A3B8;
}
.quote-progress__labels span.active { color: #3B82F6; }
.quote-step { display: none; }
.quote-step.active { display: block; }
.quote-step h2 { font-size: 1.25rem; margin-bottom: 0.25rem; }
.quote-step .step-sub { color: #64748B; font-size: 0.875rem; margin-bottom: 1.5rem; }
.pill-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 1.25rem; }
.pill-grid label { cursor: pointer; }
.pill-grid input[type="radio"],
.pill-grid input[type="checkbox"] { display: none; }
.pill-grid .pill {
display: inline-block; padding: 8px 16px; border-radius: 6px;
border: 1px solid #CBD5E1; font-size: 0.8125rem; font-weight: 500;
color: #64748B; transition: all 0.15s; background: transparent;
}
.pill-grid input:checked + .pill {
background: #3B82F6; border-color: #3B82F6; color: #fff;
}
.field-label {
display: block; font-size: 0.8125rem; font-weight: 600;
color: #334155; margin-bottom: 4px;
}
.field-label .required { color: #EF4444; }
.quote-nav { display: flex; justify-content: space-between; margin-top: 2rem; }
.quote-nav .btn-back {
background: transparent; border: 1px solid #CBD5E1; color: #64748B;
padding: 8px 20px; border-radius: 6px; cursor: pointer; font-weight: 600;
}
.btn-gradient {
background: linear-gradient(135deg, #3B82F6, #2563EB);
color: white; border: none; border-radius: 12px;
padding: 14px 28px; font-size: 15px; font-weight: 700;
box-shadow: 0 4px 16px rgba(59,130,246,0.25);
cursor: pointer; transition: transform 0.1s, box-shadow 0.1s;
}
.btn-gradient:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(59,130,246,0.35); }
.summary-card {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px;
padding: 1rem; margin-bottom: 1.5rem; font-size: 0.8125rem;
}
.summary-card h4 { font-size: 0.8125rem; font-weight: 700; color: #334155; margin-bottom: 0.5rem; }
.summary-card dt { color: #94A3B8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }
.summary-card dd { color: #334155; font-weight: 500; margin: 0 0 0.5rem; }
.privacy-box {
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 10px;
padding: 14px 16px; margin-bottom: 1.5rem; font-size: 0.8125rem;
color: #1E40AF; display: flex; gap: 10px; align-items: flex-start;
}
.privacy-box svg { flex-shrink: 0; margin-top: 2px; }
.consent-group { margin-bottom: 1.5rem; }
.consent-group label {
display: flex; gap: 8px; align-items: flex-start;
font-size: 0.8125rem; color: #475569; cursor: pointer;
}
.consent-group input[type="checkbox"] { margin-top: 3px; flex-shrink: 0; }
.field-group { margin-bottom: 1.25rem; }
</style>
{% endblock %}
{% block content %}
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
<div class="container-page py-12">
<div class="quote-flow">
<!-- Progress bar with labels -->
<div class="quote-progress">
<div class="quote-progress__step active" data-step="1"></div>
<div class="quote-progress__step" data-step="2"></div>
<div class="quote-progress__step" data-step="3"></div>
</div>
<div class="quote-progress__labels">
<span class="active" data-label="1">Project</span>
<span data-label="2">Details</span>
<span data-label="3">Contact</span>
</div>
<!-- Elevated card -->
<div style="background: white; border-radius: 16px; padding: 36px 32px; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
<form method="post" id="quoteForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- STEP 1: Tell us about your project -->
<div class="quote-step active" data-step="1">
<h2>Tell us about your project</h2>
<p class="step-sub">This helps us match you with the right suppliers. Share what you know.</p>
<div class="field-group">
<span class="field-label">Facility Type <span class="required">*</span></span>
<div class="pill-grid">
{% for val, label in [('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('both', 'Indoor + Outdoor')] %}
<label><input type="radio" name="facility_type" value="{{ val }}" {{ 'checked' if prefill.facility_type == val }} required><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<label class="field-label" for="court_count">Number of Courts <span class="required">*</span></label>
<input type="number" id="court_count" name="court_count" class="form-input" min="1" max="50" value="{{ prefill.court_count or 6 }}" required>
</div>
<div class="field-group">
<span class="field-label">Glass Type</span>
<div class="pill-grid">
{% for val, label in [('standard', 'Standard Glass'), ('panoramic', 'Panoramic Glass'), ('no_preference', 'No Preference')] %}
<label><input type="radio" name="glass_type" value="{{ val }}" {{ 'checked' if prefill.glass_type == val }}><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<span class="field-label">Lighting</span>
<div class="pill-grid">
{% for val, label in [('led_standard', 'LED Standard'), ('led_competition', 'LED Competition'), ('natural', 'Natural Light'), ('not_sure', 'Not Sure')] %}
<label><input type="radio" name="lighting_type" value="{{ val }}" {{ 'checked' if prefill.lighting_type == val }}><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<span class="field-label">Build Context</span>
<div class="pill-grid">
{% for val, label in [('new_standalone', 'New Standalone Venue'), ('adding_to_club', 'Adding to Existing Club'), ('converting_building', 'Converting a Building'), ('venue_search', 'Need Help Finding a Venue / Land')] %}
<label><input type="radio" name="build_context" value="{{ val }}"><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="quote-nav">
<div></div>
<button type="button" class="btn-gradient" onclick="goStep(2)">Continue &rarr;</button>
</div>
</div>
<!-- STEP 2: Project Details -->
<div class="quote-step" data-step="2">
<h2>Project details</h2>
<p class="step-sub">Help suppliers understand your timeline and scope.</p>
<div class="field-group">
<label class="field-label" for="city">City / Region</label>
<input type="text" id="city" name="city" class="form-input" placeholder="e.g. Munich, Bavaria">
</div>
<div class="field-group">
<label class="field-label" for="country">Country <span class="required">*</span></label>
<select id="country" name="country" class="form-input" required>
<option value="">Select country...</option>
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('UK', 'United Kingdom'), ('PT', 'Portugal'), ('BE', 'Belgium'), ('AT', 'Austria'), ('CH', 'Switzerland'), ('DK', 'Denmark'), ('FI', 'Finland'), ('NO', 'Norway'), ('PL', 'Poland'), ('CZ', 'Czech Republic'), ('AE', 'UAE'), ('SA', 'Saudi Arabia'), ('US', 'United States'), ('OTHER', 'Other')] %}
<option value="{{ code }}" {{ 'selected' if prefill.country == code }}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="field-group">
<span class="field-label">Project Phase</span>
<div class="pill-grid">
{% for val, label in [('still_searching', 'Still searching for a location'), ('location_found', 'Location identified / shortlisted'), ('converting_existing', 'Converting existing facility'), ('lease_signed', 'Lease or purchase signed'), ('permit_not_filed', 'Building permit not yet filed'), ('permit_pending', 'Building permit in progress'), ('permit_granted', 'Building permit approved')] %}
<label><input type="radio" name="location_status" value="{{ val }}"><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<span class="field-label">Timeline <span class="required">*</span></span>
<div class="pill-grid">
{% for val, label in [('asap', 'ASAP'), ('3-6mo', '3-6 Months'), ('6-12mo', '6-12 Months'), ('12+mo', '12+ Months')] %}
<label><input type="radio" name="timeline" value="{{ val }}" {{ 'required' if loop.first }}><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<label class="field-label" for="budget_estimate">Budget Estimate (&euro;)</label>
<input type="number" id="budget_estimate" name="budget_estimate" class="form-input" placeholder="e.g. 500000" value="{{ prefill.budget or '' }}">
</div>
<div class="field-group">
<span class="field-label">Financing Status</span>
<div class="pill-grid">
{% for val, label in [('self_funded', 'Self-Funded'), ('loan_approved', 'Loan Approved'), ('seeking', 'Seeking Financing'), ('not_started', 'Not Started')] %}
<label><input type="radio" name="financing_status" value="{{ val }}"><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<label class="field-label">
<input type="checkbox" name="wants_financing_help" value="1" style="margin-right: 6px;">
I'd like help finding financing options
</label>
</div>
<div class="field-group">
<span class="field-label">Decision Process</span>
<div class="pill-grid">
{% for val, label in [('solo', 'Solo Decision'), ('partners', 'With Partners'), ('committee', 'Committee / Board')] %}
<label><input type="radio" name="decision_process" value="{{ val }}"><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<span class="field-label">You are... <span class="required">*</span></span>
<div class="pill-grid">
{% for val, label in [('entrepreneur', 'Entrepreneur / Investor'), ('tennis_club', 'Tennis / Sports Club'), ('municipality', 'Municipality / Public Body'), ('developer', 'Real Estate Developer'), ('operator', 'Existing Padel Operator'), ('architect', 'Architect / Engineer')] %}
<label><input type="radio" name="stakeholder_type" value="{{ val }}" {{ 'required' if loop.first }}><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<span class="field-label">Services Needed (select all that apply)</span>
<div class="pill-grid">
{% for val, label in [('court_supply', 'Court Supply'), ('installation', 'Installation'), ('construction', 'Hall Construction'), ('design', 'Facility Design'), ('lighting', 'Lighting'), ('flooring', 'Flooring'), ('turnkey', 'Full Turnkey')] %}
<label><input type="checkbox" name="services_needed" value="{{ val }}"><span class="pill">{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field-group">
<label class="field-label" for="additional_info">Anything else?</label>
<textarea id="additional_info" name="additional_info" class="form-input" rows="3" placeholder="Any specific requirements, questions, or context..."></textarea>
</div>
<div class="quote-nav">
<button type="button" class="btn-back" onclick="goStep(1)">&larr; Back</button>
<button type="button" class="btn-gradient" onclick="goStep(3)">Continue &rarr;</button>
</div>
</div>
<!-- STEP 3: Contact -->
<div class="quote-step" data-step="3">
<h2>How should suppliers reach you?</h2>
<p class="step-sub">Matched suppliers will contact you directly with tailored proposals.</p>
<div class="privacy-box">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5 7V5a3.5 3.5 0 10-7 0v2M4 7h8a1 1 0 011 1v5a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1z" stroke="#3B82F6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Your contact details are shared only with 2-5 pre-vetted suppliers that match your project specs.</span>
</div>
<div class="field-group">
<label class="field-label" for="contact_name">Full Name <span class="required">*</span></label>
<input type="text" id="contact_name" name="contact_name" class="form-input" required>
</div>
<div class="field-group">
<label class="field-label" for="contact_email">Email <span class="required">*</span></label>
<input type="email" id="contact_email" name="contact_email" class="form-input" required>
</div>
<div class="field-group">
<label class="field-label" for="contact_phone">Phone (optional)</label>
<input type="tel" id="contact_phone" name="contact_phone" class="form-input">
</div>
<div class="field-group">
<label class="field-label" for="contact_company">Company (optional)</label>
<input type="text" id="contact_company" name="contact_company" class="form-input">
</div>
<div id="quoteSummary" class="summary-card"></div>
<div class="consent-group">
<label>
<input type="checkbox" name="consent" value="1" required>
<span>I agree that my project details and contact information may be shared with verified padel court suppliers matched to my project. <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> &middot; <a href="{{ url_for('public.terms') }}">Terms</a></span>
</label>
</div>
<div class="quote-nav">
<button type="button" class="btn-back" onclick="goStep(2)">&larr; Back</button>
<button type="submit" class="btn-gradient">Submit &amp; Get Quotes &rarr;</button>
</div>
<p style="text-align: center; font-size: 0.75rem; color: #94A3B8; margin-top: 1rem;">No obligation.</p>
</div>
</form>
</div>
</div>
</div>
</main>
{% endblock %}
{% block scripts %}
<script>
function goStep(n) {
document.querySelectorAll('.quote-step').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.quote-progress__step').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.quote-progress__labels span').forEach(s => s.classList.remove('active'));
document.querySelector(`.quote-step[data-step="${n}"]`).classList.add('active');
for (let i = 1; i <= n; i++) {
document.querySelector(`.quote-progress__step[data-step="${i}"]`).classList.add('active');
document.querySelector(`.quote-progress__labels span[data-label="${i}"]`).classList.add('active');
}
window.scrollTo({top: 0, behavior: 'smooth'});
if (n === 3) buildSummary();
}
function buildSummary() {
const f = document.getElementById('quoteForm');
const val = name => {
const checked = f.querySelector(`[name="${name}"]:checked`);
if (checked) return checked.value;
const el = f.querySelector(`[name="${name}"]`);
return el ? el.value : '';
};
const labels = {
facility_type: 'Facility', court_count: 'Courts', glass_type: 'Glass',
lighting_type: 'Lighting', build_context: 'Build Context', city: 'Location',
country: 'Country', timeline: 'Timeline', budget_estimate: 'Budget',
location_status: 'Project Phase', financing_status: 'Financing',
decision_process: 'Decision Process', stakeholder_type: 'You are',
};
let html = '<h4>Your project brief</h4><dl style="display:grid;grid-template-columns:1fr 1fr;gap:4px 1rem">';
for (const [k, label] of Object.entries(labels)) {
const v = val(k);
if (v) html += `<dt>${label}</dt><dd>${v.replace(/_/g, ' ')}</dd>`;
}
html += '</dl>';
document.getElementById('quoteSummary').innerHTML = html;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}You're Matched! - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<style>
.submitted-flow { max-width: 580px; margin: 0 auto; text-align: center; }
.check-circle {
width: 64px; height: 64px; border-radius: 50%;
background: #D1FAE5; display: inline-flex; align-items: center;
justify-content: center; margin-bottom: 1rem;
}
.check-circle svg { width: 32px; height: 32px; color: #059669; }
.next-steps {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 12px;
padding: 1.25rem 1.5rem; text-align: left; margin: 1.5rem 0;
}
.next-steps h3 { font-size: 0.875rem; font-weight: 700; color: #334155; margin-bottom: 0.75rem; }
.next-steps ol { padding-left: 0; list-style: none; margin: 0; }
.next-steps li {
display: flex; gap: 12px; align-items: flex-start;
padding: 8px 0; font-size: 0.8125rem; color: #475569;
border-bottom: 1px solid #F1F5F9;
}
.next-steps li:last-child { border-bottom: none; }
.next-steps .step-num {
width: 24px; height: 24px; border-radius: 50%;
background: #EFF6FF; color: #3B82F6; font-weight: 700;
font-size: 0.75rem; display: flex; align-items: center;
justify-content: center; flex-shrink: 0;
}
.next-steps .step-time {
margin-left: auto; font-size: 0.6875rem; color: #94A3B8;
white-space: nowrap; padding-left: 8px;
}
.email-box {
background: #FFF7ED; border: 1px solid #FED7AA; border-radius: 10px;
padding: 14px 16px; text-align: left; margin: 1rem 0;
font-size: 0.8125rem; color: #9A3412;
}
.signup-box {
background: white; border: 1px solid #E2E8F0; border-radius: 12px;
padding: 1.25rem 1.5rem; text-align: center; margin: 1.5rem 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.signup-box h3 { font-size: 0.9375rem; font-weight: 700; color: #334155; margin-bottom: 0.25rem; }
.signup-box p { font-size: 0.8125rem; color: #64748B; margin-bottom: 1rem; }
</style>
{% endblock %}
{% block content %}
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
<div class="container-page py-12">
<div class="submitted-flow">
<div class="check-circle">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<h1 class="text-2xl" style="margin-bottom: 0.5rem;">You're matched!</h1>
<p style="color: #64748B; font-size: 0.9375rem;">
We've matched your
{% if court_count %}{{ court_count }}-court{% endif %}
{% if facility_type %}{{ facility_type }}{% endif %}
project
{% if country %}in {{ country }}{% endif %}
with verified suppliers who'll reach out with tailored proposals.
</p>
<div class="next-steps">
<h3>What happens next</h3>
<ol>
<li>
<span class="step-num">1</span>
<span>Suppliers review your project brief and prepare proposals</span>
<span class="step-time">Now</span>
</li>
<li>
<span class="step-num">2</span>
<span>Matched suppliers contact you with tailored quotes</span>
<span class="step-time">1-2 days</span>
</li>
<li>
<span class="step-num">3</span>
<span>Compare proposals and ask follow-up questions</span>
<span class="step-time">1-2 weeks</span>
</li>
<li>
<span class="step-num">4</span>
<span>Choose the supplier that fits your project best</span>
<span class="step-time">At your pace</span>
</li>
</ol>
</div>
{% if contact_email %}
<div class="email-box">
&#128231; A confirmation has been sent to <strong>{{ contact_email }}</strong>. Check your inbox (and spam folder).
</div>
{% endif %}
{% if not user %}
<div class="signup-box">
<h3>Create an account</h3>
<p>Save scenarios, track your project, and get notified when suppliers respond.</p>
<a href="{{ url_for('auth.signup') }}?next={{ url_for('planner.index') }}" class="btn">Create Account</a>
</div>
{% else %}
<div style="margin-top: 1.5rem;">
<a href="{{ url_for('planner.index') }}" class="btn">Back to Planner</a>
</div>
{% endif %}
</div>
</div>
</main>
{% endblock %}

View File

@@ -125,7 +125,7 @@ CREATE INDEX IF NOT EXISTS idx_scenarios_user ON scenarios(user_id);
-- Lead requests (when user wants supplier quotes or financing) -- Lead requests (when user wants supplier quotes or financing)
CREATE TABLE IF NOT EXISTS lead_requests ( CREATE TABLE IF NOT EXISTS lead_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER REFERENCES users(id),
lead_type TEXT NOT NULL, lead_type TEXT NOT NULL,
scenario_id INTEGER REFERENCES scenarios(id), scenario_id INTEGER REFERENCES scenarios(id),
location TEXT, location TEXT,
@@ -133,7 +133,80 @@ CREATE TABLE IF NOT EXISTS lead_requests (
budget_estimate INTEGER, budget_estimate INTEGER,
message TEXT, message TEXT,
status TEXT DEFAULT 'new', status TEXT DEFAULT 'new',
created_at TEXT NOT NULL created_at TEXT NOT NULL,
-- Phase 0: expanded quote qualification fields
facility_type TEXT,
glass_type TEXT,
lighting_type TEXT,
build_context TEXT,
country TEXT,
timeline TEXT,
location_status TEXT,
financing_status TEXT,
wants_financing_help INTEGER DEFAULT 0,
decision_process TEXT,
previous_supplier_contact TEXT,
services_needed TEXT,
additional_info TEXT,
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
contact_company TEXT,
stakeholder_type TEXT,
heat_score TEXT DEFAULT 'cool'
); );
CREATE INDEX IF NOT EXISTS idx_leads_status ON lead_requests(status); CREATE INDEX IF NOT EXISTS idx_leads_status ON lead_requests(status);
CREATE INDEX IF NOT EXISTS idx_leads_heat ON lead_requests(heat_score);
-- Suppliers directory (seeded with unclaimed listings)
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
country_code TEXT NOT NULL,
city TEXT,
region TEXT NOT NULL,
website TEXT,
description TEXT,
category TEXT NOT NULL,
contact TEXT,
claimed_at TEXT,
claimed_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
tier TEXT NOT NULL DEFAULT 'free',
logo_url TEXT,
is_verified INTEGER NOT NULL DEFAULT 0,
highlight INTEGER NOT NULL DEFAULT 0,
sticky_until TEXT,
sticky_country TEXT
);
CREATE INDEX IF NOT EXISTS idx_suppliers_country ON suppliers(country_code);
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category);
CREATE INDEX IF NOT EXISTS idx_suppliers_slug ON suppliers(slug);
-- FTS5 full-text search for suppliers
CREATE VIRTUAL TABLE IF NOT EXISTS suppliers_fts USING fts5(
name, description, city, country_code, category,
content='suppliers', content_rowid='id'
);
-- Keep FTS in sync with suppliers table
CREATE TRIGGER IF NOT EXISTS suppliers_ai AFTER INSERT ON suppliers BEGIN
INSERT INTO suppliers_fts(rowid, name, description, city, country_code, category)
VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category);
END;
CREATE TRIGGER IF NOT EXISTS suppliers_ad AFTER DELETE ON suppliers BEGIN
INSERT INTO suppliers_fts(suppliers_fts, rowid, name, description, city, country_code, category)
VALUES ('delete', old.id, old.name, old.description, old.city, old.country_code, old.category);
END;
CREATE TRIGGER IF NOT EXISTS suppliers_au AFTER UPDATE ON suppliers BEGIN
INSERT INTO suppliers_fts(suppliers_fts, rowid, name, description, city, country_code, category)
VALUES ('delete', old.id, old.name, old.description, old.city, old.country_code, old.category);
INSERT INTO suppliers_fts(rowid, name, description, city, country_code, category)
VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category);
END;

View File

@@ -0,0 +1,30 @@
"""Expand lead_requests for 3-step quote qualification flow."""
def up(conn):
cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)")}
new_cols = {
"facility_type": "TEXT",
"glass_type": "TEXT",
"lighting_type": "TEXT",
"build_context": "TEXT",
"country": "TEXT",
"timeline": "TEXT",
"location_status": "TEXT",
"financing_status": "TEXT",
"wants_financing_help": "INTEGER DEFAULT 0",
"decision_process": "TEXT",
"previous_supplier_contact": "TEXT",
"services_needed": "TEXT",
"additional_info": "TEXT",
"contact_name": "TEXT",
"contact_email": "TEXT",
"contact_phone": "TEXT",
"contact_company": "TEXT",
"heat_score": "TEXT DEFAULT 'cool'",
}
for col, col_type in new_cols.items():
if col not in cols:
conn.execute(
f"ALTER TABLE lead_requests ADD COLUMN {col} {col_type}"
)

View File

@@ -0,0 +1,7 @@
"""Add stakeholder_type column to lead_requests."""
def up(conn):
cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)")}
if "stakeholder_type" not in cols:
conn.execute("ALTER TABLE lead_requests ADD COLUMN stakeholder_type TEXT")

View File

@@ -0,0 +1,680 @@
"""Create suppliers table with FTS5 full-text search and seed directory data."""
import re
import unicodedata
def _slugify(text):
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^\w\s-]", "", text.lower())
return re.sub(r"[-\s]+", "-", text).strip("-")
_REGION = {
"DE": "Europe", "ES": "Europe", "IT": "Europe", "FR": "Europe",
"PT": "Europe", "GB": "Europe", "NL": "Europe", "BE": "Europe",
"SE": "Europe", "DK": "Europe", "FI": "Europe", "NO": "Europe",
"AT": "Europe", "SI": "Europe", "IS": "Europe", "CH": "Europe",
"US": "North America", "CA": "North America",
"MX": "Latin America", "BR": "Latin America", "AR": "Latin America",
"AE": "Middle East", "SA": "Middle East", "TR": "Middle East",
"CN": "Asia Pacific", "IN": "Asia Pacific", "SG": "Asia Pacific",
"ID": "Asia Pacific", "TH": "Asia Pacific", "AU": "Asia Pacific",
"ZA": "Africa", "EG": "Africa",
}
# (name, country_code, city, website, description, category, contact)
_SUPPLIERS = [
# ---- Germany: Court Manufacturers (1.1) ----
("artec Sportgeräte GmbH", "DE", "Melle", "artec-sportgeraete.de",
"30+ years sports equipment. 12mm laminated safety glass, DIN-standard statics, customizable colors. Indoor & outdoor.", "manufacturer", None),
("PADELWERK Court GmbH", "DE", "Dortmund", "padelwerk.de",
"Germany's first padel court manufacturer (est. 2021). All components made in Germany. LED lighting, roofing, financing consulting.", "manufacturer", "+49 172 54 60 150"),
("Vindico Sport GmbH", "DE", None, "vindico-sport.de",
"Serie B and Pano models. Carbon-steel S235JRH, 10-12mm tempered glass, 9-stage corrosion coating, WPT-standard entrances.", "manufacturer", None),
("Kübler Sport GmbH", "DE", "Backnang", "kuebler-sport.de",
"Major sports supplier. CLASSIC and PANORAMA courts with steel structure, tempered glass, wire mesh, artificial turf, 8 LED elements.", "manufacturer", "info@kuebler-sport.de"),
("Padello GmbH", "DE", "Troisdorf", "padello.de",
"Full-service builder with own metalworking shop. Steel construction up to 5mm adapted to regional wind loads. DIN-compliant, DEKRA-certified.", "manufacturer", None),
("LOB Sport", "DE", "Nuremberg", "lobsport.de",
"40+ years in tennis equipment, now padel. Edge and Infinity models. Foundation-free option for converting sand courts. Part of BECO Bermüller group.", "manufacturer", "+49 911 64200-0"),
("Brako Padel GmbH", "DE", "Berlin", "brakopadel.com",
"Padel specialist since 2013. 15-year corrosion warranty. Indoor, outdoor, panoramic, and portable courts. No-foundation portable options.", "manufacturer", None),
("Padel Concept", "DE", None, "padelconcept.de",
"Production in Baltic states, serves DACH/Scandinavia/BeNeLux. Own assembly teams. Innovative mobile floor plate solution.", "manufacturer", None),
("Padelsportanlagenbau", "DE", None, "padelsportanlagenbau.de",
"Manufacturer & turnkey builder, active in 18+ countries.", "manufacturer", None),
# ---- Germany: Turnkey & Consultants (1.2) ----
("The Court Company", "DE", "Cologne", "courtcompany.de",
"Germany's most experienced padel construction expert. 40+ courts in 2021. Licensed partner of AFP Courts, RedSport, adidas courts. Full service plus leasing.", "turnkey", "+49 2237 6034685"),
("Real Padel GmbH", "DE", "Schönebeck", "realpadel.de",
"Premium courts and full-service club consulting. Booking systems, automation, equipment, sponsor connections. DACH broker.", "turnkey", None),
("Padel4U", "DE", None, "padel4u.de",
"German distributor for Manzasport. Full range including standard, panoramic, and custom courts. ~1,000 courts/year through partnership.", "turnkey", None),
("Trendsport Rummenigge", "DE", None, "trendsport-rummenigge.de",
"Turnkey solutions from concept to completion. Guidance on German funding programs (Sportstättenförderung).", "turnkey", None),
("PadelCity", "DE", None, "padelcity.de",
"Germany's largest padel operator (20 facilities, 100+ courts). DTB partner. Proprietary booking app. Franchise model.", "franchise", None),
("padelBOX", "DE", None, "padelbox.de",
"Major German padel operator and consultant.", "turnkey", "r.stroehl@padelbox.de"),
("Padel Solution", "DE", None, "padelsolution.de",
"Supplier, planner, autonomous club solutions.", "turnkey", None),
("Best World Padel", "DK", None, None,
"Official MejorSet distributor for Germany and Denmark.", "turnkey", None),
# ---- Germany: Hall & Building Constructors (1.3) ----
("SMC2 Bau", "DE", None, "smc2-bau.de",
"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",
"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",
"45+ years building steel halls. 3 standardized padel hall solutions plus custom. Sandwich panels, mezzanine floors, court layout optimization.", "hall_builder", None),
# ---- Germany: International Manufacturers with DE Presence (1.4) — unique entries only ----
("Padelcreations", "ES", None, "padelcreations.com",
"24+ courts in DACH region. 10 court models. Certified Spanish assembly teams.", "manufacturer", "+34 965 049 221"),
("Courtwall", "AT", "Vienna", "padelcourt.biz",
"Building courts since 1984 (squash) and padel since 2007. 1,200+ courts worldwide. ISO-certified.", "manufacturer", None),
("Unix Padel", "TR", "Istanbul", "unixpadel.com",
"7,800+ courts globally. FIP-standard, KIWA-tested. Up to 12 courts/day production. TÜV-tested components.", "manufacturer", None),
("The Padel Lab", "NL", None, "thepadellab.com",
"HD Vision courts, tournament-grade panoramic, modular steel frame technology. Claims 40% reduction in construction time.", "manufacturer", None),
# ---- Germany: Turf, Lighting, Software (1.5) — unique entries only ----
("BECO Bermüller", "DE", "Nuremberg", "beco-bermueller.de",
"German synthetic turf and sports field materials. 40+ years. Parent of LOB Sport.", "turf", "+49 911 64200-0"),
("Polytan GmbH", "DE", None, "polytan.com",
"Since 1969. Sports surfaces development and production. Active in padel turf across Europe.", "turf", None),
("Primaflor", "DE", None, "primaflor.de",
"Artificial turf supplier for padel courts.", "turf", None),
("bookaball", "DE", None, "bookaball.com",
"Booking software for racket sport clubs. Used by German market leaders. €30M+ bookings processed.", "software", None),
("AS LED Lighting", "DE", "Upper Bavaria", 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) ----
("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"),
("Padel Galis", "ES", "Valencia", "padelgalis.com",
"10,000+ courts, 75+ countries. Partnership with Wilson and Fernando Belasteguín. Official WPT/Premier Padel supplier.", "manufacturer", None),
("Portico Sport", "ES", "Villafranca de los Barros", "porticosport.com",
"15+ years, 35+ countries, 4,000+ courts. Only manufacturer also building sports canopies. 110,000+ sqft factory.", "manufacturer", None),
("Padel10", "ES", "Rubí", "padel10.com",
"Exclusively padel since 2008. 4,500+ courts. Former WPT official supplier. 15-day production. Mondo and ACT turf systems.", "manufacturer", None),
("Manzasport", "ES", "Beniparrell", "manzasport.com",
"Top-4 global builder since 2003, 4 factories in Valencia. 100% Spanish manufacturing, FIP and NIDE 2021 compliant.", "manufacturer", "+34 963 217 472"),
("Maxpeed", "ES", "Viladecans", "maxpeed.com",
"Manufacturer, builder, equipment supplier.", "manufacturer", "+34 936 593 961"),
("AFP Courts", "ES", None, "afpcourts.com",
"Official adidas licensee. 15+ years, 33+ exclusive distribution centers. Official PPL court. 2,150+ courts under RedSport brand.", "manufacturer", None),
("Jubo Padel", "ES", None, "jubopadel.com",
"Family-owned, 25+ years, 6,000+ courts. FIP official. Comprehensive alliance partner network. Court configurator.", "manufacturer", None),
("Padel Courts Deluxe", "ES", "Alicante", "padelcourtsdeluxe.es",
"100% Spanish, premium courts. Robotic welding, carbon fibre courts. Partnership with Greenset for surfaces.", "manufacturer", "info@padelcourtsdeluxe.com"),
("SportBS", "ES", "Extremadura", "sportbs.es",
"100% Spanish materials. 360° service including courts, lighting, turf, software.", "manufacturer", None),
("Ingode Padel Courts", "ES", "Crevillente", "ingodepadel.com",
"ISO 14001 & 9001. Galvanized steel with epoxy paint. 20+ years.", "manufacturer", None),
("SkyPadel", "ES", None, "skypadel.com",
"1,500+ courts in 40+ countries. EUROCODE compliant. Featured at WPT events globally. Subsidiaries in India/Brazil/Mexico.", "manufacturer", None),
("PadelMagic", "ES", "Valladolid", "padelmagic.es",
"400+ courts/year, 80% exported. FIP-certified.", "manufacturer", None),
("Padel Hispania", "ES", None, "padelhispania.com",
"Federation-approved specialist in Spain and Portugal.", "manufacturer", None),
("VerdePadel", "ES", None, "verdepadel.com",
"Building since 2004. 2,000+ courts, 40,000+ m² of artificial grass installed.", "manufacturer", None),
("J'hayber Padel", "ES", None, "jhayberinstalaciones.com",
"Manufacturer & lighting specialist since 1972.", "manufacturer", None),
("PadelFan Valencia", "ES", "Alaquàs", "padelfanvalencia.com",
"10+ years, 800+ customers. Also provides turf replacement.", "manufacturer", "+34 661 320 398"),
("TMPadel", "ES", "Valencia", "tmpadel.com",
"Kiwa certified, exports to 6+ EU countries.", "manufacturer", None),
("Niberma", "ES", None, "niberma.es",
"700+ courts.", "manufacturer", None),
("Grupo Pineda", "ES", "Griñón", "grupopineda.eu",
"Proprietary turf & pavement.", "manufacturer", None),
("MR Instalaciones", "ES", "Madrid", "mrinstalaciones.es",
"20+ years, turnkey.", "manufacturer", None),
("X-Treme Group", "ES", "Alcalá de Henares", "x-tremegroup.com",
"18+ years, 6,000 m² facility.", "manufacturer", None),
("MTR Padel", "ES", None, "mtrpadel.com",
"Methacrylate glass, turnkey.", "manufacturer", None),
("GimPadel", "ES", "León", "gimpadel.com",
"800+ installations. Built in Netherlands, Belgium, Italy, Kuwait, Portugal, Kenya.", "manufacturer", None),
("EE Padel", "ES", None, "eepadel.com",
"2021 merger of Eljoi Padel, EcoNatura, Swedish investors. Aluminum courts. Ships to US/Canada/ME.", "manufacturer", None),
("Greencourt", "ES", None, "greencourt.es",
"Galvanized/aluminum/precast options.", "manufacturer", None),
("ExtremaAdel", "ES", "Extremadura", None,
"Aluminum model innovation.", "manufacturer", None),
("Padelgest", "ES", None, None,
"Urban/sustainability focus.", "manufacturer", None),
("Iberopadel", "ES", None, "iberopadel.com",
"FEP approved, Joma/Maxpeed partner.", "manufacturer", None),
("Padel Alba", "ES", "Granja de Rocamora", "padelalba.com",
"25+ years, FIP-compliant.", "manufacturer", None),
("Pistas-Padel.es", "ES", None, "pistas-padel.es",
"All court types.", "manufacturer", None),
# ---- Spain: Turf/Surface (2.2) ----
("Realturf", "ES", None, "realturf.com",
"Official USPA sponsor. Drive Pro, Match Play, fibrillated lines. FEP-compliant.", "turf", None),
("Act Sports", "ES", None, "act.sport",
"Global padel turf leader, 10,000+ fields.", "turf", None),
("Eurocesped", "ES", None, "eurocesped.com",
"ITF parameters.", "turf", None),
("Allgrass", "ES", None, "allgrass.es",
"FEP compliant.", "turf", None),
("Albergrass", "ES", "Pilar de la Horadada", "albergrass.com",
"Artificial turf for padel courts.", "turf", None),
# ---- Spain: Lighting (2.3) ----
("Led Projects", "ES", None, "ledprojects.es",
"World leader in padel lighting, 5,000+ courts, WPT/Premier Padel official.", "lighting", None),
("Óptima LED", "ES", None, "optimaled.es",
"ProTour padel spotlights, anti-glare.", "lighting", None),
("PlazaLED", "ES", "Madrid", "plazaled.es",
"Sports LED projectors & scoreboards.", "lighting", None),
("Ellite LED Padel", "ES", None, "ledpadel.com",
"30+ years R&D. 360° perimeter system. 10-year warranty. Integration with booking apps.", "lighting", None),
("Ledkia", "ES", None, "ledkia.com",
"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) ----
("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),
("Italgreen", "IT", None, "italgreen.org",
"40+ years. Patented fiberglass structure. FIP sponsor. Iron, Full Panoramic, V-PRO courts. Own padel turf lines.", "manufacturer", None),
("Limonta Sport", "IT", None, "limontasport.com",
"Premium padel turf, FEP approved, CONI partner.", "turf", None),
("Padel Factory SRL", "IT", "Near Rome", "padelfactorysrl.com",
"1,000+ courts. Innovative hybrid wood-steel design. 10+ European countries.", "manufacturer", None),
("Padel Corporation", "IT", None, "padelcorporation.com",
"10+ years, 20+ countries. 100% made in Italy, ITF compliant. Full turnkey.", "manufacturer", "+39 339 731 2152"),
("Italian Padel", "IT", None, "italianpadel.it",
"3,000+ courts in 28 countries. Up to 180 courts/month. CE certified.", "manufacturer", "info@italianpadel.it"),
("Italia Team Padel", "IT", "Pesaro-Urbino", "italiateampadel.com",
"Supplied first court at Foro Italico. Basic, Vision Pro, Full Vision models.", "manufacturer", "+39 0721 571 588"),
("Campidapadel.it", "IT", "Lesmo", "campidapadel.it",
"Design, supply, installation, plus financial and marketing consultancy.", "manufacturer", None),
("Favaretti Padel", "IT", "Bagnoli di Sopra", "favarettipadel.it",
"Turnkey, official Dunlop partner.", "manufacturer", None),
("WIP Padel", "IT", None, "wippadel.it",
"CE marked, 40+ years in sports.", "manufacturer", None),
("Merli Sport", "IT", "Ravenna", "merlisport.com",
"500+ courts, 'The Wall' showroom.", "manufacturer", None),
("NXPadel", "IT", None, "nxpadel.com",
"Patented fiberglass technology.", "manufacturer", None),
("Edil Padel S.R.L.", "IT", None, "edilpadel.it",
"Construction/installation, wood/steel.", "manufacturer", None),
("Durocem Italia", "IT", None, "durocem.it",
"Civil works & court installation, Padel Technologies distributor.", "manufacturer", None),
("Top Padel Italia", "IT", None, "toppadelitalia.it",
"Turnkey, from €15,800.", "manufacturer", None),
("Toro Padel", "IT", None, "toro-padel.it",
"100+ courts since 2019, patented lighting.", "manufacturer", None),
# ---- France (4) ----
("EPS Concept", "FR", "Moutiers", "eps-concept.com",
"French manufacturer, 2,000+ courts, FFT PQP certified.", "manufacturer", "+33 2 99 96 42 61"),
("Padel 360", "FR", "Bischheim", "padel360.fr",
"FFT PQP certified. Turnkey with 10-year warranty. Automated club solutions and video scoring.", "manufacturer", "+33 7 80 91 69 43"),
("France Padel", "FR", "Paris", "france-padel.fr",
"Only French company 100% padel-dedicated. Premium, innovation-focused.", "manufacturer", "+33 5 35 45 55 00"),
("100% Padel", "FR", None, "centpourcentpadel.fr",
"Family company managed by pro player Jérémy Scatena. French-manufactured.", "manufacturer", "+33 6 69 78 18 47"),
("Constructeur Padel", "FR", None, "constructeur-padel.fr",
"10+ years, 100+ clubs. Turnkey FIP-compliant courts. French/ecological materials.", "manufacturer", "+33 1 59 30 28 24"),
("Le Padel Français", "FR", None, "lepadelfrancais.fr",
"100% made in France, eco-responsible. Qualisteelcoat C5 protection. Hydro'Way permeable flooring.", "manufacturer", None),
("Metal Padel", "FR", "Rousset", "padel.metal-laser.com",
"First French padel manufacturer. FFT approved. Installs in France, Sweden, UK, Mauritius.", "manufacturer", None),
("SMC2 Construction", "FR", "Mornant", "smc2-construction.com",
"Covered halls specialist, wood & textile membrane. Largest athletics hall in Southern Europe.", "hall_builder", "+33 4 78 67 60 56"),
("Univers Construction", "FR", "Bouc-Bel-Air", "universconstruction.com",
"French manufacturer/installer. Showcase center with 9 courts. 20+ years.", "manufacturer", "+33 6 69 02 08 09"),
("3S Sport Systems", "FR", "Montpellier", "sportsystems.fr",
"3mm steel, Saint-Gobain Securit glass. 20-year structure warranty.", "manufacturer", None),
("WeOui Padel", "FR", "Valence", "terrain-padel.com",
"High-end courts, FFT compliant. Custom furniture, decoration, accessories, maintenance.", "manufacturer", None),
("KIP Sport", "FR", None, "kipsport.fr",
"FFT Qualisport, 30 years sports infra.", "manufacturer", None),
("Storkeo", "FR", None, "storkeo.com",
"Turnkey including real estate & financing.", "turnkey", None),
("VW Sports Padel", "FR", "Noisy-le-Grand", "vwsports.fr",
"Historic tennis company, now padel, French manufacturing.", "manufacturer", "+33 1 48 45 04 29"),
("Lauralu Industrie", "FR", None, "lauralu.com",
"Padel court covers/halls specialist, FFT certified, 20+ years.", "hall_builder", None),
("ACS Production", "FR", "Near Nantes", None,
"Court construction & coverage, 20+ years, 25-yr membrane warranty.", "manufacturer", "+33 2 40 45 94 94"),
("Concasport", "FR", None, None,
"French manufacturer, custom designs.", "manufacturer", None),
("FieldTurf", "FR", None, "fieldturf.com",
"25+ years synthetic turf for tennis/padel. 1,000,000+ m² installed. FIFA Preferred Producer. Part of Tarkett.", "turf", None),
("Losberger De Boer", "DE", None, "losbergerdeboer.com",
"Semi-permanent modular padel structures. Aluminum/wood frames with canvas roofing.", "hall_builder", None),
("Infinite Padel Courts", "FR", None, "infinitepadelcourts.com",
"Custom courts, height-adjustable, manufactured in Alicante.", "manufacturer", "infinitepadelcourts@gmail.com"),
# ---- Portugal (5) ----
("inCourts Padel", "PT", "Lisbon", "incourtspadel.com",
"Robotic manufacturing, factory in North Portugal.", "manufacturer", None),
("Greenpark", "PT", None, "greenpark.com.pt",
"First Portuguese-made padel court manufacturer.", "manufacturer", None),
("Sports Evolution", "PT", None, "sports-evolution.pt",
"Builder/installer, also manufactures covers.", "manufacturer", None),
("Sports Partner", "PT", None, "sportspartner.pt",
"Equipment supplier, multi-sport.", "manufacturer", None),
# ---- United Kingdom (6) ----
("PRO Padel Courts", "GB", None, "propadelcourts.com",
"Times 100 Ones to Watch 2025. Italian-engineered, patented anti-noise system. 50-year lifespan. MejorSet master distributor UK.", "manufacturer", None),
("Padel Tech", "GB", None, "padeltech.co.uk",
"Leading UK supplier/installer. Exclusive AFP Courts/adidas UK distributor. 150+ courts.", "turnkey", None),
("Hexa Padel", "GB", "Woodford Green", "hexapadel.co.uk",
"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",
"Turnkey, MejorSet distributor, SAPCA approved.", "turnkey", None),
("SIS Pitches", "GB", None, "sispitches.com",
"25+ years elite sports surfaces. UK-based turf manufacturer. Design, manufacture, install, maintain. 65 years.", "turf", None),
("Padel Magic UK", "GB", None, "padelmagic.co.uk",
"Proprietary Magic Base for uneven terrain. Custom covers/canopies. Nationwide.", "manufacturer", None),
("Padel Build UK", "GB", "North Lincolnshire", "padelbuilduk.com",
"UK manufacturer, hot-dip galvanizing.", "manufacturer", None),
("Padel Systems", "GB", None, "padelsystems.co.uk",
"Bespoke builder, partners with Italian Padel. Sister company: CopriSystems.", "manufacturer", None),
("Red Raven Solutions", "GB", None, "redravensolutions.co.uk",
"Exclusive PadelCreations UK distributor, 500+ courts.", "turnkey", None),
("Padel Works UK", "GB", "Whitchurch", "padelworks.co.uk",
"FIP-approved courts. 10-year warranty on court and surface.", "manufacturer", None),
("Padel Galis UK", "GB", "Coventry", "padelgalis.uk",
"Exclusive UK supplier of Padel Galis.", "turnkey", "info@padelgalis.uk"),
("S&C Slatter", "GB", None, "slattersportsconstruction.com",
"30+ years sports construction. Partners with FieldTurf. In-house civil engineering.", "turnkey", None),
("Fordingbridge", "GB", "West Sussex", "fordingbridge.co.uk",
"UK's leading padel canopy specialist, 60+ years, 25-yr guarantee.", "hall_builder", "info@fordingbridge.co.uk"),
("Collinson Tensile", "GB", None, "collinsontensile.co.uk",
"20+ years tensile buildings. Exclusive UK partner for Best-Hall Finland. ISO 9001/45001.", "hall_builder", None),
("Rubb UK", "GB", "Gateshead", "rubbuk.com",
"Fabric building specialists. Thermohall insulation. Modular, relocatable.", "hall_builder", None),
("J & J Carter", "GB", None, "jjcarter.com",
"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) ----
("Allesvoorpadel", "NL", "Biddinghuizen", "allesvoorpadel.nl",
"Leading Dutch builder, 10+ years, NK Padel official rink builder. AFP Courts partner. Philips lighting.", "manufacturer", "info@allesvoorpadel.nl"),
("Padel Nederland B.V.", "NL", "Monster", "padelnederland.nl",
"Durable aluminum courts, 15-year warranty. KIWA/KNLTB certified. Solar canopy options, acoustic solutions.", "manufacturer", "info@padelnederland.nl"),
("I-Padel", "NL", None, "i-padel.nl",
"Dutch manufacturer, in-house production. KIWA ISA Sport / NOC*NSF certified. 15-year warranty. Also offers Ping-Pong Padel.", "manufacturer", None),
("SkyPadel NL", "NL", "Zuid-Holland", "skypadel.nl",
"1,800+ courts, Babolat official, since 2002.", "turnkey", "info@skypadel.nl"),
("Padel.nl", "NL", None, "padel.nl",
"Since 2003, KNLTB/KIWA certified, proprietary foundations.", "manufacturer", None),
("Orange Padel International", "NL", None, "orangepadel.nl",
"Premium Dutch-designed courts. 30 years experience. FEMEPA certification.", "manufacturer", None),
("Padel Solution NL", "NL", None, "padelsolution.nl",
"3,000+ installed courts in 30+ countries. Full project support.", "turnkey", None),
("World Padel NL", "NL", None, "worldpadel.nl",
"Court supplier.", "manufacturer", None),
("Lumosa", "NL", None, "lumosa.eu",
"Dutch LED sports lighting manufacturer. Custom lighting plans, up to 80% energy savings.", "lighting", None),
("Frisomat", "BE", None, "frisomat.com",
"Nearly 50 years steel construction. Cold-formed galvanized padel canopies/roofs. Modular, demountable.", "hall_builder", None),
# ---- Belgium (8) ----
("Padel Projects", "BE", None, "padelprojects.eu",
"Court construction, 10+ years, 3,000+ courts, patented lighting.", "manufacturer", None),
("JM Padel", "BE", "Province of Liège", "jmpadel.be",
"Installer, consultant, club management, IT/video.", "turnkey", None),
("YoPadel SPRL", "BE", None, "yopadel.be",
"Belgian manufacturer, Belgian materials, Lano Sports turf partner.", "manufacturer", None),
("Domo Sports Grass", "BE", None, None,
"Global artificial grass expert. Certified by major sports federations.", "turf", None),
# ---- Scandinavia (9) ----
("Padeltotal", "SE", None, "padeltotal.se",
"Largest Nordic supplier. 1,600+ courts since 2013. Galvanized for Nordic conditions, 12mm glass. Duruss partnership.", "manufacturer", None),
("Padel Global", "SE", "Jönköping", "padel-global.com",
"Manufacturer, own factory.", "manufacturer", None),
("Scandinavian Padel AB", "SE", "Malmö", "scandinavianpadel.co",
"25+ years in steel/glass for Nordic climate. C3 oil rig grade steel. Own ScanTurf turf.", "manufacturer", "info@scandinavianpadel.co"),
("Sweden Padel Master", "SE", None, "swedenpadelmaster.se",
"SPM Grass Court Cut technology.", "manufacturer", None),
("Acenta Group", "SE", None, "acenta.group",
"Major Scandinavian company, courts/service/digital/equipment. Fiberglass courts expanding to AU/NZ.", "manufacturer", None),
("Instantpadel", "SE", None, "instantcourts.com",
"World-unique mobile court, setup in <4 hours. 150+ courts, 17 countries. Installations at Gleneagles, Soho Club London.", "manufacturer", None),
("Hallgruppen", "SE", None, "hallgruppen.com",
"Padel hall structures. Self-supporting steel frames (50-year lifespan). Rental, leasing, purchase. CE approved.", "hall_builder", None),
("Best-Hall", "FI", None, None,
"5,500+ buildings worldwide. 40+ years. Fabric structures for sports halls.", "hall_builder", None),
("ViPadel", "DK", None, "vipadel.dk",
"Total supplier, official Mondo dealer DK/FI.", "turnkey", None),
("A-Sport", "DK", None, "a-sport.dk",
"Supplier/installer, 250+ courts.", "manufacturer", None),
("Tiebreak International", "DK", "Glostrup", "padeltotal.dk",
"PadelTotal concept for Denmark, 200+ courts.", "turnkey", "info@tiebreakinternational.com"),
("Unisport", "FI", None, "unisport.com",
"Court manufacturer, Saltex Tempo turf.", "manufacturer", None),
# ---- Other European (10) — unique entries only ----
("DUOL", "SI", None, "duol.eu",
"Air-supported and fabric sports buildings. Nearly 30 years. Online configurator.", "hall_builder", None),
# ---- USA: Manufacturers & Builders (11.1) ----
("Absolute Padel", "US", "Mohnton, PA", "absolutepadelusa.com",
"Only North America-based manufacturer. 100+ projects. 50%+ of US courts. Unique Pickleball & Padel combo court.", "manufacturer", "+1 717 445 5036"),
("The Padel Box", "US", None, "thepadelbox.com",
"US pioneer since 2012, licensed in 15+ states. Official MejorSet US/Canada distributor. Hurricane-rated to 180 mph.", "manufacturer", "info@padelbox.com"),
("Sportsfield Specialties", "US", "Delhi, NY", "sportsfield.com",
"USPA-endorsed manufacturer, 100% Made in USA, PaDelhi courts.", "manufacturer", "+1 607 746 8911"),
("USA Padel Center", "US", "Houston, TX", "usapadel.com",
"Manufacturer/consultant since 2007.", "manufacturer", "+1 713 539 3110"),
("Padel One Courts", "US", "Florida", "padelonecourts.com",
"Premium American-made courts. C5 anti-rust coating. Trusted by Pro Padel League and SVB Mouratoglou Academy.", "manufacturer", None),
("Bounce Padel Courts", "CA", None, "bouncepadelcourts.com",
"Premier North American provider. SGCC/ANSI-certified glass. Hurricane-class anchoring. Converts tennis courts and ice rinks.", "manufacturer", None),
("Northeast Padel", "US", "Pocasset, MA", "northeastpadel.com",
"Division of Cape & Island Tennis & Track, most-awarded US court builder. 50+ facility awards from ASBA.", "manufacturer", "+1 508 759 5636"),
("Mondo Padel US", "US", "West Palm Beach, FL", "mondopadel.com",
"150+ years combined team experience. FL licensed. Builds from ground up or converts tennis courts.", "manufacturer", "+1 888 423 1120"),
("Keystone Sports Construction", "US", None, "keystonesportsconstruction.com",
"Full-service turnkey padel from design to installation. Sportsfield Specialties partner.", "turnkey", None),
("MTJ Sports", "US", "Chicago", "mtjsports.com",
"20+ years sports courts. Padel, pickleball, soccer, tennis. Turnkey for clubs, hotels, municipalities.", "turnkey", None),
("Capas Padel", "US", None, "capaspadel.com",
"Builder/consultant, GreenSet surfaces, Smart Padel Club.", "turnkey", None),
("All Racquet Sports", "US", "Sandy, UT", "allracquetsports.com",
"Official adidas/AFP US distributor, 700+ courts network.", "turnkey", "info@allracquetsports.com"),
# ---- USA: Franchises & Operators (11.3) ----
("Conquer Padel Club", "US", "Lehi, UT", "conquerpadel.com",
"First US padel franchise, $1.1M-$3M+ investment.", "franchise", None),
("Park Padel", "US", "San Francisco", "parkpadel.com",
"Franchise, pop-up courts, community-focused.", "franchise", "hello@parkpadel.com"),
("Jungle Padel", "US", None, "junglepadel.com",
"Franchise, premium Mondo turf, academy.", "franchise", None),
# ---- USA: Turf (11.4) — unique entries only ----
("WinterGreen Synthetic Grass", "US", "Dallas, TX", "wintergreengrass.com",
"Pro-grade padel turf in DFW. Padel Pro surface (same as WPT).", "turf", None),
("Laykold", "US", None, "laykold.com",
"Padel Turf Pro surface. US Open official surface brand. Part of Sport Group.", "turf", None),
# ---- USA: Lighting (11.5) ----
("LED Lighting Supply", "US", None, "ledlightingsupply.com",
"15+ years, 25,000+ projects. 150W LED fixtures. Free photometric plans. 5-year warranty.", "lighting", None),
("Tweener USA", "US", None, "tweenerusa.com",
"Patented LED on existing fencing — no poles needed. Minimal light pollution. Dimmable.", "lighting", None),
("Brite Court", "US", None, "britecourt.com",
"40+ years racquet sports lighting. 600+ facilities. 18+ fixture designs. Samsung LEDs. 10-year warranty.", "lighting", None),
("AEON LED Lighting", "US", None, "aeonledlighting.com",
"Patented luminaires. UGR below 19 (glare-free). 100,000-hour lifespan. DLC Premium listed.", "lighting", None),
("AGC Lighting", "CN", None, "agcled.com",
"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) ----
("American Padel", "MX", "Mexico City", "americanpadel.com.mx",
"FIP-compliant, 25 yrs metalwork.", "manufacturer", "+52 55 5891 3350"),
("Padel Center México", "MX", "Aguascalientes", "padelcenter.mx",
"FIP-certified. Clásica, Semipanorámica, Pro models. 20-30 day delivery.", "manufacturer", None),
("MG Canchas", "MX", "Monterrey", "mgcanchas.com",
"Pioneer manufacturer in Monterrey. Also supplies synthetic turf.", "manufacturer", None),
("SicaSport", "MX", None, "sicasport.com",
"Manufacturer/builder/installer.", "manufacturer", None),
("Gott Padel", "MX", None, "gottpadel.com",
"Design, installation, construction. Also sells rackets and balls.", "manufacturer", None),
("AFP Courts México", "MX", None, "afpcourts.mx",
"Official adidas licensee for Mexico.", "turnkey", None),
("CanchasdePadel.com", "MX", None, "canchasdepadel.com",
"FIP-certified. WPT-certified curly turf.", "manufacturer", None),
("Padel Works MX", "MX", None, "padelworks.com.mx",
"High-quality custom courts. Full support from civil works to club growth.", "manufacturer", None),
("PadelStore.mx", "MX", None, "padelstore.mx",
"Court accessories: fencing, nets, posts, turf, sand, LED, protective pads.", "manufacturer", None),
# ---- Middle East (13) ----
("Padel Factory ME", "AE", "Dubai", "padelfactory.me",
"Top manufacturer/supplier. Super Panoramic, Panoramic, Challenger, Portable. 400+ courts across UAE/KSA/Kuwait/Bahrain/Oman.", "manufacturer", "info@padelfactory.me"),
("RedLine Padel", "AE", "Dubai", "redlinepadel.com",
"Spanish manufacturer based in Dubai. 48-hour delivery across ME. UNE EN 1090.", "manufacturer", None),
("Cypex Group", "AE", None, "cypex-group.com",
"Represents Padel Factory ME. Exclusive LANO GRASS Belgium distributor for GCC.", "turnkey", None),
("APW Pools", "AE", "Dubai", "apw-pools.com",
"Padel supplier/installer, smart lighting, advanced materials.", "manufacturer", "+971 50 852 1161"),
("Mister Shade ME", "AE", "Dubai", "mistershademe.com",
"20+ years in flooring. Artificial turf and acrylic padel courts. All UAE emirates.", "manufacturer", None),
("Gebal Group", "AE", "Dubai", "gebalgroup.com",
"Turnkey builder across GCC (6 countries). FIP-compliant.", "turnkey", None),
("Empower Sport Services", "AE", None, "empowersportservices.com",
"2,500+ installations, FEP certified.", "manufacturer", None),
("Shades Galaxy", "AE", "Dubai", "shadesgalaxy.com",
"Manufacturer/supplier, all UAE emirates.", "manufacturer", None),
("Fab Floorings", "AE", "Dubai", "fabfloorings.ae",
"Turnkey: flooring/glass/lighting/branding.", "turnkey", None),
("Al Mustaqbal Alsarea", "AE", None, "almustaqbalalsarea.com",
"Gulf countries leader.", "manufacturer", "+971 50 247 5749"),
("PFS Gulf", "SA", None, "pfsgulf.com",
"Infrastructure company. Padel courts across KSA (Riyadh, Jeddah, Dammam, Mecca, Medina).", "manufacturer", None),
# ---- Turkey (14) ----
("Mediterra Padel", "TR", "Antalya", "mediterrapadel.com",
"Turkey's largest, 35+ countries. Active in Kenya, SA, Sierra Leone, Morocco, Nigeria.", "manufacturer", "info@mediterrapadel.com"),
("Integral Grass", "TR", "Istanbul", "integralgrass.com",
"11 models, 70+ countries.", "manufacturer", "info@integralgrass.com"),
# ---- China (15) ----
("Legend Sports", "CN", "Yanshan", "legendsports.com",
"One of China's largest. 220+ employees, 32 engineers. 5,000+ courts in 60+ countries. 66,000 m² factory.", "manufacturer", None),
("Fortune Padel", "CN", None, "fortunepadel.com",
"ISO 9001:2015. 20+ models including electric roof. Ships to 50+ countries within 20 days.", "manufacturer", None),
("China Youngman Padel", "CN", "Hefei", "youngpadel.com",
"China's largest since 2010. SGS, CE, ISO9001. 8 models. Also produces roof covers.", "manufacturer", None),
("Wanhe Padel", "CN", "Huaian", "wanhesport.com",
"Follows FIP regulations. Courts, 12mm turf, LED lighting. Also padel rackets.", "manufacturer", None),
("Shengshi Sports Tech", "CN", "Tianjin", None,
"20+ years sports equipment. 10,000+ m² facility near Tianjin port.", "manufacturer", None),
("PadelCourt10", "CN", "Hebei", "padelcourt10.com",
"1,000 sets/year capacity. 25+ countries. 5-year warranty. DDU door-to-door service.", "manufacturer", None),
("Shanghai Super Power", "CN", "Shanghai", "padelcourtfactory.cn",
"200+ employees. 350 courts/month capacity. Aluminum frames for coastal regions.", "manufacturer", None),
("UNIPADEL", "CN", "Guangzhou", "gzunipadel.com",
"5,000+ courts worldwide. Panoramic, classic, portable, roofed. Active in Indonesia, ME, Africa, LATAM.", "manufacturer", None),
("ArtPadel", "CN", None, "artpadel.com",
"Panoramic/classic courts, patented technology.", "manufacturer", None),
("LDK China", "CN", "Shenzhen", "ldkchina.com",
"Manufacturer/exporter.", "manufacturer", "info@ldkchina.com"),
("SANJING Group", "CN", "Linqu", "sanjingcourt.com",
"Glass specialist, 27 years, 300+ employees, 40+ countries.", "manufacturer", None),
("Luckin Padel", "CN", None, "luckinpadel.com",
"FIP-certified standards, educational focus.", "manufacturer", None),
("Saintyol Sports", "CN", None, None,
"15+ years. Specializes in padel turf and structures. 10,000+ m² facility.", "manufacturer", None),
("Nanjing Padelworker", "CN", "Nanjing", None,
"67% client reorder rate. Courts, squash equipment, glass fittings, turf.", "manufacturer", None),
("Hebei Aohe Teaching Equipment", "CN", "Hebei", None,
"Est. 2012. 80% repeat business. Also aluminum frame sports tents.", "manufacturer", None),
("Shandong Century Star", "CN", "Shandong", None,
"Large facility. Steel structure courts, panoramic models.", "manufacturer", None),
("CCGrass", "CN", None, "ccgrass.com",
"Three factories. FEP-compliant. FastPro and YEII products. Also complete court packages.", "turf", None),
("JCTurf", "CN", None, "jcturf.com",
"In-house fiber extrusion. FIP/FEP compliant. Also complete court solutions.", "turf", None),
("MightyGrass", "CN", None, "mightygrass.com",
"Professional padel turf, FEP-level. Factory direct pricing.", "turf", None),
# ---- India (16) ----
("Asian Flooring India", "IN", "Mumbai", "afipadel.com",
"India's largest padel manufacturer. FIP-standard. Standard, Panoramic, Ultra Panoramic, Kids. 25-day delivery.", "manufacturer", None),
("Apex Sport Surfaces", "IN", "Mumbai", "apexsportsurfaces.in",
"FIP-compliant manufacturer/exporter.", "manufacturer", None),
("Sky Padel India", "IN", "Mumbai", "skypadel.in",
"Local manufacturing, subsidiary of Spanish Sky Padel.", "manufacturer", None),
("PFS Sport India", "IN", None, "pfs.sport",
"Turf & surface manufacturer.", "turf", None),
("PadelHaus India", "IN", None, "padelhaus.in",
"Courts for every budget.", "manufacturer", None),
# ---- Asia Other (17) ----
("SmartPadel", "SG", None, "smartpadel.asia",
"Regional leader in SE Asia. Video recording, automated scoring. Part of SEARA Sports.", "manufacturer", None),
("Olympia Courts", "AU", None, "olympiacourts.com",
"Premium courts. European know-how, Asian manufacturing. Partners with Asia Pacific Padel Tour.", "manufacturer", None),
("Indo Padel", "ID", "Bali", "indopadel.com",
"Spanish-Indonesian team. Courts manufactured in Indonesia. Active in Thailand, India.", "manufacturer", None),
("Padel Asia", "TH", "Bangkok", "padelasia.org",
"Courts meeting international standards. Also sells rackets, clothing. Operates courts in Bangkok.", "manufacturer", None),
# ---- Brazil (18.1) ----
("Padel Master Brasil", "BR", None, "padelmaster.com.br",
"1,500+ courts in 10+ countries. Official Pala Tour/WPT Americas court. 10-year warranty.", "manufacturer", None),
("Sky Padel Brasil", "BR", None, "skypadel.com.br",
"10+ years. FEP/FIP certified. VP PRO 2.0, SP PRO, Full View, rental, mobile. 15-day production.", "manufacturer", None),
("Smart Padel BR", "BR", None, "smart-padel.com",
"IoT-connected courts. Online monitoring, strategic management software. FIP/FEP certified.", "manufacturer", None),
("Flores Pádel", "BR", None, "florespadel.com.br",
"Community-based manufacturer. 10+ years. Turf certified by Spanish federation. Founded by national team athletes.", "manufacturer", None),
("FC Quadras", "BR", "São Paulo", "fcquadras.com.br",
"Distributes Padelgest courts. Turnkey from terrain prep to finishing.", "turnkey", None),
("Padel Prime", "BR", None, "padelprime.com.br",
"European-standard manufacturing. Co-founded by ex-footballer Edmílson.", "manufacturer", None),
("F4 Quadras", "BR", None, None,
"Manufacturer/installer.", "manufacturer", None),
# ---- Argentina (18.2) ----
("Padel Courts Master", "AR", None, "padelcourtsmaster.ar",
"1,500+ courts delivered worldwide, 8+ countries. Robotic welding. FIP-standard.", "manufacturer", None),
("MS Pádel", "AR", None, "metalurgicametalser.com",
"Professional panoramic courts. Full package: structure, glass, LED, turf.", "manufacturer", "+54 2314 407746"),
("World Padel Court", "AR", None, "worldpadelcourt.com.ar",
"Led by Visión Deportiva. Top-quality courts. Portable court rental.", "manufacturer", None),
("Blue Court", "AR", None, "bluecourt.com.ar",
"15+ years manufacturing. Established Argentine brand.", "manufacturer", None),
("Slavon Césped Sintético", "AR", None, "slavoncespedsintetico.com",
"Panoramic and full panoramic courts. Also provides synthetic turf.", "manufacturer", None),
# ---- Africa (19) ----
("Padel Nation", "ZA", None, "padelnation.co.za",
"SA's leading manufacturer. 150+ courts. Local hot-dip galvanized manufacturing. 4-week lead times. Up to 10-year warranty.", "manufacturer", None),
("Padel Build SA", "ZA", None, "padelbuild.co.za",
"Premier turnkey builder. Partnered with Spain's Padel Galis. First FlexiPadel Base in Africa.", "turnkey", None),
("Techno Padel", "ZA", None, "technopadel.co.za",
"Premier supplier/installer. 5-7 day installation.", "manufacturer", None),
("Padel Solutions SA", "ZA", None, "padelsolutions.co.za",
"Proudly SA. Design, manufacture, install. Turnkey.", "manufacturer", None),
("Padel Quip", "ZA", None, "padelquip.co.za",
"Local manufacturer. Basic to Premium Plus models. Partnership and financial assistance.", "manufacturer", None),
("Padel Projects SA", "ZA", None, "padelprojects.co.za",
"Designed for local conditions.", "manufacturer", None),
("Trompie Sport", "ZA", None, "trompiesport.co.za",
"Builder, imported & local courts.", "manufacturer", None),
("Belgotex Sport", "ZA", "Pietermaritzburg", "belgotexsport.co.za",
"SA-based turf manufacturer. UNE 147301:2018 compliant. 220+ installations.", "turf", None),
("Africa Padel", "ZA", None, "africapadel.com",
"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",
"Contractor, one of the best in Arab world.", "manufacturer", None),
("Turkan Company", "EG", None, "turkan-eg.com",
"Manufacturer, one of first in Egypt.", "manufacturer", None),
# ---- Australia & Oceania (20) ----
("APT Asia Pacific", "AU", "Melbourne", "aptasiapacific.com.au",
"Asia Pacific's largest sports surfaces company. Australian-made turf. Aluminium 6061-T6 frames. AS/NZS certified. Pop-up courts.", "manufacturer", None),
("Synthetic Padel Courts", "AU", None, "syntheticpadelcourts.com.au",
"Built the first padel court in Australia. Preferred installer for Indoor Padel Australia, Sydney Racquet Club.", "turnkey", None),
("Padel in One Australia", "AU", None, "padelinone.com.au",
"Turnkey specialist. 8+ years. Management, marketing, operations consulting.", "turnkey", None),
("PadelVolt", "AU", None, "padelvolt.com",
"End-to-end premium service. MejorSet distributor across Oceania and Pacific Islands. Extreme weather designs.", "manufacturer", None),
("Padel 360 Australia", "AU", None, "padel360.com.au",
"Developer/builder/manager, Gimpadel partner.", "turnkey", None),
("AS Lodge Tennis Courts", "AU", "Melbourne", "asltenniscourts.com.au",
"Builder, $90K-$130K per court.", "manufacturer", None),
("All Sport Projects", "AU", None, "allsportprojects.com.au",
"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) ----
("Playtomic", "ES", "Madrid", "playtomic.com",
"World's largest racket sports platform. 6,700+ clubs, 4M+ players, 52+ countries. €56M raised.", "software", None),
("MATCHi", "SE", None, "tpcmatchpoint.com",
"1,600+ venues. Multi-sport booking, memberships, leagues, coaching.", "software", None),
("Padel Mates", "NL", None, None,
"All-in-one platform. Won Rocket Padel (Europe's largest indoor chain). Gamification features.", "software", None),
("SmashClub", "NL", None, "smashclub.cloud",
"Padel CRM and club management. Integrates with Playtomic, MATCHi, Padel Mates.", "software", None),
("Taykus", "ES", None, None,
"Software specifically for padel clubs. Online booking, payment, communication automation.", "software", None),
("Playbypoint", "US", None, "playbypoint.com",
"Official tech partner of 2025 US Open Padel. Custom branded app per club.", "software", None),
("CourtReserve", "US", None, None,
"Leading US booking platform alongside Playbypoint and Playtomic.", "software", None),
("360Player", "IS", None, "en-us.360player.com",
"Club management with website builder, video analysis, player development tools.", "software", None),
("Booklux", "EE", None, "booklux.com",
"Customizable booking system. Stripe payments. Google Analytics integration.", "software", None),
("SetTime", "US", None, "settime.io",
"Free padel booking software. Google/Apple Calendar sync. Analytics for utilization.", "software", None),
("ProPadelKit", "GB", None, "propadelkit.com",
"Turnkey court solutions, Classic & Fusion models.", "manufacturer", None),
]
def up(conn):
# Table, indexes, FTS, and triggers are created by schema.sql (IF NOT EXISTS).
# This migration only needs to seed data.
row = conn.execute("SELECT COUNT(*) FROM suppliers").fetchone()
if row[0] > 0:
return
# Seed suppliers
seen_slugs = set()
for name, cc, city, website, desc, cat, contact in _SUPPLIERS:
slug = _slugify(name)
if slug in seen_slugs:
i = 2
while f"{slug}-{i}" in seen_slugs:
i += 1
slug = f"{slug}-{i}"
seen_slugs.add(slug)
region = _REGION.get(cc, "Other")
conn.execute(
"INSERT INTO suppliers"
" (name, slug, country_code, city, region, website, description, category, contact)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(name, slug, cc, city, region, website, desc, cat, contact),
)

View File

@@ -0,0 +1,20 @@
"""Add tier, logo, verified, highlight, and sticky columns to suppliers."""
def up(conn):
existing = {
row[1] for row in conn.execute("PRAGMA table_info(suppliers)").fetchall()
}
columns = [
("tier", "TEXT NOT NULL DEFAULT 'free'"),
("logo_url", "TEXT"),
("is_verified", "INTEGER NOT NULL DEFAULT 0"),
("highlight", "INTEGER NOT NULL DEFAULT 0"),
("sticky_until", "TEXT"),
("sticky_country", "TEXT"),
]
for name, definition in columns:
if name not in existing:
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {name} {definition}")

View File

@@ -83,6 +83,11 @@ DEFAULTS = {
"holdYears": 5, "holdYears": 5,
"exitMultiple": 6, "exitMultiple": 6,
"annualRevGrowth": 2, "annualRevGrowth": 2,
"budgetTarget": 0,
"country": "DE",
"permitsCompliance": 12000,
"glassType": "standard",
"lightingType": "led_standard",
"ramp": [0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.93, 0.96, 0.98, 1], "ramp": [0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.93, 0.96, 0.98, 1],
"season": [0, 0, 0, 0.7, 0.9, 1, 1, 1, 0.8, 0, 0, 0], "season": [0, 0, 0, 0.7, 0.9, 1, 1, 1, 0.8, 0, 0, 0],
} }
@@ -166,6 +171,13 @@ def calc(s: dict) -> dict:
) )
d["sqm"] = d["hallSqm"] if is_in else d["outdoorLandSqm"] d["sqm"] = d["hallSqm"] if is_in else d["outdoorLandSqm"]
# -- Multipliers for glass and lighting --
glass_mult = 1.4 if s["glassType"] == "panoramic" else 1.0
light_mult = 1.5 if s["lightingType"] == "led_competition" else 1.0
# Natural light zeroes out lighting costs (outdoor only)
if s["lightingType"] == "natural" and not is_in:
light_mult = 0
# -- CAPEX -- # -- CAPEX --
capex_items: list[dict] = [] capex_items: list[dict] = []
@@ -174,8 +186,9 @@ def calc(s: dict) -> dict:
ci( ci(
"Padel Courts", "Padel Courts",
s["dblCourts"] * s["courtCostDbl"] + s["sglCourts"] * s["courtCostSgl"], (s["dblCourts"] * s["courtCostDbl"] + s["sglCourts"] * s["courtCostSgl"]) * glass_mult,
f"{s['dblCourts']}\u00d7dbl + {s['sglCourts']}\u00d7sgl", f"{s['dblCourts']}\u00d7dbl + {s['sglCourts']}\u00d7sgl"
+ (" (panoramic)" if s["glassType"] == "panoramic" else ""),
) )
ci("Shipping", math.ceil(total_courts / 2) * s["shipping"] if total_courts else 0) ci("Shipping", math.ceil(total_courts / 2) * s["shipping"] if total_courts else 0)
@@ -190,7 +203,7 @@ def calc(s: dict) -> dict:
f"{land_sqm}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2") f"{land_sqm}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2")
ci("Transaction Costs", _round(land_sqm * s["landPriceSqm"] * 0.1), "~10% of land") ci("Transaction Costs", _round(land_sqm * s["landPriceSqm"] * 0.1), "~10% of land")
ci("HVAC System", s["hvac"]) ci("HVAC System", s["hvac"])
ci("Electrical + Lighting", s["electrical"]) ci("Electrical + Lighting", s["electrical"] * light_mult)
ci("Sanitary / Changing", s["sanitary"]) ci("Sanitary / Changing", s["sanitary"])
ci("Parking + Exterior", s["parking"]) ci("Parking + Exterior", s["parking"])
ci("Planning + Permits", s["planning"]) ci("Planning + Permits", s["planning"])
@@ -198,13 +211,15 @@ def calc(s: dict) -> dict:
else: else:
ci("Floor Preparation", s["floorPrep"]) ci("Floor Preparation", s["floorPrep"])
ci("HVAC Upgrade", s["hvacUpgrade"]) ci("HVAC Upgrade", s["hvacUpgrade"])
ci("Lighting Upgrade", s["lightingUpgrade"]) ci("Lighting Upgrade", s["lightingUpgrade"] * light_mult)
ci("Fit-Out & Reception", s["fitout"]) ci("Fit-Out & Reception", s["fitout"])
ci("Permits & Compliance", s["permitsCompliance"])
else: else:
ci("Concrete Foundation", (s["dblCourts"] * 250 + s["sglCourts"] * 150) * s["outdoorFoundation"]) ci("Concrete Foundation", (s["dblCourts"] * 250 + s["sglCourts"] * 150) * s["outdoorFoundation"])
ci("Site Work", s["outdoorSiteWork"]) ci("Site Work", s["outdoorSiteWork"])
ci("Lighting", total_courts * s["outdoorLighting"]) ci("Lighting", total_courts * s["outdoorLighting"] * light_mult)
ci("Fencing", s["outdoorFencing"]) ci("Fencing", s["outdoorFencing"])
ci("Permits & Compliance", s["permitsCompliance"])
if is_buy: if is_buy:
ci("Land Purchase", d["outdoorLandSqm"] * s["landPriceSqm"], ci("Land Purchase", d["outdoorLandSqm"] * s["landPriceSqm"],
f"{d['outdoorLandSqm']}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2") f"{d['outdoorLandSqm']}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2")
@@ -418,4 +433,9 @@ def calc(s: dict) -> dict:
else 0 else 0
) )
# -- Budget comparison --
d["budgetTarget"] = s["budgetTarget"]
d["budgetVariance"] = d["capex"] - s["budgetTarget"] if s["budgetTarget"] > 0 else 0
d["budgetPct"] = d["capex"] / s["budgetTarget"] * 100 if s["budgetTarget"] > 0 else 0
return d return d

View File

@@ -50,10 +50,12 @@ async def get_scenarios(user_id: int) -> list[dict]:
# ============================================================================= # =============================================================================
@bp.route("/") @bp.route("/")
@login_required
async def index(): async def index():
scenario_count = await count_scenarios(g.user["id"]) scenario_count = 0
default = await get_default_scenario(g.user["id"]) default = None
if g.user:
scenario_count = await count_scenarios(g.user["id"])
default = await get_default_scenario(g.user["id"])
initial_state = json.loads(default["state_json"]) if default else {} initial_state = json.loads(default["state_json"]) if default else {}
state = validate_state(initial_state) state = validate_state(initial_state)
initial_d = calc(state) initial_d = calc(state)
@@ -66,7 +68,6 @@ async def index():
@bp.route("/calculate", methods=["POST"]) @bp.route("/calculate", methods=["POST"])
@login_required
async def calculate(): async def calculate():
data = await request.get_json() data = await request.get_json()
state = validate_state(data.get("state", {})) state = validate_state(data.get("state", {}))

View File

@@ -2,6 +2,11 @@
{% block title %}Padel Court Financial Planner - {{ config.APP_NAME }}{% endblock %} {% block title %}Padel Court Financial Planner - {{ config.APP_NAME }}{% endblock %}
{% block head %} {% block head %}
<meta name="description" content="Plan your padel court investment with our 60+ variable financial planner. Calculate ROI, CAPEX, cash flow, and more.">
<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:type" content="website">
<meta property="og:image" content="{{ url_for('static', filename='images/og-planner.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 src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
{% endblock %} {% endblock %}
@@ -29,52 +34,235 @@
<nav id="nav" class="tab-nav"></nav> <nav id="nav" class="tab-nav"></nav>
<main class="planner-main"> <main class="planner-main">
<!-- ASSUMPTIONS --> <!-- ASSUMPTIONS (Wizard) -->
<div class="tab" id="tab-assumptions"> <div class="tab" id="tab-assumptions">
<div class="grid-2"> <div class="wizard-header" id="wizardHeader">
<div> <div class="wizard-dots" id="wizardDots"></div>
<div class="mb-section"> <button id="resetDefaultsBtn" class="btn-reset" title="Reset all assumptions to defaults">Reset to Defaults</button>
<div class="section-header"><h3>Venue Type</h3></div> </div>
<label class="slider-group__label">Environment</label>
<div class="toggle-group" id="tog-venue"></div> <!-- Step 1: Your Venue -->
<label class="slider-group__label">Ownership Model</label> <div class="wizard-step active" data-wiz="1">
<div class="toggle-group" id="tog-own"></div> <h2 class="wizard-step__title">Your Venue</h2>
</div> <p class="wizard-step__sub">Define the type of facility you're planning to build.</p>
<div class="mb-section"> <div class="mb-section">
<div class="section-header"><h3>Court Configuration</h3></div> <label class="slider-group__label">Environment</label>
<div id="inp-courts"></div> <div class="toggle-group" id="tog-venue"></div>
<div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div> <label class="slider-group__label">Ownership Model</label>
<div id="inp-space"></div> <div class="toggle-group" id="tog-own"></div>
<div class="court-summary" id="courtSummary"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div>
<div id="inp-pricing"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Utilization &amp; Operations</h3></div>
<div id="inp-util"></div>
</div>
</div> </div>
<div> <div class="mb-section">
<div class="mb-section"> <div id="inp-country"></div>
<div class="section-header"><h3>Construction &amp; CAPEX</h3><span class="hint">Adjust per scenario</span></div> </div>
<div id="inp-capex"></div> <div class="mb-section">
</div> <div class="section-header"><h3>Court Configuration</h3></div>
<div class="mb-section"> <div id="inp-courts"></div>
<div class="section-header"><h3>Monthly Operating Costs</h3></div> <div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div>
<div id="inp-opex"></div> <div id="inp-space"></div>
</div> <div class="court-summary" id="courtSummary"></div>
<div class="mb-section">
<div class="section-header"><h3>Financing</h3></div>
<div id="inp-finance"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Exit Assumptions</h3></div>
<div id="inp-exit"></div>
</div>
</div> </div>
</div> </div>
<!-- Step 2: Pricing & Utilization -->
<div class="wizard-step" data-wiz="2">
<h2 class="wizard-step__title">Pricing &amp; Utilization</h2>
<p class="wizard-step__sub">Set your court rates, operating schedule, and ancillary revenue streams.</p>
<div class="mb-section">
<div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div>
<div id="inp-pricing"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Utilization &amp; Operations</h3></div>
<div id="inp-util"></div>
</div>
</div>
<!-- Step 3: Investment & Build Costs -->
<div class="wizard-step" data-wiz="3">
<h2 class="wizard-step__title">Investment &amp; Build Costs</h2>
<p class="wizard-step__sub">Configure construction costs, glass and lighting options, and your budget target.</p>
<div class="mb-section">
<div class="section-header"><h3>Construction &amp; CAPEX</h3><span class="hint">Adjust per scenario</span></div>
<div id="inp-capex"></div>
</div>
</div>
<!-- Step 4: Operations & Financing -->
<div class="wizard-step" data-wiz="4">
<h2 class="wizard-step__title">Operations &amp; Financing</h2>
<p class="wizard-step__sub">Monthly operating costs, loan terms, and exit assumptions.</p>
<div class="mb-section">
<div class="section-header"><h3>Monthly Operating Costs</h3></div>
<div id="inp-opex"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Financing</h3></div>
<div id="inp-finance"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Exit Assumptions</h3></div>
<div id="inp-exit"></div>
</div>
</div>
<!-- Step 5: Get Quotes -->
<div class="wizard-step" data-wiz="5">
<h2 class="wizard-step__title">Get Quotes from Suppliers</h2>
<p class="wizard-step__sub">Your project specs are pre-filled from the planner. Complete a few details and we'll match you with verified court builders.</p>
<div class="wiz-autofill-summary" id="wizAutoSummary"></div>
<form id="wizQuoteForm" onsubmit="return false">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="facility_type" id="wiz_facility_type">
<input type="hidden" name="court_count" id="wiz_court_count">
<input type="hidden" name="glass_type" id="wiz_glass_type">
<input type="hidden" name="lighting_type" id="wiz_lighting_type">
<input type="hidden" name="country" id="wiz_country">
<input type="hidden" name="budget_estimate" id="wiz_budget">
<div class="field-group">
<label class="field-label" for="wiz_city">City / Region</label>
<input type="text" id="wiz_city" name="city" class="wiz-input" placeholder="e.g. Munich, Bavaria">
</div>
<div class="field-group">
<span class="field-label">Build Context</span>
<div class="pill-grid">
<label><input type="radio" name="build_context" value="new_standalone"><span class="pill">New Standalone Venue</span></label>
<label><input type="radio" name="build_context" value="adding_to_club"><span class="pill">Adding to Existing Club</span></label>
<label><input type="radio" name="build_context" value="converting_building"><span class="pill">Converting a Building</span></label>
<label><input type="radio" name="build_context" value="venue_search"><span class="pill">Need Help Finding Venue</span></label>
</div>
</div>
<div class="field-group">
<span class="field-label">Project Phase</span>
<div class="pill-grid">
<label><input type="radio" name="location_status" value="still_searching"><span class="pill">Still searching</span></label>
<label><input type="radio" name="location_status" value="location_found"><span class="pill">Location identified</span></label>
<label><input type="radio" name="location_status" value="converting_existing"><span class="pill">Converting facility</span></label>
<label><input type="radio" name="location_status" value="lease_signed"><span class="pill">Lease / purchase signed</span></label>
<label><input type="radio" name="location_status" value="permit_not_filed"><span class="pill">Permit not filed</span></label>
<label><input type="radio" name="location_status" value="permit_pending"><span class="pill">Permit in progress</span></label>
<label><input type="radio" name="location_status" value="permit_granted"><span class="pill">Permit approved</span></label>
</div>
</div>
<div class="field-group">
<span class="field-label">Timeline <span class="required">*</span></span>
<div class="pill-grid">
<label><input type="radio" name="timeline" value="asap" required><span class="pill">ASAP</span></label>
<label><input type="radio" name="timeline" value="3-6mo"><span class="pill">3-6 Months</span></label>
<label><input type="radio" name="timeline" value="6-12mo"><span class="pill">6-12 Months</span></label>
<label><input type="radio" name="timeline" value="12+mo"><span class="pill">12+ Months</span></label>
</div>
</div>
<div class="field-group">
<span class="field-label">Financing Status</span>
<div class="pill-grid">
<label><input type="radio" name="financing_status" value="self_funded"><span class="pill">Self-Funded</span></label>
<label><input type="radio" name="financing_status" value="loan_approved"><span class="pill">Loan Approved</span></label>
<label><input type="radio" name="financing_status" value="seeking"><span class="pill">Seeking Financing</span></label>
<label><input type="radio" name="financing_status" value="not_started"><span class="pill">Not Started</span></label>
</div>
</div>
<div class="field-group">
<label class="field-label wiz-checkbox-label">
<input type="checkbox" name="wants_financing_help" value="1">
<span>I'd like help finding financing options</span>
</label>
</div>
<div class="field-group">
<span class="field-label">Decision Process</span>
<div class="pill-grid">
<label><input type="radio" name="decision_process" value="solo"><span class="pill">Solo Decision</span></label>
<label><input type="radio" name="decision_process" value="partners"><span class="pill">With Partners</span></label>
<label><input type="radio" name="decision_process" value="committee"><span class="pill">Committee / Board</span></label>
</div>
</div>
<div class="field-group">
<span class="field-label">You are... <span class="required">*</span></span>
<div class="pill-grid">
<label><input type="radio" name="stakeholder_type" value="entrepreneur" required><span class="pill">Entrepreneur / Investor</span></label>
<label><input type="radio" name="stakeholder_type" value="tennis_club"><span class="pill">Tennis / Sports Club</span></label>
<label><input type="radio" name="stakeholder_type" value="municipality"><span class="pill">Municipality / Public</span></label>
<label><input type="radio" name="stakeholder_type" value="developer"><span class="pill">Real Estate Developer</span></label>
<label><input type="radio" name="stakeholder_type" value="operator"><span class="pill">Existing Padel Operator</span></label>
<label><input type="radio" name="stakeholder_type" value="architect"><span class="pill">Architect / Engineer</span></label>
</div>
</div>
<div class="field-group">
<span class="field-label">Services Needed <span style="color:var(--txt-3);font-weight:400">(select all that apply)</span></span>
<div class="pill-grid">
<label><input type="checkbox" name="services_needed" value="court_supply"><span class="pill">Court Supply</span></label>
<label><input type="checkbox" name="services_needed" value="installation"><span class="pill">Installation</span></label>
<label><input type="checkbox" name="services_needed" value="construction"><span class="pill">Hall Construction</span></label>
<label><input type="checkbox" name="services_needed" value="design"><span class="pill">Facility Design</span></label>
<label><input type="checkbox" name="services_needed" value="lighting"><span class="pill">Lighting</span></label>
<label><input type="checkbox" name="services_needed" value="flooring"><span class="pill">Flooring</span></label>
<label><input type="checkbox" name="services_needed" value="turnkey"><span class="pill">Full Turnkey</span></label>
</div>
</div>
<div class="field-group">
<label class="field-label" for="wiz_additional">Anything else?</label>
<textarea id="wiz_additional" name="additional_info" class="wiz-input" rows="3" placeholder="Any specific requirements, questions, or context..."></textarea>
</div>
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0">
<div class="field-group">
<label class="field-label" for="wiz_name">Full Name <span class="required">*</span></label>
<input type="text" id="wiz_name" name="contact_name" class="wiz-input" required>
</div>
<div class="field-group">
<label class="field-label" for="wiz_email">Email <span class="required">*</span></label>
<input type="email" id="wiz_email" name="contact_email" class="wiz-input" required>
</div>
<div class="field-group">
<label class="field-label" for="wiz_phone">Phone <span style="color:var(--txt-3);font-weight:400">(optional)</span></label>
<input type="tel" id="wiz_phone" name="contact_phone" class="wiz-input">
</div>
<div class="field-group">
<label class="field-label" for="wiz_company">Company <span style="color:var(--txt-3);font-weight:400">(optional)</span></label>
<input type="text" id="wiz_company" name="contact_company" class="wiz-input">
</div>
<div class="wiz-privacy-box">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5 7V5a3.5 3.5 0 10-7 0v2M4 7h8a1 1 0 011 1v5a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1z" stroke="#3B82F6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Your contact details are shared only with 2-5 pre-vetted suppliers that match your project specs.</span>
</div>
<div class="consent-group">
<label>
<input type="checkbox" name="consent" value="1" required>
<span>I agree that my project details and contact info may be shared with verified padel court suppliers. <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> &middot; <a href="{{ url_for('public.terms') }}">Terms</a></span>
</label>
</div>
</form>
<div class="wiz-success" id="wizSuccess" style="display:none">
<div class="wiz-success__icon">&#10003;</div>
<h3>You're matched!</h3>
<p>We'll connect you with 2-5 verified suppliers within 48 hours. Check your email for confirmation.</p>
{% if not user %}
<div class="wiz-signup-nudge">
<p>Save this plan, compare scenarios, and get early access to financing tools.</p>
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">Create Account</a>
</div>
{% endif %}
</div>
</div>
<!-- Preview bar + navigation -->
<div class="wizard-preview" id="wizPreview"></div>
<div class="wizard-nav" id="wizNav"></div>
</div> </div>
<!-- CAPEX --> <!-- CAPEX -->
@@ -87,7 +275,7 @@
</div> </div>
<div class="lead-cta mt-4" id="capexCta"> <div class="lead-cta mt-4" id="capexCta">
<span class="lead-cta__text">These are estimates. Get actual quotes from verified court suppliers.</span> <span class="lead-cta__text">These are estimates. Get actual quotes from verified court suppliers.</span>
<a href="{{ url_for('leads.suppliers') }}" class="lead-cta__btn">Get Quotes</a> <a href="#" class="lead-cta__btn" onclick="wizStep=5;activeTab='assumptions';showWizStep();render();window.scrollTo({top:0,behavior:'smooth'});return false">Get Builder Quotes</a>
</div> </div>
</div> </div>
@@ -158,7 +346,8 @@
</div> </div>
<div class="lead-cta mt-4" id="returnsCta"> <div class="lead-cta mt-4" id="returnsCta">
<span class="lead-cta__text">Your project looks profitable. Ready to take the next step?</span> <span class="lead-cta__text">Your project looks profitable. Ready to take the next step?</span>
<a href="{{ url_for('leads.suppliers') }}" class="lead-cta__btn">Get Started</a> <a href="#" class="lead-cta__btn" onclick="wizStep=5;activeTab='assumptions';showWizStep();render();window.scrollTo({top:0,behavior:'smooth'});return false">Get Builder Quotes</a>
<a href="{{ url_for('billing.checkout', plan='business_plan') }}" class="lead-cta__btn lead-cta__btn--secondary">Export Business Plan (PDF) &mdash; &euro;99</a>
</div> </div>
</div> </div>
@@ -173,11 +362,13 @@
</div> </div>
</main> </main>
<footer class="lead-cta-bar"> {% if not user %}
<span>Ready to move forward?</span> <div class="signup-bar" id="signupBar">
<a href="{{ url_for('leads.suppliers') }}">Get Supplier Quotes</a> <span>Create an account to <b>save scenarios</b>, <b>compare plans</b>, and get early access to <b>financing tools</b>.</span>
<a href="{{ url_for('leads.financing') }}">Find Financing</a> <a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">Sign Up</a>
</footer> <button class="signup-bar__close" onclick="document.getElementById('signupBar').style.display='none'" aria-label="Dismiss">&times;</button>
</div>
{% endif %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="scenario-drawer"></div> <div id="scenario-drawer"></div>
@@ -194,6 +385,7 @@ window.__PADELNOMICS_INITIAL_D__ = {{ initial_d | safe }};
window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}"; window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}";
window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}"; window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/"; window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
window.__PADELNOMICS_QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
</script> </script>
<script src="{{ url_for('static', filename='js/planner.js') }}"></script> <script src="{{ url_for('static', filename='js/planner.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@ from pathlib import Path
from quart import Blueprint, Response, render_template from quart import Blueprint, Response, render_template
from ..core import config from ..core import config, fetch_one
bp = Blueprint( bp = Blueprint(
"public", "public",
@@ -14,9 +14,26 @@ bp = Blueprint(
) )
async def _supplier_counts():
"""Fetch aggregate supplier stats for landing/marketing pages."""
total = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
countries = await fetch_one(
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
)
return (
total["cnt"] if total else 0,
countries["cnt"] if countries else 0,
)
@bp.route("/") @bp.route("/")
async def landing(): async def landing():
return await render_template("landing.html") total_suppliers, total_countries = await _supplier_counts()
return await render_template(
"landing.html",
total_suppliers=total_suppliers,
total_countries=total_countries,
)
@bp.route("/features") @bp.route("/features")
@@ -39,6 +56,16 @@ async def about():
return await render_template("about.html") return await render_template("about.html")
@bp.route("/suppliers")
async def suppliers():
total_suppliers, total_countries = await _supplier_counts()
return await render_template(
"suppliers.html",
total_suppliers=total_suppliers,
total_countries=total_countries,
)
@bp.route("/sitemap.xml") @bp.route("/sitemap.xml")
async def sitemap(): async def sitemap():
base = config.BASE_URL.rstrip("/") base = config.BASE_URL.rstrip("/")
@@ -46,6 +73,9 @@ async def sitemap():
f"{base}/", f"{base}/",
f"{base}/features", f"{base}/features",
f"{base}/about", f"{base}/about",
f"{base}/planner/",
f"{base}/directory/",
f"{base}/suppliers",
f"{base}/billing/pricing", f"{base}/billing/pricing",
f"{base}/terms", f"{base}/terms",
f"{base}/privacy", f"{base}/privacy",

View File

@@ -10,109 +10,169 @@
<meta property="og:url" content="{{ config.BASE_URL }}"> <meta property="og:url" content="{{ config.BASE_URL }}">
<link rel="canonical" href="{{ config.BASE_URL }}"> <link rel="canonical" href="{{ config.BASE_URL }}">
<style> <style>
/* Teaser calculator — scoped styles for range inputs */ /* Hero two-column grid */
.teaser-calc input[type=range] { .hero-grid {
-webkit-appearance: none; display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center;
appearance: none;
height: 5px;
border-radius: 3px;
background: #E2E8F0;
outline: none;
cursor: pointer;
} }
.teaser-calc input[type=range]::-webkit-slider-thumb { .hero-badge {
-webkit-appearance: none; display: inline-flex; align-items: center; gap: 8px;
width: 16px; height: 16px; background: #F0FDF4; border: 1px solid #DCFCE7; border-radius: 999px;
border-radius: 50%; padding: 5px 14px; margin-bottom: 20px; font-size: 0.75rem; font-weight: 600; color: #16A34A;
background: #3B82F6;
border: 2px solid #FFFFFF;
cursor: pointer;
} }
.teaser-calc input[type=range]::-moz-range-thumb { .hero-title {
width: 16px; height: 16px; font-size: clamp(30px, 4.5vw, 48px); font-weight: 800; letter-spacing: -0.035em;
border-radius: 50%; line-height: 1.1; margin: 0 0 18px;
background: #3B82F6; }
border: 2px solid #FFFFFF; .hero-desc {
cursor: pointer; font-size: 1.0625rem; color: #64748B; line-height: 1.65; margin: 0 0 28px; max-width: 460px;
}
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
.hero-actions .btn { padding: 14px 26px; font-size: 1rem; font-weight: 700; border-radius: 10px; }
.hero-actions .btn-outline { padding: 14px 22px; font-size: 1rem; border-radius: 10px; }
.hero-bullets { display: flex; gap: 20px; font-size: 0.8125rem; color: #94A3B8; }
.hero-check { color: #16A34A; font-weight: 700; }
/* Quick ROI Calculator */
.roi-calc {
background: #FFFFFF; border: 1.5px solid #E2E8F0; border-radius: 20px;
padding: 28px; box-shadow: 0 8px 40px rgba(0,0,0,0.06);
}
.roi-calc__title { font-size: 0.875rem; font-weight: 700; margin-bottom: 4px; }
.roi-calc__sub { font-size: 0.75rem; color: #94A3B8; margin-bottom: 22px; }
.roi-calc__sliders { display: grid; gap: 18px; }
.roi-calc__row label {
display: flex; justify-content: space-between; font-size: 0.8125rem;
font-weight: 500; color: #64748B; margin-bottom: 6px;
}
.roi-calc__row label span {
color: #1D4ED8; font-weight: 700; font-size: 0.875rem;
font-family: 'Commit Mono', 'JetBrains Mono', monospace;
}
.roi-calc__row input[type="range"] {
width: 100%; accent-color: #1D4ED8; cursor: pointer; height: 6px;
}
.roi-metrics {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
border-top: 1px solid #E2E8F0; padding-top: 18px; margin-top: 24px;
}
.roi-metric { padding: 0; background: none; box-shadow: none; text-align: left; }
.roi-metric__label {
font-size: 0.6875rem; color: #94A3B8; text-transform: uppercase;
letter-spacing: 0.04em; margin-bottom: 3px;
}
.roi-metric__val {
font-size: 1.125rem; font-weight: 700; color: #0F172A;
font-family: 'Commit Mono', 'JetBrains Mono', monospace;
}
.roi-metric__val--green { color: #1D4ED8; }
.roi-metric__val--red { color: #DC2626; }
.roi-calc__note {
font-size: 0.6875rem; color: #94A3B8; margin-top: 14px; line-height: 1.5;
}
.roi-calc__cta {
display: block; width: 100%; text-align: center; margin-top: 16px;
background: #1D4ED8; color: #fff; border-radius: 10px; padding: 12px;
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;
}
.roi-calc__cta:hover { background: #1E40AF; color: #fff; }
/* Supplier matching section */
.match-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem;
max-width: 800px; margin: 0 auto;
}
.match-step { text-align: center; }
.match-step__num {
display: inline-flex; align-items: center; justify-content: center;
width: 48px; height: 48px; border-radius: 50%;
background: #EFF6FF; color: #1D4ED8; font-weight: 700; font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.match-step h3 { font-size: 1rem; margin-bottom: 0.25rem; }
.match-step p { font-size: 0.8125rem; color: #64748B; }
/* FAQ */
.faq { max-width: 640px; margin: 0 auto; }
.faq details { border-bottom: 1px solid #E2E8F0; padding: 1rem 0; }
.faq summary {
font-weight: 600; cursor: pointer; font-size: 0.9375rem; color: #1E293B;
list-style: none;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::before { content: "+ "; color: #1D4ED8; font-weight: 700; }
.faq details[open] summary::before { content: "- "; }
.faq p { color: #64748B; font-size: 0.875rem; margin-top: 0.5rem; line-height: 1.6; }
@media (max-width: 768px) {
.hero-grid { grid-template-columns: 1fr; gap: 32px; }
.hero-title { font-size: clamp(28px, 6vw, 36px); }
.hero-bullets { flex-wrap: wrap; gap: 12px; }
.roi-metrics { grid-template-columns: 1fr 1fr; }
.match-grid { grid-template-columns: 1fr; }
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="container-page"> <main class="container-page">
<!-- Hero --> <!-- Hero: two-column with calculator -->
<header class="text-center py-16 pb-8"> <section style="padding: 72px 0 56px">
<h1 class="text-4xl md:text-5xl leading-tight">Plan Your Padel Business<br>in Minutes, Not Months</h1> <div class="hero-grid">
<p class="text-xl text-slate max-w-xl mx-auto mt-4"> <div>
Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. <div class="hero-badge">&#x1F3BE; Padel court financial planner</div>
</p> <h1 class="hero-title">Plan Your Padel<br>Business in Minutes,<br><span class="text-electric">Not Months</span></h1>
</header> <p class="hero-desc">
Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers.
<!-- Teaser Calculator --> </p>
<section class="py-8 pb-12"> <div class="hero-actions">
<div class="teaser-calc card max-w-3xl mx-auto shadow-sm"> <a href="{{ url_for('planner.index') }}" class="btn">Open the Planner &rarr;</a>
<h2 class="text-center text-xl mb-6">Quick ROI Estimate</h2> <a href="{{ url_for('directory.index') }}" class="btn-outline">Browse Suppliers</a>
<div class="space-y-5">
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Courts</label>
<input type="range" id="tc-courts" min="2" max="12" step="1" value="6" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-courts">6</span>
</div> </div>
<div class="flex items-center gap-4"> <div class="hero-bullets">
<label class="w-36 shrink-0 text-sm text-slate">Peak Rate</label> <span><span class="hero-check">&#x2713;</span> No signup required</span>
<input type="range" id="tc-rate" min="20" max="100" step="5" value="50" oninput="tCalc()" class="flex-1"> <span><span class="hero-check">&#x2713;</span> 60+ variables</span>
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-rate">&euro;50/hr</span> <span><span class="hero-check">&#x2713;</span> Unlimited scenarios</span>
</div>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Utilization</label>
<input type="range" id="tc-util" min="15" max="75" step="5" value="40" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-util">40%</span>
</div>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Build Cost / Court</label>
<input type="range" id="tc-buildcost" min="20000" max="50000" step="5000" value="30000" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-buildcost">&euro;30K</span>
</div>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Equity %</label>
<input type="range" id="tc-equity" min="15" max="50" step="5" value="25" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-equity">25%</span>
</div> </div>
</div> </div>
<p class="text-center text-xs text-slate mt-3">Assumes &euro;8/m&sup2; rent, 5% interest, 10-year loan, 300 m&sup2; per court</p> <div class="roi-calc">
<div class="roi-calc__title">Quick ROI Estimate</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t border-light-gray"> <div class="roi-calc__sub">Drag the sliders to see your projection</div>
<div class="text-center"> <div class="roi-calc__sliders">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Total Investment</div> <div class="roi-calc__row">
<div class="font-mono text-2xl font-bold text-navy" id="tr-invest">&mdash;</div> <label>Courts <span id="roiCourtsVal">6</span></label>
<div class="text-xs text-slate mt-0.5">CAPEX (rent model)</div> <input type="range" id="roiCourts" min="2" max="20" value="6" step="1">
</div>
<div class="roi-calc__row">
<label>Avg. Hourly Rate <span id="roiRateVal">&euro;40</span></label>
<input type="range" id="roiRate" min="20" max="120" value="40" step="5">
</div>
<div class="roi-calc__row">
<label>Target Utilization <span id="roiUtilVal">35%</span></label>
<input type="range" id="roiUtil" min="20" max="80" value="35" step="5">
</div>
</div> </div>
<div class="text-center"> <div class="roi-metrics">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Monthly Cash Flow</div> <div class="roi-metric">
<div class="font-mono text-2xl font-bold" id="tr-cf">&mdash;</div> <div class="roi-metric__label">Investment</div>
<div class="text-xs text-slate mt-0.5">After debt service</div> <div class="roi-metric__val" id="roiCapex">&euro;330K</div>
</div>
<div class="roi-metric">
<div class="roi-metric__label">Monthly Cash Flow</div>
<div class="roi-metric__val" id="roiCf">&euro;7K</div>
</div>
<div class="roi-metric">
<div class="roi-metric__label">Payback Period</div>
<div class="roi-metric__val" id="roiPayback">3.9 yr</div>
</div>
<div class="roi-metric">
<div class="roi-metric__label">Annual ROI</div>
<div class="roi-metric__val" id="roiReturn">26%</div>
</div>
</div> </div>
<div class="text-center"> <p class="roi-calc__note">Assumes indoor rent model, &euro;8/m&sup2; rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.</p>
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Payback Period</div> <a href="{{ url_for('planner.index') }}" class="roi-calc__cta">Open Full Planner &rarr;</a>
<div class="font-mono text-2xl font-bold text-electric" id="tr-payback">&mdash;</div>
<div class="text-xs text-slate mt-0.5">Years to recover equity</div>
</div>
<div class="text-center">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Cash-on-Cash</div>
<div class="font-mono text-2xl font-bold" id="tr-coc">&mdash;</div>
<div class="text-xs text-slate mt-0.5">Annual return on equity</div>
</div>
</div>
<div class="text-center mt-6 pt-4">
<p class="text-sm text-slate mb-3">Want the full picture? The planner models 60+ variables with monthly projections, sensitivity analysis, and connects you with court suppliers.</p>
{% if user %}
<a href="{{ url_for('planner.index') }}" class="btn">Start Planning</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" class="btn">Create Your Plan</a>
{% endif %}
</div> </div>
</div> </div>
</section> </section>
@@ -134,8 +194,8 @@
<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> <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>
</div> </div>
<div class="card border-l-4 border-l-danger"> <div class="card border-l-4 border-l-danger">
<p class="font-semibold text-navy mb-2">&#x1F3D7;&#xFE0F; Build <span class="badge">Coming Soon</span></p> <p class="font-semibold text-navy mb-2">&#x1F3D7;&#xFE0F; Build</p>
<p class="text-sm text-slate-dark">Get quotes from verified court suppliers. Compare pricing, quality, and delivery timelines for your specific project.</p> <p class="text-sm text-slate-dark">Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched based on your project specs.</p>
</div> </div>
<div class="card border-l-4 border-l-slate"> <div class="card border-l-4 border-l-slate">
<p class="font-semibold text-navy mb-2">&#x1F4C8; Grow <span class="badge">Coming Soon</span></p> <p class="font-semibold text-navy mb-2">&#x1F4C8; Grow <span class="badge">Coming Soon</span></p>
@@ -177,6 +237,59 @@
</div> </div>
</section> </section>
<!-- Supplier Matching -->
<section class="py-12">
<h2 class="text-2xl text-center mb-2">Find the Right Suppliers for Your Project</h2>
<p class="text-center text-slate mb-8">{{ total_suppliers }}+ verified suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and more.</p>
<div class="match-grid">
<div class="match-step">
<div class="match-step__num">1</div>
<h3>Plan Your Venue</h3>
<p>Use the financial planner to model your courts, budget, and timeline.</p>
</div>
<div class="match-step">
<div class="match-step__num">2</div>
<h3>Get Quotes</h3>
<p>Request quotes and we match you with suppliers based on your project specs.</p>
</div>
<div class="match-step">
<div class="match-step__num">3</div>
<h3>Compare &amp; Build</h3>
<p>Receive proposals from 2-5 relevant suppliers. No cold outreach needed.</p>
</div>
</div>
<div class="text-center mt-8">
<a href="{{ url_for('directory.index') }}" class="btn-outline">Browse Supplier Directory</a>
</div>
</section>
<!-- FAQ -->
<section id="faq" class="py-12">
<h2 class="text-2xl text-center mb-6">Frequently Asked Questions</h2>
<div class="faq">
<details>
<summary>What does the planner calculate?</summary>
<p>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.</p>
</details>
<details>
<summary>Do I need to sign up?</summary>
<p>No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.</p>
</details>
<details>
<summary>How does supplier matching work?</summary>
<p>When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with 2-5 relevant suppliers from our directory. They contact you directly with proposals.</p>
</details>
<details>
<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>
</details>
<details>
<summary>How accurate are the financial projections?</summary>
<p>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.</p>
</details>
</div>
</section>
<!-- SEO Content --> <!-- SEO Content -->
<section class="py-12 max-w-3xl mx-auto"> <section class="py-12 max-w-3xl mx-auto">
<h2 class="text-2xl mb-4">Padel Court Investment Planning</h2> <h2 class="text-2xl mb-4">Padel Court Investment Planning</h2>
@@ -193,99 +306,77 @@
<!-- Final CTA --> <!-- Final CTA -->
<section class="text-center py-12"> <section class="text-center py-12">
<h2 class="text-2xl mb-2">Start Planning Today</h2> <h2 class="text-2xl mb-2">Start Planning Today</h2>
<p class="text-slate mb-6">Start with your plan. Then get quotes from verified court suppliers and connect with financing partners.</p> <p class="text-slate mb-6">Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.</p>
{% if user %} <a href="{{ url_for('planner.index') }}" class="btn">Open the Planner</a>
<a href="{{ url_for('planner.index') }}" class="btn">Start Planning</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" class="btn">Create Your Plan</a>
{% endif %}
</section> </section>
</main> </main>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function tCalc() { (function() {
var courts = +document.getElementById('tc-courts').value; var courts = document.getElementById('roiCourts');
var rate = +document.getElementById('tc-rate').value; var rate = document.getElementById('roiRate');
var util = +document.getElementById('tc-util').value; var util = document.getElementById('roiUtil');
var buildCost = +document.getElementById('tc-buildcost').value;
var equityPct = +document.getElementById('tc-equity').value;
// Display slider values function fmt(n) {
document.getElementById('tv-courts').textContent = courts; if (n >= 1e6) return '\u20ac' + (n / 1e6).toFixed(1) + 'M';
document.getElementById('tv-rate').innerHTML = '&euro;' + rate + '/hr'; if (n >= 1e3) return '\u20ac' + Math.round(n / 1e3) + 'K';
document.getElementById('tv-util').textContent = util + '%'; return '\u20ac' + Math.round(n);
document.getElementById('tv-buildcost').innerHTML = '&euro;' + Math.round(buildCost / 1000) + 'K';
document.getElementById('tv-equity').textContent = equityPct + '%';
// CAPEX: build cost per court is the main driver
var capex = courts * buildCost;
// Financing: equity % from slider, remainder is debt
// Assumes 5% interest, 10-year amortizing loan
var iRate = 0.05, term = 10;
var equity = capex * (equityPct / 100);
var debt = capex - equity;
var mRate = iRate / 12;
var nPay = term * 12;
var pmt = debt > 0 ? debt * mRate / (1 - Math.pow(1 + mRate, -nPay)) : 0;
// Revenue
// Assumes 16 bookable hours/day, 29 days/month, off-peak rate = 65% of peak
var hoursDay = 16, daysMonth = 29;
var availHours = courts * hoursDay * daysMonth;
var bookedHours = availHours * (util / 100);
var peakShare = 0.4;
var wRate = rate * peakShare + rate * 0.65 * (1 - peakShare);
var grossRev = bookedHours * wRate;
var netRev = grossRev * 0.9; // 10% booking platform fee
// Operating costs
// Assumes €8/m² rent (300 m² per court), €400/court utilities, €350 marketing
var rent = courts * 300 * 8;
var opex = rent + courts * 400 + 350;
// Monthly cash flow after debt service
var ebitda = netRev - opex;
var netCF = ebitda - pmt;
// Payback period: years to recover equity from annual net cash flow
var annualCF = netCF * 12;
var payback = annualCF > 0 ? equity / annualCF : Infinity;
// Cash-on-cash return: annual net CF / equity invested
var coc = equity > 0 ? annualCF / equity : 0;
// Format outputs
var fmt = function(n) { return (n >= 0 ? '' : '-') + '\u20AC' + Math.abs(Math.round(n)).toLocaleString('de-DE'); };
document.getElementById('tr-invest').innerHTML = fmt(capex);
var cfEl = document.getElementById('tr-cf');
cfEl.innerHTML = fmt(netCF);
cfEl.className = 'font-mono text-2xl font-bold ' + (netCF >= 0 ? 'text-accent' : 'text-danger');
var pbEl = document.getElementById('tr-payback');
if (payback > 0 && payback <= 30) {
pbEl.textContent = payback.toFixed(1) + 'yr';
pbEl.className = 'font-mono text-2xl font-bold ' + (payback <= 5 ? 'text-electric' : 'text-navy');
} else {
pbEl.innerHTML = '&mdash;';
pbEl.className = 'font-mono text-2xl font-bold text-danger';
} }
var cocEl = document.getElementById('tr-coc'); function update() {
if (isFinite(coc)) { var c = +courts.value, r = +rate.value, u = +util.value / 100;
cocEl.textContent = (coc * 100).toFixed(1) + '%';
cocEl.className = 'font-mono text-2xl font-bold ' + (coc >= 0 ? 'text-accent' : 'text-danger');
} else {
cocEl.innerHTML = '&mdash;';
cocEl.className = 'font-mono text-2xl font-bold text-navy';
}
}
// Run on load document.getElementById('roiCourtsVal').textContent = c;
tCalc(); document.getElementById('roiRateVal').innerHTML = '&euro;' + r;
document.getElementById('roiUtilVal').textContent = u * 100 + '%';
// Simplified indoor-rent model (realistic costs)
var costPerCourt = 35000;
var courtCapex = c * costPerCourt;
var fitout = 60000 + c * 5000;
var contingency = 0.10;
var capex = Math.round((courtCapex + fitout) * (1 + contingency));
// Revenue: courts * hours/day * days/mo * utilization * avg rate
var hoursDay = 16, daysMonth = 29;
var monthlyRev = c * hoursDay * daysMonth * u * r;
// OpEx: rent + staff + utilities + insurance + maintenance + marketing
var sqm = c * 300;
var staff = 2500 * Math.ceil(c / 4) + 3500;
var monthlyOpex = (sqm * 8) + staff + (c * 600) + 300 + (c * 300) + 350;
// Debt: 85% LTV, 5% interest, 10yr
var loan = capex * 0.85;
var mr = 0.05 / 12;
var n = 120;
var debtService = loan * mr * Math.pow(1 + mr, n) / (Math.pow(1 + mr, n) - 1);
var monthlyCf = monthlyRev - monthlyOpex - debtService;
var annualCf = monthlyCf * 12;
// Metrics — payback and ROI based on total investment (not just equity)
document.getElementById('roiCapex').textContent = fmt(capex);
var cfEl = document.getElementById('roiCf');
cfEl.textContent = fmt(Math.abs(monthlyCf)) + '/mo';
cfEl.className = 'roi-metric__val ' + (monthlyCf >= 0 ? 'roi-metric__val--green' : 'roi-metric__val--red');
var payback = annualCf > 0 ? capex / annualCf : 99;
document.getElementById('roiPayback').textContent = payback < 20 ? payback.toFixed(1) + ' yr' : '20+ yr';
var roi = capex > 0 ? (annualCf / capex) * 100 : 0;
var roiEl = document.getElementById('roiReturn');
roiEl.textContent = roi > 0 ? Math.round(roi) + '%' : 'N/A';
roiEl.className = 'roi-metric__val ' + (roi > 0 ? 'roi-metric__val--green' : 'roi-metric__val--red');
}
courts.addEventListener('input', update);
rate.addEventListener('input', update);
util.addEventListener('input', update);
update();
})();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,293 @@
{% extends "base.html" %}
{% block title %}For Suppliers - Reach Padel Entrepreneurs | {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Get listed on Padelnomics. Reach entrepreneurs actively planning padel court projects. Growth and Pro plans available.">
<style>
.sup-hero { text-align: center; padding: 3rem 0 2rem; }
.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 .btn { margin-top: 1.5rem; padding: 14px 32px; font-size: 1rem; }
.sup-stats {
display: flex; justify-content: center; gap: 3rem; padding: 1.5rem 0 2rem;
font-size: 0.875rem; color: #64748B;
}
.sup-stats strong { display: block; font-size: 1.5rem; color: #1E293B; }
.sup-section { padding: 2.5rem 0; }
.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-steps {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem;
max-width: 800px; margin: 0 auto;
}
.sup-step { text-align: center; }
.sup-step__num {
display: inline-flex; align-items: center; justify-content: center;
width: 48px; height: 48px; border-radius: 50%;
background: #EFF6FF; color: #1D4ED8; font-weight: 700; font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.sup-step h3 { font-size: 1rem; margin-bottom: 0.25rem; }
.sup-step p { font-size: 0.8125rem; color: #64748B; }
.sup-lead-preview {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
padding: 1.5rem; max-width: 600px; margin: 2rem auto 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.sup-lead-preview h4 { font-size: 0.875rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
.sup-lead-preview dl { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 1rem; font-size: 0.8125rem; }
.sup-lead-preview dt { color: #94A3B8; }
.sup-lead-preview dd { color: #1E293B; font-weight: 500; margin: 0; }
/* Pricing cards */
.pricing-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem;
max-width: 720px; margin: 0 auto;
}
.pricing-card {
border: 1px solid #E2E8F0; border-radius: 16px; padding: 1.5rem;
background: white; position: relative;
}
.pricing-card--highlight {
border-color: #1D4ED8; border-width: 2px;
box-shadow: 0 4px 20px rgba(29,78,216,0.12);
}
.pricing-card__popular {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
background: #1D4ED8; color: white; font-size: 0.6875rem; font-weight: 700;
padding: 4px 14px; border-radius: 999px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.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: 1rem; }
.pricing-card .price span { font-size: 0.875rem; font-weight: 400; color: #64748B; }
.pricing-card ul { list-style: none; padding: 0; margin: 0 0 1.5rem; }
.pricing-card li {
font-size: 0.8125rem; color: #475569; padding: 4px 0;
display: flex; align-items: flex-start; gap: 6px;
}
.pricing-card li::before { content: "✓"; color: #16A34A; font-weight: 700; flex-shrink: 0; }
.pricing-card .btn { width: 100%; text-align: center; }
/* Boosts */
.boost-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem; max-width: 720px; margin: 1.5rem auto 0;
}
.boost-card {
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 .boost-price { color: #1D4ED8; font-weight: 700; font-size: 0.75rem; }
.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; }
.sup-faq { max-width: 640px; margin: 0 auto; }
.sup-faq details {
border-bottom: 1px solid #E2E8F0; padding: 1rem 0;
}
.sup-faq summary {
font-weight: 600; cursor: pointer; font-size: 0.9375rem; color: #1E293B;
list-style: none;
}
.sup-faq summary::-webkit-details-marker { display: none; }
.sup-faq summary::before { content: "+ "; color: #1D4ED8; font-weight: 700; }
.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-cta { text-align: center; padding: 3rem 0; }
.sup-cta h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.sup-cta p { color: #64748B; margin-bottom: 1.5rem; }
@media (max-width: 640px) {
.sup-stats { flex-direction: column; gap: 1rem; align-items: center; }
.sup-steps, .sup-why { grid-template-columns: 1fr; }
.pricing-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock %}
{% block content %}
<main class="container-page">
<div class="sup-hero">
<h1>Reach Entrepreneurs<br>Actively Planning Padel Projects</h1>
<p>Padelnomics connects you with qualified leads who've already modeled their investment. No cold outreach. No wasted time.</p>
<a href="#pricing" class="btn">See Plans</a>
</div>
<div class="sup-stats">
<div><strong>{{ total_suppliers }}+</strong> suppliers listed</div>
<div><strong>{{ total_countries }}</strong> countries</div>
<div><strong>100%</strong> project-qualified leads</div>
</div>
<!-- How it works -->
<section class="sup-section">
<h2>How It Works</h2>
<p class="sub">Three steps to qualified leads.</p>
<div class="sup-steps">
<div class="sup-step">
<div class="sup-step__num">1</div>
<h3>Choose Your Plan</h3>
<p>Your company is already in our directory. Pick a plan to upgrade your listing and start receiving leads.</p>
</div>
<div class="sup-step">
<div class="sup-step__num">2</div>
<h3>Get Quotes</h3>
<p>When entrepreneurs request quotes, we match them with suppliers based on location, services, and project specs.</p>
</div>
<div class="sup-step">
<div class="sup-step__num">3</div>
<h3>Close Deals</h3>
<p>You receive pre-qualified leads with full project details: venue type, court count, budget, timeline, and more.</p>
</div>
</div>
<!-- Example lead -->
<div class="sup-lead-preview">
<h4>Example Lead You'd Receive</h4>
<dl>
<dt>Facility</dt><dd>Indoor (Rent)</dd>
<dt>Courts</dt><dd>6 double + 2 single</dd>
<dt>Glass</dt><dd>Panoramic</dd>
<dt>Country</dt><dd>Germany</dd>
<dt>Budget</dt><dd>&euro;450K</dd>
<dt>Timeline</dt><dd>3-6 months</dd>
<dt>Phase</dt><dd>Lease signed</dd>
<dt>Financing</dt><dd>Loan approved</dd>
</dl>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="sup-section">
<h2>Plans &amp; Pricing</h2>
<p class="sub">Choose the plan that fits your growth goals.</p>
<div class="pricing-grid">
<!-- Growth -->
<div class="pricing-card">
<h3>Growth</h3>
<div class="price">&euro;149 <span>/mo</span></div>
<ul>
<li>Company name &amp; category badge</li>
<li>City &amp; country shown</li>
<li>Description (3 lines)</li>
<li>"Growth" badge</li>
<li>Priority over free listings</li>
</ul>
<a href="mailto:{{ config.ADMIN_EMAIL }}?subject=Growth Plan Interest" class="btn-outline" style="display:block;text-align:center">Get Started</a>
</div>
<!-- Pro -->
<div class="pricing-card pricing-card--highlight">
<div class="pricing-card__popular">Most Popular</div>
<h3>Pro</h3>
<div class="price">&euro;399 <span>/mo</span></div>
<ul>
<li>Everything in Growth</li>
<li>Company logo displayed</li>
<li>Full description</li>
<li>Website link shown</li>
<li>Verified &#10003; badge</li>
<li>Priority placement</li>
<li>Highlighted card border</li>
</ul>
<a href="mailto:{{ config.ADMIN_EMAIL }}?subject=Pro Plan Interest" class="btn" style="display:block;text-align:center">Get Started</a>
</div>
</div>
<!-- Boost add-ons -->
<h3 style="text-align:center;font-size:1rem;margin-top:2rem;margin-bottom:0.25rem">Boost Add-Ons</h3>
<p style="text-align:center;color:#64748B;font-size:0.8125rem;margin-bottom:1rem">Available with any paid plan.</p>
<div class="boost-grid">
<div class="boost-card">
<strong>Logo</strong>
<span class="boost-price">&euro;29/mo</span>
</div>
<div class="boost-card">
<strong>Highlight</strong>
<span class="boost-price">&euro;39/mo</span>
</div>
<div class="boost-card">
<strong>Verified Badge</strong>
<span class="boost-price">&euro;49/mo</span>
</div>
<div class="boost-card">
<strong>Sticky Top</strong>
<span class="boost-price">&euro;79/wk or &euro;199/mo</span>
</div>
<div class="boost-card">
<strong>Newsletter Feature</strong>
<span class="boost-price">&euro;99/mo</span>
</div>
</div>
</section>
<!-- Why Padelnomics -->
<section class="sup-section">
<h2>Why Padelnomics Leads Are Different</h2>
<p class="sub">Every lead has already built a financial model for their project.</p>
<div class="sup-why">
<div class="sup-why-card">
<h3>Pre-Qualified</h3>
<p>Leads come through our financial planner. They've modeled CAPEX, revenue, and ROI before contacting you.</p>
</div>
<div class="sup-why-card">
<h3>Full Project Brief</h3>
<p>You get venue type, court count, glass/lighting specs, budget, timeline, financing status, and contact details.</p>
</div>
<div class="sup-why-card">
<h3>No Cold Outreach</h3>
<p>Entrepreneurs come to us. You only hear from people actively planning to build padel facilities.</p>
</div>
</div>
</section>
<!-- FAQ -->
<section class="sup-section">
<h2>Supplier FAQ</h2>
<div class="sup-faq">
<details>
<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>
</details>
<details>
<summary>How much does it cost?</summary>
<p>We offer two plans: Growth (&euro;149/mo) with description, badge, and priority placement; and Pro (&euro;399/mo) with logo, website, verified badge, and maximum visibility. Optional boost add-ons are available on top.</p>
</details>
<details>
<summary>What information do leads include?</summary>
<p>Every lead includes: facility type (indoor/outdoor), court count, glass and lighting preferences, country and city, budget estimate, project phase, timeline, financing status, stakeholder type, services needed, and full contact details.</p>
</details>
<details>
<summary>How are leads matched to suppliers?</summary>
<p>We match based on location, services offered, and project requirements. Each lead is shared with 2-5 relevant suppliers to ensure quality without overwhelming the entrepreneur.</p>
</details>
<details>
<summary>My company isn'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>
</details>
</div>
</section>
<!-- Final CTA -->
<section class="sup-cta">
<h2>Ready to Receive Qualified Leads?</h2>
<p>Choose a plan and start getting matched with padel entrepreneurs today.</p>
<a href="#pricing" class="btn">See Plans</a>
</section>
</main>
{% endblock %}

View File

@@ -23,9 +23,9 @@
--color-navy: #0F172A; --color-navy: #0F172A;
--color-charcoal: #1E293B; --color-charcoal: #1E293B;
--color-electric: #3B82F6; --color-electric: #1D4ED8;
--color-electric-hover: #2563EB; --color-electric-hover: #1E40AF;
--color-accent: #10B981; --color-accent: #16A34A;
--color-soft-white: #F8FAFC; --color-soft-white: #F8FAFC;
--color-light-gray: #E2E8F0; --color-light-gray: #E2E8F0;
--color-mid-gray: #CBD5E1; --color-mid-gray: #CBD5E1;
@@ -33,7 +33,7 @@
--color-slate-dark: #475569; --color-slate-dark: #475569;
--color-danger: #EF4444; --color-danger: #EF4444;
--color-danger-hover: #DC2626; --color-danger-hover: #DC2626;
--color-warning: #F59E0B; --color-warning: #D97706;
} }
/* ── Base layer ── */ /* ── Base layer ── */
@@ -64,7 +64,7 @@
/* Cards (replace Pico <article>) */ /* Cards (replace Pico <article>) */
.card { .card {
@apply bg-white border border-light-gray rounded-lg p-6 mb-6; @apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm;
} }
.card-header { .card-header {
@apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium; @apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium;
@@ -72,12 +72,12 @@
/* Buttons — shared base */ /* Buttons — shared base */
.btn, .btn-secondary, .btn-danger { .btn, .btn-secondary, .btn-danger {
@apply inline-flex items-center justify-center px-5 py-2.5 rounded-lg @apply inline-flex items-center justify-center px-5 py-2.5 rounded-xl
font-semibold text-sm transition-colors cursor-pointer font-semibold text-sm transition-colors cursor-pointer
focus:outline-none focus:ring-2 focus:ring-electric/50; focus:outline-none focus:ring-2 focus:ring-electric/50;
} }
.btn { .btn {
@apply bg-electric text-white hover:bg-electric-hover; @apply bg-electric text-white hover:bg-electric-hover shadow-[0_2px_10px_rgba(29,78,216,0.25)];
} }
.btn-secondary { .btn-secondary {
@apply bg-slate-dark text-white hover:bg-navy; @apply bg-slate-dark text-white hover:bg-navy;
@@ -86,7 +86,7 @@
@apply bg-danger text-white hover:bg-danger-hover; @apply bg-danger text-white hover:bg-danger-hover;
} }
.btn-outline { .btn-outline {
@apply inline-flex items-center justify-center px-5 py-2.5 rounded-lg @apply inline-flex items-center justify-center px-5 py-2.5 rounded-xl
font-semibold text-sm transition-colors cursor-pointer font-semibold text-sm transition-colors cursor-pointer
bg-transparent text-slate-dark border border-mid-gray bg-transparent text-slate-dark border border-mid-gray
hover:bg-light-gray hover:text-navy hover:bg-light-gray hover:text-navy
@@ -101,7 +101,7 @@
@apply block text-sm font-medium text-charcoal mb-1; @apply block text-sm font-medium text-charcoal mb-1;
} }
.form-input { .form-input {
@apply w-full px-3 py-2 rounded-lg border border-mid-gray bg-white @apply w-full px-3 py-2 rounded-xl border border-mid-gray bg-white
text-slate-dark placeholder-slate text-slate-dark placeholder-slate
focus:outline-none focus:ring-2 focus:ring-electric/50 focus:border-electric focus:outline-none focus:ring-2 focus:ring-electric/50 focus:border-electric
transition-colors; transition-colors;
@@ -127,7 +127,7 @@
/* Flash messages */ /* Flash messages */
.flash, .flash-error, .flash-success, .flash-warning { .flash, .flash-error, .flash-success, .flash-warning {
@apply px-4 py-3 rounded-lg mb-4 border-l-4 bg-white text-slate-dark text-sm; @apply px-4 py-3 rounded-xl mb-4 border-l-4 bg-white text-slate-dark text-sm;
} }
.flash { .flash {
@apply border-electric; @apply border-electric;

View File

@@ -13,14 +13,19 @@
--txt-3: #94A3B8; --txt-3: #94A3B8;
--head: #0F172A; --head: #0F172A;
--wht: #0F172A; --wht: #0F172A;
--rd: #3B82F6; --rd: #1D4ED8;
--rd-bg: rgba(59,130,246,0.06); --rd-bg: rgba(29,78,216,0.06);
--gn: #10B981; --gn: #16A34A;
--gn-bg: rgba(16,185,129,0.06); --gn-bg: rgba(22,163,74,0.06);
--bl: #3B82F6; --bl: #1D4ED8;
--bl-bg: rgba(59,130,246,0.06); --bl-bg: rgba(29,78,216,0.06);
--am: #F59E0B; --am: #D97706;
--am-bg: rgba(245,158,11,0.06); --am-bg: rgba(217,119,6,0.06);
--cta: #1D4ED8;
--cta-hover: #1E40AF;
--cta-shadow: rgba(29,78,216,0.25);
--cta-bg: #EFF6FF;
--cta-glow: rgba(29,78,216,0.10);
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 14px; font-size: 14px;
@@ -76,7 +81,7 @@
.scenario-controls button { .scenario-controls button {
font-size: 11px; font-size: 11px;
padding: 4px 12px; padding: 4px 12px;
border-radius: 6px; border-radius: 999px;
border: 1px solid var(--border-2); border: 1px solid var(--border-2);
background: transparent; background: transparent;
color: var(--txt-2); color: var(--txt-2);
@@ -93,8 +98,10 @@
/* ── Tab Navigation ── */ /* ── Tab Navigation ── */
.tab-nav { .tab-nav {
display: flex; display: flex;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(226,232,240,0.6);
background: var(--bg); background: rgba(255,255,255,0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
overflow-x: auto; overflow-x: auto;
position: sticky; position: sticky;
top: 0; top: 0;
@@ -154,8 +161,9 @@
.metric-card { .metric-card {
background: var(--bg-2); background: var(--bg-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 14px;
padding: 14px 16px; padding: 18px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
} }
.metric-card__label { .metric-card__label {
font-size: 10px; font-size: 10px;
@@ -267,7 +275,7 @@
outline: none; outline: none;
} }
.slider-combo input[type=number]:focus { .slider-combo input[type=number]:focus {
border-color: rgba(59,130,246,0.5); border-color: rgba(29,78,216,0.5);
} }
/* Hide number spinners */ /* Hide number spinners */
.slider-combo input[type=number]::-webkit-outer-spin-button, .slider-combo input[type=number]::-webkit-outer-spin-button,
@@ -285,7 +293,8 @@
padding: 10px 12px; padding: 10px 12px;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 14px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
font-size: 11px; font-size: 11px;
color: var(--txt-3); color: var(--txt-3);
} }
@@ -317,7 +326,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
background: transparent; background: transparent;
color: var(--txt-3); color: var(--txt-3);
border-radius: 6px; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
transition: all 0.15s; transition: all 0.15s;
@@ -328,6 +337,71 @@
color: #fff !important; color: #fff !important;
} }
.btn-reset {
padding: 5px 14px;
font-size: 11px;
font-weight: 600;
color: var(--txt-2);
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s;
}
.btn-reset:hover { color: var(--head); border-color: var(--border-2); }
/* ── Pill Select ── */
.pill-group {
margin-bottom: 14px;
}
.pill-group label {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.pill-options {
display: flex;
gap: 4px;
}
.pill-btn {
flex: 1;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border);
background: transparent;
color: var(--txt-3);
border-radius: 999px;
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: all 0.15s;
}
.pill-btn:hover {
background: var(--bg-3);
color: var(--txt-2);
}
.pill-btn--active {
background: var(--rd) !important;
border-color: var(--rd) !important;
color: #fff !important;
}
/* ── Budget Indicator ── */
.budget-indicator {
background: var(--bg-2);
border: 2px solid;
border-radius: 14px;
padding: 14px 16px;
}
.budget-indicator--under {
border-color: var(--gn);
background: var(--gn-bg);
}
.budget-indicator--over {
border-color: #EF4444;
background: rgba(239,68,68,0.06);
}
/* ── Data Tables ── */ /* ── Data Tables ── */
.data-table { .data-table {
width: 100%; width: 100%;
@@ -372,8 +446,9 @@
.chart-container { .chart-container {
background: var(--bg-2); background: var(--bg-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 18px;
padding: 1rem; padding: 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
} }
.chart-container__label { .chart-container__label {
font-size: 11px; font-size: 11px;
@@ -480,7 +555,7 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
padding: 6px 16px; padding: 6px 16px;
border-radius: 6px; border-radius: 10px;
text-decoration: none; text-decoration: none;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -489,7 +564,7 @@
color: #fff; color: #fff;
} }
.lead-cta-bar a:first-of-type:hover { .lead-cta-bar a:first-of-type:hover {
background: #2563EB; background: #1E40AF;
} }
.lead-cta-bar a:last-of-type { .lead-cta-bar a:last-of-type {
background: transparent; background: transparent;
@@ -503,10 +578,11 @@
/* ── Inline lead CTA ── */ /* ── Inline lead CTA ── */
.lead-cta { .lead-cta {
background: var(--bg-3); background: var(--cta-bg);
border: 1px solid var(--border-2); border: 2px solid var(--cta);
border-radius: 8px; border-radius: 20px;
padding: 16px 20px; padding: 20px 24px;
box-shadow: 0 4px 24px var(--cta-glow);
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -518,18 +594,28 @@
color: var(--txt-2); color: var(--txt-2);
} }
.lead-cta__btn { .lead-cta__btn {
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 600;
padding: 8px 20px; padding: 13px 26px;
border-radius: 6px; border-radius: 10px;
background: var(--rd); background: var(--cta);
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
transition: background 0.15s; transition: background 0.15s;
box-shadow: 0 2px 10px var(--cta-shadow);
} }
.lead-cta__btn:hover { .lead-cta__btn:hover {
background: #2563EB; background: var(--cta-hover);
}
.lead-cta__btn--secondary {
background: transparent;
border: 1px solid var(--border-2);
color: var(--txt-2);
}
.lead-cta__btn--secondary:hover {
background: var(--bg-3);
color: var(--txt);
} }
/* ── Exit waterfall ── */ /* ── Exit waterfall ── */
@@ -580,7 +666,7 @@
.scenario-item { .scenario-item {
padding: 12px; padding: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 12px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@@ -610,7 +696,7 @@
background: var(--gn); background: var(--gn);
color: #fff; color: #fff;
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 10px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
animation: fadeInOut 2s ease forwards; animation: fadeInOut 2s ease forwards;
@@ -622,6 +708,389 @@
100% { opacity: 0; } 100% { opacity: 0; }
} }
/* ── Wizard ── */
.wizard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
gap: 1rem;
}
.wizard-dots {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.wiz-dot {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 11px;
font-weight: 600;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--txt-3);
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: all 0.15s;
white-space: nowrap;
}
.wiz-dot:hover {
background: var(--bg-3);
color: var(--txt-2);
}
.wiz-dot--active {
background: var(--rd) !important;
border-color: var(--rd) !important;
color: #fff !important;
}
.wiz-dot--done {
background: var(--gn-bg);
border-color: var(--gn);
color: var(--gn);
}
.wiz-dot__num {
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
background: rgba(0,0,0,0.06);
color: inherit;
}
.wiz-dot--active .wiz-dot__num {
background: rgba(255,255,255,0.25);
}
.wiz-dot--done .wiz-dot__num {
background: var(--gn);
color: #fff;
}
.wizard-step {
display: none;
max-width: 560px;
}
.wizard-step.active {
display: block;
}
.wizard-step__title {
font-size: 1.25rem;
font-weight: 800;
color: var(--head);
margin: 0 0 4px;
}
.wizard-step__sub {
font-size: 13px;
color: var(--txt-2);
margin: 0 0 1.5rem;
}
/* Wizard preview bar */
.wizard-preview {
display: flex;
gap: 1rem;
padding: 12px 16px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
margin-top: 1.5rem;
max-width: 560px;
}
.wiz-preview__item {
flex: 1;
text-align: center;
}
.wiz-preview__label {
font-size: 10px;
color: var(--txt-3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wiz-preview__value {
font-size: 16px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
color: var(--head);
line-height: 1.4;
}
.wiz-preview__value.c-green { color: var(--gn); }
.wiz-preview__value.c-red { color: #EF4444; }
/* Wizard navigation */
.wizard-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
max-width: 560px;
gap: 0.75rem;
}
.wiz-btn--back {
padding: 8px 20px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border-2);
background: transparent;
color: var(--txt-2);
border-radius: 10px;
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: all 0.15s;
}
.wiz-btn--back:hover {
background: var(--bg-3);
color: var(--txt);
}
.wiz-btn--next,
.wiz-btn--submit {
padding: 10px 24px;
font-size: 13px;
font-weight: 700;
border: none;
background: var(--cta);
color: #fff;
border-radius: 10px;
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: all 0.15s;
box-shadow: 0 2px 10px var(--cta-shadow);
}
.wiz-btn--next:hover,
.wiz-btn--submit:hover {
background: var(--cta-hover);
}
.wiz-skip {
font-size: 12px;
color: var(--txt-3);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
font-family: 'Inter', sans-serif;
padding: 4px;
}
.wiz-skip:hover {
color: var(--txt-2);
}
.wizard-nav__right {
display: flex;
align-items: center;
gap: 12px;
}
/* Step 5 form styles */
.wiz-autofill-summary {
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px 16px;
margin-bottom: 1.5rem;
font-size: 12px;
}
.wiz-autofill-summary dt {
color: var(--txt-3);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wiz-autofill-summary dd {
color: var(--head);
font-weight: 600;
margin: 0 0 6px;
}
#wizQuoteForm .field-group { margin-bottom: 1.25rem; }
#wizQuoteForm .field-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--txt-2);
margin-bottom: 4px;
}
#wizQuoteForm .field-label .required { color: #EF4444; }
#wizQuoteForm .pill-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#wizQuoteForm .pill-grid label { cursor: pointer; }
#wizQuoteForm .pill-grid input[type="radio"],
#wizQuoteForm .pill-grid input[type="checkbox"] { display: none; }
#wizQuoteForm .pill-grid .pill {
display: inline-block;
padding: 7px 14px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 11px;
font-weight: 600;
color: var(--txt-3);
transition: all 0.15s;
background: transparent;
}
#wizQuoteForm .pill-grid .pill:hover {
background: var(--bg-3);
color: var(--txt-2);
}
#wizQuoteForm .pill-grid input:checked + .pill {
background: var(--rd);
border-color: var(--rd);
color: #fff;
}
.wiz-input {
width: 100%;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 10px;
padding: 8px 12px;
font-size: 13px;
font-family: 'Inter', sans-serif;
color: var(--head);
outline: none;
box-sizing: border-box;
}
.wiz-input:focus {
border-color: rgba(29,78,216,0.5);
}
textarea.wiz-input {
resize: vertical;
}
.wiz-checkbox-label {
display: flex !important;
align-items: center;
gap: 6px;
cursor: pointer;
}
.wiz-checkbox-label input[type="checkbox"] {
margin: 0;
flex-shrink: 0;
}
.wiz-privacy-box {
background: var(--bl-bg);
border: 1px solid rgba(29,78,216,0.2);
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 1rem;
font-size: 12px;
color: var(--rd);
display: flex;
gap: 8px;
align-items: flex-start;
}
.wiz-privacy-box svg { flex-shrink: 0; margin-top: 1px; }
#wizQuoteForm .consent-group { margin-bottom: 1.25rem; }
#wizQuoteForm .consent-group label {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 12px;
color: var(--txt-2);
cursor: pointer;
}
#wizQuoteForm .consent-group input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
}
#wizQuoteForm .consent-group a {
color: var(--rd);
}
/* Success state */
.wiz-success {
text-align: center;
padding: 2rem 1rem;
}
.wiz-success__icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gn);
color: #fff;
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.wiz-success h3 {
font-size: 1.25rem;
font-weight: 800;
color: var(--head);
margin: 0 0 8px;
}
.wiz-success p {
font-size: 13px;
color: var(--txt-2);
max-width: 400px;
margin: 0 auto;
}
.wiz-signup-nudge {
margin-top: 1.5rem;
background: var(--cta-bg);
border: 2px solid var(--cta);
border-radius: 16px;
padding: 1.5rem;
}
.wiz-signup-nudge p {
font-size: 13px;
color: var(--txt-2);
margin-bottom: 0.75rem;
}
/* ── Guest signup bar (sticky on results) ── */
.signup-bar {
position: sticky;
bottom: 0;
display: none; /* shown by JS on results tabs */
align-items: center;
gap: 12px;
padding: 10px 1.5rem;
background: var(--cta-bg);
border-top: none;
box-shadow: 0 -2px 12px rgba(29,78,216,0.08);
font-size: 12px;
color: var(--txt-2);
z-index: 20;
}
.signup-bar span { flex: 1; }
.signup-bar b { color: var(--head); }
.signup-bar__close {
background: none;
border: none;
font-size: 18px;
color: var(--txt-2);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.signup-bar__close:hover { color: var(--txt); }
/* Mobile wizard */
@media (max-width: 768px) {
.wizard-dots { gap: 4px; }
.wiz-dot { padding: 5px 10px; font-size: 10px; }
.wiz-dot__num { width: 16px; height: 16px; font-size: 9px; }
.wizard-preview {
flex-direction: column;
gap: 8px;
text-align: left;
}
.wiz-preview__item {
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}
.wizard-step { max-width: 100%; }
.wizard-preview,
.wizard-nav { max-width: 100%; }
}
/* ── Computing indicator ── */ /* ── Computing indicator ── */
.planner-app--computing .planner-header h1::after { .planner-app--computing .planner-header h1::after {
content: 'computing\2026'; content: 'computing\2026';

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -15,6 +15,8 @@ const S = {
floorPrep:12000, hvacUpgrade:20000, lightingUpgrade:10000, floorPrep:12000, hvacUpgrade:20000, lightingUpgrade:10000,
outdoorFoundation:35, outdoorSiteWork:8000, outdoorLighting:4000, outdoorFencing:6000, outdoorFoundation:35, outdoorSiteWork:8000, outdoorLighting:4000, outdoorFencing:6000,
equipment:2000, workingCapital:15000, contingencyPct:10, equipment:2000, workingCapital:15000, contingencyPct:10,
country:'DE', permitsCompliance:12000,
budgetTarget:0, glassType:'standard', lightingType:'led_standard',
rentSqm:4, outdoorRent:400, insurance:300, electricity:600, heating:400, rentSqm:4, outdoorRent:400, insurance:300, electricity:600, heating:400,
maintenance:300, cleaning:300, marketing:350, staff:0, propertyTax:250, water:125, maintenance:300, cleaning:300, marketing:350, staff:0, propertyTax:250, water:125,
loanPct:85, interestRate:5, loanTerm:10, constructionMonths:0, loanPct:85, interestRate:5, loanTerm:10, constructionMonths:0,
@@ -23,6 +25,9 @@ const S = {
season:[0,0,0,.7,.9,1,1,1,.8,0,0,0], season:[0,0,0,.7,.9,1,1,1,.8,0,0,0],
}; };
// Freeze a copy of defaults before any overrides
const DEFAULTS = Object.freeze(JSON.parse(JSON.stringify(S)));
// Restore saved scenario if available // Restore saved scenario if available
if (window.__PADELNOMICS_INITIAL_STATE__) { if (window.__PADELNOMICS_INITIAL_STATE__) {
Object.assign(S, window.__PADELNOMICS_INITIAL_STATE__); Object.assign(S, window.__PADELNOMICS_INITIAL_STATE__);
@@ -39,6 +44,16 @@ const TABS = [
let activeTab = 'assumptions'; let activeTab = 'assumptions';
const charts = {}; const charts = {};
// ── Wizard state ──────────────────────────────────────────
let wizStep = 1;
const WIZ_STEPS = [
{n:1, label:'Venue'},
{n:2, label:'Pricing'},
{n:3, label:'Costs'},
{n:4, label:'Finance'},
{n:5, label:'Get Quotes'},
];
// ── Helpers ──────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
const $$=s=>document.querySelectorAll(s); const $$=s=>document.querySelectorAll(s);
@@ -54,6 +69,54 @@ function ti(text){
return ` <span class="ti">i<span class="tp">${text}</span></span>`; return ` <span class="ti">i<span class="tp">${text}</span></span>`;
} }
function pillSelect(key,label,options,tip){
let h=`<div class="pill-group"><label><span class="slider-group__label">${label}</span>${ti(tip)}</label><div class="pill-options">`;
for(const opt of options){
h+=`<button data-key="${key}" data-val="${opt.v}" class="pill-btn ${S[key]===opt.v?'pill-btn--active':''}">${opt.l}</button>`;
}
h+='</div></div>';
return h;
}
const COUNTRY_PRESETS = {
DE: { permitsCompliance: 12000 },
ES: { permitsCompliance: 25000 },
IT: { permitsCompliance: 18000 },
FR: { permitsCompliance: 15000 },
NL: { permitsCompliance: 10000 },
SE: { permitsCompliance: 8000 },
UK: { permitsCompliance: 10000 },
OTHER: { permitsCompliance: 12000 },
};
// Track which keys the user has manually adjusted
const _userAdjusted = new Set();
function bindPills(){
document.querySelectorAll('.pill-btn').forEach(b=>{
b.onclick=()=>{
const k=b.dataset.key, v=b.dataset.val;
S[k]=v;
// Update active state within same group
b.closest('.pill-options').querySelectorAll('.pill-btn').forEach(btn=>{
btn.classList.toggle('pill-btn--active',btn.dataset.val===v);
});
// Apply country presets when country changes
if(k==='country'){
const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.OTHER;
for(const [pk,pv] of Object.entries(preset)){
if(!_userAdjusted.has(pk)){ S[pk]=pv; }
}
rebuildCapexInputs(); bindSliders(); bindPills();
}
// Rebuild inputs if lighting options depend on venue
if(k==='glassType'||k==='lightingType'){
rebuildCapexInputs(); bindSliders(); bindPills();
}
render();
};
});
}
function cardHTML(label,value,sub,cls='',tip=''){ function cardHTML(label,value,sub,cls='',tip=''){
const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head'; const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head';
return `<div class="metric-card"> return `<div class="metric-card">
@@ -129,6 +192,7 @@ function slider(key,label,min,max,step,fmtFn,tip){
function buildInputs(){ function buildInputs(){
buildToggle('tog-venue',[{v:'indoor',l:'Indoor'},{v:'outdoor',l:'Outdoor'}],'venue'); buildToggle('tog-venue',[{v:'indoor',l:'Indoor'},{v:'outdoor',l:'Outdoor'}],'venue');
buildToggle('tog-own',[{v:'rent',l:'Rent / Lease'},{v:'buy',l:'Buy / Build'}],'own'); buildToggle('tog-own',[{v:'rent',l:'Rent / Lease'},{v:'buy',l:'Buy / Build'}],'own');
buildCountryPill();
$('#inp-courts').innerHTML = $('#inp-courts').innerHTML =
slider('dblCourts','Double Courts (20\u00D710m)',0,30,1,fN,'Standard padel court for 4 players. Most common format with highest recreational demand.')+ slider('dblCourts','Double Courts (20\u00D710m)',0,30,1,fN,'Standard padel court for 4 players. Most common format with highest recreational demand.')+
@@ -189,10 +253,26 @@ function rebuildSpaceInputs(){
$('#inp-space').innerHTML = h; $('#inp-space').innerHTML = h;
} }
function buildCountryPill(){
$('#inp-country').innerHTML = pillSelect('country','Country',[
{v:'DE',l:'Germany'},{v:'ES',l:'Spain'},{v:'IT',l:'Italy'},
{v:'FR',l:'France'},{v:'NL',l:'Netherlands'},{v:'SE',l:'Sweden'},
{v:'UK',l:'United Kingdom'},{v:'OTHER',l:'Other'},
],'Affects regulatory cost defaults');
}
function rebuildCapexInputs(){ function rebuildCapexInputs(){
const isIn=S.venue==='indoor', isBuy=S.own==='buy'; const isIn=S.venue==='indoor', isBuy=S.own==='buy';
let h = slider('courtCostDbl','Court Cost \u2014 Double',0,80000,1000,fE,'Purchase price of one double padel court. Standard glass: \u20AC25\u201330K. Panoramic: \u20AC30\u201345K. WPT-spec: \u20AC40\u201355K.')+ let glassOpts=[{v:'standard',l:'Standard Glass'},{v:'panoramic',l:'Panoramic Glass'}];
slider('courtCostSgl','Court Cost \u2014 Single',0,60000,1000,fE,'Purchase price of one single padel court. Generally 60\u201370% of a double court cost.'); let lightOpts=[{v:'led_standard',l:'LED Standard'},{v:'led_competition',l:'LED Competition'}];
if(!isIn) lightOpts.push({v:'natural',l:'Natural Light'});
// Reset lightingType to led_standard if natural was selected but switched to indoor
if(isIn && S.lightingType==='natural') S.lightingType='led_standard';
let h = pillSelect('glassType','Glass Type',glassOpts,'Standard glass: \u20AC25\u201330K per court. Panoramic glass: \u20AC30\u201345K. Panoramic offers full visibility and premium feel.')+
pillSelect('lightingType','Lighting Type',lightOpts,'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament/broadcast standards. Natural: outdoor only, no lighting cost.')+
slider('courtCostDbl','Court Cost \u2014 Double',0,80000,1000,fE,'Base price of one double padel court. The glass type multiplier is applied automatically.')+
slider('courtCostSgl','Court Cost \u2014 Single',0,60000,1000,fE,'Base price of one single padel court. Generally 60\u201370% of a double court cost.');
if(isIn&&isBuy){ if(isIn&&isBuy){
h+=slider('hallCostSqm','Hall Construction (\u20AC/m\u00B2)',0,2000,10,fE,'Construction cost per m\u00B2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312m clear height.')+ h+=slider('hallCostSqm','Hall Construction (\u20AC/m\u00B2)',0,2000,10,fE,'Construction cost per m\u00B2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312m clear height.')+
slider('foundationSqm','Foundation (\u20AC/m\u00B2)',0,400,5,fE,'Foundation cost per m\u00B2. Depends on soil conditions, load-bearing requirements, and local ground water levels.')+ slider('foundationSqm','Foundation (\u20AC/m\u00B2)',0,400,5,fE,'Foundation cost per m\u00B2. Depends on soil conditions, load-bearing requirements, and local ground water levels.')+
@@ -206,16 +286,19 @@ function rebuildCapexInputs(){
h+=slider('floorPrep','Floor Preparation',0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+ h+=slider('floorPrep','Floor Preparation',0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
slider('hvacUpgrade','HVAC Upgrade',0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+ slider('hvacUpgrade','HVAC Upgrade',0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
slider('lightingUpgrade','Lighting Upgrade',0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+ slider('lightingUpgrade','Lighting Upgrade',0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.'); slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.')+
slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,'Building permits, change-of-use application, fire safety review, noise assessment');
} else if(!isIn){ } else if(!isIn){
h+=slider('outdoorFoundation','Concrete (\u20AC/m\u00B2)',0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+ h+=slider('outdoorFoundation','Concrete (\u20AC/m\u00B2)',0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
slider('outdoorSiteWork','Site Work',0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+ slider('outdoorSiteWork','Site Work',0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
slider('outdoorLighting','Lighting per Court',0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+ slider('outdoorLighting','Lighting per Court',0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.'); slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.')+
slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,'Building permits, noise assessment, environmental compliance');
if(isBuy) h+=slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.'); if(isBuy) h+=slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
} }
h+=slider('workingCapital','Working Capital',0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+ h+=slider('workingCapital','Working Capital',0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
slider('contingencyPct','Contingency',0,30,1,fP,'Percentage buffer on total CAPEX for unexpected costs. 10\u201315% is standard for construction, 15\u201320% for complex projects.'); slider('contingencyPct','Contingency',0,30,1,fP,'Percentage buffer on total CAPEX for unexpected costs. 10\u201315% is standard for construction, 15\u201320% for complex projects.')+
slider('budgetTarget','Your Budget Target',0,5000000,10000,fE,'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.');
$('#inp-capex').innerHTML = h; $('#inp-capex').innerHTML = h;
} }
@@ -248,7 +331,7 @@ function buildToggle(id,opts,key){
el.querySelectorAll('button').forEach(btn=>{ el.querySelectorAll('button').forEach(btn=>{
btn.classList.toggle('toggle-btn--active', btn.dataset.val===S[key]); btn.classList.toggle('toggle-btn--active', btn.dataset.val===S[key]);
}); });
rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); render(); rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); bindPills(); render();
}); });
} }
@@ -257,6 +340,7 @@ function bindSliders(){
inp.oninput = () => { inp.oninput = () => {
const k=inp.dataset.key; const k=inp.dataset.key;
S[k]=parseFloat(inp.value); S[k]=parseFloat(inp.value);
if(k==='permitsCompliance') _userAdjusted.add(k);
const numInp = document.querySelector(`input[type=number][data-numfor="${k}"]`); const numInp = document.querySelector(`input[type=number][data-numfor="${k}"]`);
if(numInp) numInp.value = S[k]; if(numInp) numInp.value = S[k];
render(); render();
@@ -268,6 +352,7 @@ function bindSliders(){
const v = parseFloat(inp.value); const v = parseFloat(inp.value);
if(isNaN(v)) return; if(isNaN(v)) return;
S[k]=v; S[k]=v;
if(k==='permitsCompliance') _userAdjusted.add(k);
const rangeInp = document.querySelector(`input[type=range][data-key="${k}"]`); const rangeInp = document.querySelector(`input[type=range][data-key="${k}"]`);
if(rangeInp) rangeInp.value = v; if(rangeInp) rangeInp.value = v;
render(); render();
@@ -285,6 +370,10 @@ function render(){
t.classList.toggle('active',t.id===`tab-${activeTab}`); t.classList.toggle('active',t.id===`tab-${activeTab}`);
}); });
// Show signup bar on results tabs for guests
const sb=$('#signupBar');
if(sb) sb.style.display=activeTab!=='assumptions'?'flex':'none';
// If we have cached data, render immediately with it // If we have cached data, render immediately with it
if(_lastD) renderWith(_lastD); if(_lastD) renderWith(_lastD);
@@ -314,6 +403,8 @@ function renderWith(d){
if(isIn){ sec.classList.remove('visible'); } if(isIn){ sec.classList.remove('visible'); }
else { sec.classList.add('visible'); renderSeasonChart(); } else { sec.classList.add('visible'); renderSeasonChart(); }
} }
if(activeTab==='assumptions') renderWizPreview();
} }
// ── Table helper ── // ── Table helper ──
@@ -326,6 +417,18 @@ function renderCapex(d){
cardHTML('Per Court',fmt(Math.round(d.capexPerCourt)),d.totalCourts+' courts','','Total investment divided by number of courts. Useful for comparing scenarios and benchmarking.')+ cardHTML('Per Court',fmt(Math.round(d.capexPerCourt)),d.totalCourts+' courts','','Total investment divided by number of courts. Useful for comparing scenarios and benchmarking.')+
cardHTML('Per m\u00B2',fmt(Math.round(d.capexPerSqm)),fmtN(d.sqm)+' m\u00B2','','Total investment per square meter of venue space. Benchmarks construction efficiency.'); cardHTML('Per m\u00B2',fmt(Math.round(d.capexPerSqm)),fmtN(d.sqm)+' m\u00B2','','Total investment per square meter of venue space. Benchmarks construction efficiency.');
// Budget indicator
if(d.budgetTarget>0){
const over=d.budgetVariance>0;
const cls=over?'red':'green';
const sign=over?'+':'';
$('#capexCards').innerHTML+=`<div class="budget-indicator budget-indicator--${over?'over':'under'}">
<div class="metric-card__label">BUDGET ${over?'OVER':'UNDER'}</div>
<div class="metric-card__value ${over?'c-red':'c-green'}">${sign}${fmt(Math.round(d.budgetVariance))}</div>
<div class="metric-card__sub">${d.budgetPct.toFixed(0)}% of ${fmtK(d.budgetTarget)} budget</div>
</div>`;
}
let rows = d.capexItems.map(i=>`<tr><td>${i.name}${i.info?` <span style="color:var(--txt-3);font-size:10px">(${i.info})</span>`:''}</td><td class="mono">${fmt(i.amount)}</td></tr>`).join(''); let rows = d.capexItems.map(i=>`<tr><td>${i.name}${i.info?` <span style="color:var(--txt-3);font-size:10px">(${i.info})</span>`:''}</td><td class="mono">${fmt(i.amount)}</td></tr>`).join('');
rows += `<tr class="total-row"><td>TOTAL CAPEX</td><td class="mono">${fmt(d.capex)}</td></tr>`; rows += `<tr class="total-row"><td>TOTAL CAPEX</td><td class="mono">${fmt(d.capex)}</td></tr>`;
$('#capexTable').innerHTML = `<table class="data-table"><thead><tr>${TH('Item')}${THR('Amount')}</tr></thead><tbody>${rows}</tbody></table>`; $('#capexTable').innerHTML = `<table class="data-table"><thead><tr>${TH('Item')}${THR('Amount')}</tr></thead><tbody>${rows}</tbody></table>`;
@@ -591,14 +694,28 @@ function loadScenario(id){
Object.assign(S, state); Object.assign(S, state);
buildInputs(); buildInputs();
bindSliders(); bindSliders();
bindPills();
render(); render();
document.getElementById('scenario-drawer').classList.remove('open'); document.getElementById('scenario-drawer').classList.remove('open');
} }
}); });
} }
function resetToDefaults(){
if(!confirm('Reset all assumptions to defaults?')) return;
Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS)));
_userAdjusted.clear();
buildInputs();
bindSliders();
bindPills();
render();
}
// Wire up save button // Wire up save button
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const resetBtn = document.getElementById('resetDefaultsBtn');
if(resetBtn) resetBtn.onclick = resetToDefaults;
const saveBtn = document.getElementById('saveScenarioBtn'); const saveBtn = document.getElementById('saveScenarioBtn');
if(saveBtn) saveBtn.onclick = saveScenario; if(saveBtn) saveBtn.onclick = saveScenario;
@@ -610,10 +727,195 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
// ── Wizard navigation ─────────────────────────────────────
function buildWizardNav(){
const dots = $('#wizardDots');
if(!dots) return;
dots.innerHTML = WIZ_STEPS.map(s=>{
const cls = s.n===wizStep?'wiz-dot wiz-dot--active':s.n<wizStep?'wiz-dot wiz-dot--done':'wiz-dot';
return `<button class="${cls}" data-wiz="${s.n}"><span class="wiz-dot__num">${s.n<wizStep?'&#10003;':s.n}</span>${s.label}</button>`;
}).join('');
dots.querySelectorAll('button').forEach(b=>b.onclick=()=>{
wizStep=parseInt(b.dataset.wiz);
showWizStep();
});
}
function showWizStep(){
document.querySelectorAll('.wizard-step').forEach(el=>{
el.classList.toggle('active',parseInt(el.dataset.wiz)===wizStep);
});
buildWizardNav();
renderWizNav();
if(_lastD) renderWizPreview();
// Auto-fill hidden fields when entering step 5
if(wizStep===5) populateWizAutoFill();
}
function renderWizPreview(){
const el=$('#wizPreview');
if(!el||!_lastD) return;
const d=_lastD;
const cf = d.ebitdaMonth - d.monthlyPayment;
const cfCls = cf>=0?'c-green':'c-red';
const irrOk = isFinite(d.irr)&&!isNaN(d.irr);
el.innerHTML=`
<div class="wiz-preview__item">
<div class="wiz-preview__label">CAPEX</div>
<div class="wiz-preview__value">${fmtK(d.capex)}</div>
</div>
<div class="wiz-preview__item">
<div class="wiz-preview__label">Monthly CF</div>
<div class="wiz-preview__value ${cfCls}">${fmtK(cf)}/mo</div>
</div>
<div class="wiz-preview__item">
<div class="wiz-preview__label">IRR (${S.holdYears}yr)</div>
<div class="wiz-preview__value">${irrOk?fmtP(d.irr):'N/A'}</div>
</div>`;
}
function renderWizNav(){
const el=$('#wizNav');
if(!el) return;
let left='', right='';
if(wizStep>1){
left=`<button class="wiz-btn--back" onclick="wizStep--;showWizStep()">&larr; Back</button>`;
} else {
left='<div></div>';
}
if(wizStep<4){
right=`<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">Next &rarr;</button>`;
} else if(wizStep===4){
right=`<div class="wizard-nav__right">
<button class="wiz-skip" onclick="activeTab='capex';render()">Skip to Results</button>
<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">Next &rarr;</button>
</div>`;
} else if(wizStep===5){
right=`<div class="wizard-nav__right">
<button class="wiz-skip" onclick="activeTab='capex';render()">Skip to Results</button>
<button class="wiz-btn--submit" onclick="submitQuote()">Submit &amp; Get Quotes &rarr;</button>
</div>`;
}
el.innerHTML=left+right;
}
const COUNTRY_NAMES = {
DE:'Germany',ES:'Spain',IT:'Italy',FR:'France',NL:'Netherlands',
SE:'Sweden',UK:'United Kingdom',OTHER:'Other'
};
function populateWizAutoFill(){
// Set hidden inputs
const ct = S.dblCourts+S.sglCourts;
const el=v=>document.getElementById(v);
if(el('wiz_facility_type')) el('wiz_facility_type').value=S.venue;
if(el('wiz_court_count')) el('wiz_court_count').value=ct;
if(el('wiz_glass_type')) el('wiz_glass_type').value=S.glassType;
if(el('wiz_lighting_type')) el('wiz_lighting_type').value=S.lightingType;
if(el('wiz_country')) el('wiz_country').value=S.country;
if(el('wiz_budget')) el('wiz_budget').value=S.budgetTarget||'';
// Render auto-fill summary
const summary=$('#wizAutoSummary');
if(summary){
summary.innerHTML=`<dl style="display:grid;grid-template-columns:1fr 1fr;gap:2px 1rem;margin:0">
<dt>Facility</dt><dd>${S.venue==='indoor'?'Indoor':'Outdoor'} (${S.own==='buy'?'Buy':'Rent'})</dd>
<dt>Courts</dt><dd>${ct} (${S.dblCourts} double + ${S.sglCourts} single)</dd>
<dt>Glass</dt><dd>${S.glassType==='panoramic'?'Panoramic':'Standard'}</dd>
<dt>Lighting</dt><dd>${S.lightingType.replace(/_/g,' ')}</dd>
<dt>Country</dt><dd>${COUNTRY_NAMES[S.country]||S.country}</dd>
${S.budgetTarget?`<dt>Budget</dt><dd>${fmtK(S.budgetTarget)}</dd>`:''}
</dl>`;
}
}
function submitQuote(){
const form=document.getElementById('wizQuoteForm');
if(!form) return;
// Check required fields
const name=form.querySelector('[name="contact_name"]');
const email=form.querySelector('[name="contact_email"]');
const consent=form.querySelector('[name="consent"]');
if(!name.value.trim()||!email.value.trim()){
name.reportValidity(); email.reportValidity();
return;
}
if(!consent.checked){
consent.reportValidity();
return;
}
// Collect form data
const fd=new FormData(form);
const data={};
for(const [k,v] of fd.entries()){
if(k==='csrf_token') continue;
if(k==='services_needed'){
if(!data.services_needed) data.services_needed=[];
data.services_needed.push(v);
} else {
data[k]=v;
}
}
// Ensure services_needed is array
if(data.services_needed&&!Array.isArray(data.services_needed)) data.services_needed=[data.services_needed];
const csrf=form.querySelector('[name="csrf_token"]')?.value;
const btn=document.querySelector('.wiz-btn--submit');
if(btn){ btn.disabled=true; btn.textContent='Submitting\u2026'; }
fetch(window.__PADELNOMICS_QUOTE_URL__||'/leads/quote',{
method:'POST',
headers:{
'Content-Type':'application/json',
'X-CSRF-Token':csrf,
},
body:JSON.stringify(data),
})
.then(r=>{
if(!r.ok&&r.status===422) return r.json().then(d=>{throw d});
return r.json();
})
.then(resp=>{
if(resp.ok){
// Hide form, show success
form.style.display='none';
$('#wizAutoSummary').style.display='none';
document.getElementById('wizSuccess').style.display='block';
// Hide nav buttons
$('#wizNav').innerHTML=`<div></div><button class="wiz-btn--next" onclick="activeTab='capex';render()">View Results &rarr;</button>`;
}
})
.catch(err=>{
if(btn){ btn.disabled=false; btn.textContent='Submit & Get Quotes \u2192'; }
if(err&&err.errors){
alert(err.errors.join('\n'));
}
});
}
// ── Quote URL builder ─────────────────────────────────────
function getQuoteUrl(){
const base = window.__PADELNOMICS_QUOTE_URL__ || '/leads/quote';
return base+'?'+new URLSearchParams({
venue:S.venue,
courts:S.dblCourts+S.sglCourts,
glass:S.glassType,
lighting:S.lightingType,
budget:S.budgetTarget||'',
country:S.country,
}).toString();
}
// ── Init ────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────
buildNav(); buildNav();
buildInputs(); buildInputs();
bindSliders(); bindSliders();
bindPills();
showWizStep();
// Use server-provided initial data for first render (no API call needed) // Use server-provided initial data for first render (no API call needed)
if(_lastD){ if(_lastD){
renderWith(_lastD); renderWith(_lastD);

View File

@@ -20,25 +20,34 @@
</head> </head>
<body> <body>
<!-- Navigation --> <!-- Navigation -->
<nav class="container-page flex items-center justify-between py-4"> <nav class="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-light-gray">
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="flex items-center no-underline"> <div class="container-page flex items-center justify-between py-3">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-8 w-auto"> <div class="flex items-center gap-6">
</a> <a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="flex items-center no-underline">
<div class="flex items-center gap-4 text-sm"> <img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-8 w-auto">
{% if user %} </a>
<a href="{{ url_for('planner.index') }}">Planner</a> <div class="hidden md:flex items-center gap-4 text-sm">
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> <a href="{{ url_for('planner.index') }}">Planner</a>
{% if session.get('is_admin') %} <a href="{{ url_for('directory.index') }}">Directory</a>
<a href="{{ url_for('admin.index') }}"><span class="badge">Admin</span></a> <span class="text-slate-300">|</span>
{% endif %} <a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
<form method="post" action="{{ url_for('auth.logout') }}" class="m-0"> <a href="{{ url_for('public.landing') }}#faq">Help</a>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> </div>
<button type="submit" class="btn-outline btn-sm">Sign Out</button> </div>
</form> <div class="flex items-center gap-3 text-sm">
{% else %} {% if user %}
<a href="{{ url_for('auth.login') }}">Sign In</a> <a href="{{ url_for('dashboard.index') }}">Dashboard</a>
<a href="{{ url_for('auth.signup') }}" class="btn btn-sm whitespace-nowrap">Get Started Free</a> {% if session.get('is_admin') %}
{% endif %} <a href="{{ url_for('admin.index') }}"><span class="badge">Admin</span></a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
</form>
{% else %}
<a href="{{ url_for('auth.login') }}" class="btn-outline btn-sm">Sign In</a>
{% endif %}
</div>
</div> </div>
</nav> </nav>
@@ -68,8 +77,8 @@
<p class="font-semibold text-navy text-sm mb-2">Product</p> <p class="font-semibold text-navy text-sm mb-2">Product</p>
<ul class="space-y-1 text-sm"> <ul class="space-y-1 text-sm">
<li><a href="{{ url_for('planner.index') }}">Planner</a></li> <li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('leads.suppliers') }}">Suppliers</a></li> <li><a href="{{ url_for('directory.index') }}">Supplier Directory</a></li>
<li><a href="{{ url_for('leads.financing') }}">Financing</a></li> <li><a href="{{ url_for('public.suppliers') }}">For Suppliers</a></li>
</ul> </ul>
</div> </div>
<div> <div>

View File

@@ -476,6 +476,55 @@ class TestCalcIndoorRent:
assert "Water" in names assert "Water" in names
assert "Cleaning" in names assert "Cleaning" in names
def test_permits_compliance_in_indoor_rent(self, d):
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_permits_compliance_amount(self, d):
permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
assert permits["amount"] == DEFAULTS["permitsCompliance"]
# ════════════════════════════════════════════════════════════
# calc — permits & compliance across scenarios
# ════════════════════════════════════════════════════════════
class TestPermitsCompliance:
def test_indoor_rent_has_permits(self):
d = calc(default_state(venue="indoor", own="rent"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_outdoor_rent_has_permits(self):
d = calc(default_state(venue="outdoor", own="rent"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_outdoor_buy_has_permits(self):
d = calc(default_state(venue="outdoor", own="buy"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_indoor_buy_no_permits_compliance(self):
"""Indoor Buy already has Planning + Permits, so no separate Permits & Compliance."""
d = calc(default_state(venue="indoor", own="buy"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" not in names
assert "Planning + Permits" in names
def test_permits_compliance_value_adjustable(self):
d = calc(default_state(venue="indoor", own="rent", permitsCompliance=25000))
permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
assert permits["amount"] == 25000
def test_country_in_defaults(self):
assert "country" in DEFAULTS
assert DEFAULTS["country"] == "DE"
def test_permits_compliance_in_defaults(self):
assert "permitsCompliance" in DEFAULTS
assert DEFAULTS["permitsCompliance"] == 12000
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# calc — edge cases # calc — edge cases
@@ -758,7 +807,7 @@ class TestCalcRegression:
return calc(default_state()) return calc(default_state())
def test_capex_value(self, d): def test_capex_value(self, d):
assert d["capex"] == 270380 assert d["capex"] == 283580
def test_total_courts(self, d): def test_total_courts(self, d):
assert d["totalCourts"] == 6 assert d["totalCourts"] == 6
@@ -981,6 +1030,11 @@ plausible_state = st.fixed_dictionaries({
"exitMultiple": st.floats(0, 20, allow_nan=False, allow_infinity=False), "exitMultiple": st.floats(0, 20, allow_nan=False, allow_infinity=False),
"contingencyPct": st.integers(0, 30), "contingencyPct": st.integers(0, 30),
"staff": st.integers(0, 20000), "staff": st.integers(0, 20000),
"budgetTarget": st.integers(0, 5000000),
"glassType": st.sampled_from(["standard", "panoramic"]),
"lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "OTHER"]),
"permitsCompliance": st.integers(0, 50000),
}) })

View File

@@ -0,0 +1,505 @@
"""
Phase 0 tests: guest mode, new calculator variables, heat score, quote flow, migration.
"""
import json
import math
import pytest
from padelnomics.leads.routes import calculate_heat_score
from padelnomics.planner.calculator import DEFAULTS, calc, validate_state
ALL_COMBOS = [
("indoor", "rent"),
("indoor", "buy"),
("outdoor", "rent"),
("outdoor", "buy"),
]
def default_state(**overrides):
s = {**DEFAULTS, **overrides}
return validate_state(s)
def _assert_finite(obj, path=""):
if isinstance(obj, float):
assert math.isfinite(obj), f"Non-finite at {path}: {obj}"
elif isinstance(obj, dict):
for k, v in obj.items():
_assert_finite(v, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
_assert_finite(v, f"{path}[{i}]")
# ════════════════════════════════════════════════════════════
# Guest mode
# ════════════════════════════════════════════════════════════
class TestGuestMode:
async def test_planner_accessible_without_login(self, client):
"""GET /planner/ returns 200 for unauthenticated user."""
resp = await client.get("/planner/")
assert resp.status_code == 200
async def test_calculate_endpoint_works_without_login(self, client):
"""POST /planner/calculate returns valid JSON for guest."""
resp = await client.post(
"/planner/calculate",
json={"state": {"dblCourts": 4}},
)
assert resp.status_code == 200
data = await resp.get_json()
assert "capex" in data
async def test_scenario_routes_require_login(self, client):
"""Save/load/delete/list scenarios still require auth."""
resp = await client.post(
"/planner/scenarios/save",
json={"name": "test", "state_json": "{}"},
)
assert resp.status_code in (302, 401)
async def test_planner_hides_save_for_guest(self, client):
"""Planner HTML does not render scenario controls for guests."""
resp = await client.get("/planner/")
html = (await resp.data).decode()
assert "saveScenarioBtn" not in html
async def test_planner_shows_save_for_auth(self, auth_client):
"""Planner HTML renders scenario controls for logged-in users."""
resp = await auth_client.get("/planner/")
html = (await resp.data).decode()
assert "saveScenarioBtn" in html
# ════════════════════════════════════════════════════════════
# New calculator variables — defaults
# ════════════════════════════════════════════════════════════
class TestNewCalculatorVariables:
def test_budget_target_in_defaults(self):
assert "budgetTarget" in DEFAULTS
assert DEFAULTS["budgetTarget"] == 0
def test_glass_type_in_defaults(self):
assert "glassType" in DEFAULTS
assert DEFAULTS["glassType"] == "standard"
def test_lighting_type_in_defaults(self):
assert "lightingType" in DEFAULTS
assert DEFAULTS["lightingType"] == "led_standard"
# ════════════════════════════════════════════════════════════
# Glass type
# ════════════════════════════════════════════════════════════
class TestGlassType:
def test_panoramic_glass_increases_capex(self):
d_std = calc(default_state(glassType="standard"))
d_pan = calc(default_state(glassType="panoramic"))
assert d_pan["capex"] > d_std["capex"]
@pytest.mark.parametrize("glass", ["standard", "panoramic"])
@pytest.mark.parametrize("venue,own", ALL_COMBOS)
def test_glass_type_all_combos(self, venue, own, glass):
d = calc(default_state(venue=venue, own=own, glassType=glass))
assert d["capex"] > 0
_assert_finite(d)
def test_panoramic_applies_1_4x_multiplier(self):
"""Panoramic courts cost 1.4x standard courts."""
d_std = calc(default_state(glassType="standard"))
d_pan = calc(default_state(glassType="panoramic"))
std_courts = next(i for i in d_std["capexItems"] if i["name"] == "Padel Courts")
pan_courts = next(i for i in d_pan["capexItems"] if i["name"] == "Padel Courts")
assert pan_courts["amount"] == pytest.approx(std_courts["amount"] * 1.4, abs=1)
# ════════════════════════════════════════════════════════════
# Lighting type
# ════════════════════════════════════════════════════════════
class TestLightingType:
def test_led_competition_increases_capex(self):
d_std = calc(default_state(lightingType="led_standard"))
d_comp = calc(default_state(lightingType="led_competition"))
assert d_comp["capex"] > d_std["capex"]
def test_natural_light_outdoor(self):
d_led = calc(default_state(venue="outdoor", lightingType="led_standard"))
d_nat = calc(default_state(venue="outdoor", lightingType="natural"))
assert d_nat["capex"] < d_led["capex"]
def test_natural_light_zeroes_outdoor_lighting(self):
d = calc(default_state(venue="outdoor", lightingType="natural"))
lighting_item = next(
(i for i in d["capexItems"] if i["name"] == "Lighting"), None
)
assert lighting_item is not None
assert lighting_item["amount"] == 0
@pytest.mark.parametrize("light", ["led_standard", "led_competition"])
@pytest.mark.parametrize("venue,own", ALL_COMBOS)
def test_lighting_type_all_combos(self, venue, own, light):
d = calc(default_state(venue=venue, own=own, lightingType=light))
assert d["capex"] > 0
_assert_finite(d)
# ════════════════════════════════════════════════════════════
# Budget target
# ════════════════════════════════════════════════════════════
class TestBudgetTarget:
def test_budget_variance_when_set(self):
d = calc(default_state(budgetTarget=300000))
assert "budgetVariance" in d
assert d["budgetVariance"] == d["capex"] - 300000
def test_budget_variance_zero_when_no_budget(self):
d = calc(default_state(budgetTarget=0))
assert d["budgetVariance"] == 0
assert d["budgetPct"] == 0
def test_budget_pct_calculated(self):
d = calc(default_state(budgetTarget=200000))
assert d["budgetPct"] == pytest.approx(d["capex"] / 200000 * 100)
def test_budget_target_passthrough(self):
d = calc(default_state(budgetTarget=500000))
assert d["budgetTarget"] == 500000
# ════════════════════════════════════════════════════════════
# Heat score
# ════════════════════════════════════════════════════════════
class TestHeatScore:
def test_hot_lead(self):
"""High-readiness signals = hot."""
form = {
"timeline": "asap",
"location_status": "lease_signed",
"financing_status": "self_funded",
"decision_process": "solo",
"previous_supplier_contact": "received_quotes",
"budget_estimate": "500000",
}
assert calculate_heat_score(form) == "hot"
def test_cool_lead(self):
"""Low-readiness signals = cool."""
form = {
"timeline": "12+mo",
"location_status": "still_searching",
"financing_status": "not_started",
"decision_process": "committee",
"previous_supplier_contact": "first_time",
"budget_estimate": "0",
}
assert calculate_heat_score(form) == "cool"
def test_warm_lead(self):
"""Mid-readiness signals = warm."""
form = {
"timeline": "3-6mo",
"location_status": "location_found",
"financing_status": "seeking",
"decision_process": "partners",
"budget_estimate": "150000",
}
assert calculate_heat_score(form) == "warm"
def test_empty_form_is_cool(self):
assert calculate_heat_score({}) == "cool"
def test_timeline_6_12mo_scores_1(self):
assert calculate_heat_score({"timeline": "6-12mo"}) == "cool"
def test_high_budget_alone_not_hot(self):
"""Budget alone shouldn't make a lead hot."""
assert calculate_heat_score({"budget_estimate": "1000000"}) == "cool"
def test_permit_granted_scores_4(self):
assert calculate_heat_score({"location_status": "permit_granted"}) == "cool"
# Combined with timeline makes it warm
form = {"location_status": "permit_granted", "timeline": "asap"}
assert calculate_heat_score(form) == "warm"
def test_permit_pending_scores_3(self):
form = {
"location_status": "permit_pending",
"timeline": "3-6mo",
"financing_status": "self_funded",
}
assert calculate_heat_score(form) == "warm"
def test_converting_existing_scores_2(self):
assert calculate_heat_score({"location_status": "converting_existing"}) == "cool"
def test_permit_not_filed_scores_2(self):
assert calculate_heat_score({"location_status": "permit_not_filed"}) == "cool"
def test_location_found_scores_1(self):
assert calculate_heat_score({"location_status": "location_found"}) == "cool"
# ════════════════════════════════════════════════════════════
# Quote request route
# ════════════════════════════════════════════════════════════
class TestQuoteRequest:
async def test_quote_form_loads(self, client):
"""GET /leads/quote returns 200 with form."""
resp = await client.get("/leads/quote")
assert resp.status_code == 200
async def test_quote_prefill_from_params(self, client):
"""Query params pre-fill the form."""
resp = await client.get("/leads/quote?venue=outdoor&courts=6")
assert resp.status_code == 200
async def test_quote_submit_creates_lead(self, client, db):
"""POST /leads/quote creates a lead_requests row."""
# Get CSRF token first
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
form={
"facility_type": "indoor",
"court_count": "4",
"glass_type": "panoramic",
"lighting_type": "led_standard",
"build_context": "new_standalone",
"country": "DE",
"timeline": "3-6mo",
"location_status": "location_found",
"financing_status": "self_funded",
"decision_process": "solo",
"stakeholder_type": "entrepreneur",
"contact_name": "Test User",
"contact_email": "test@example.com",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute("SELECT * FROM lead_requests WHERE lead_type = 'quote'") as cur:
rows = await cur.fetchall()
assert len(rows) == 1
row = dict(rows[0])
assert row["heat_score"] in ("hot", "warm", "cool")
assert row["contact_email"] == "test@example.com"
assert row["facility_type"] == "indoor"
assert row["stakeholder_type"] == "entrepreneur"
async def test_quote_submit_without_login(self, client, db):
"""Guests can submit quotes (user_id is null)."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
form={
"facility_type": "indoor",
"court_count": "2",
"country": "DE",
"timeline": "3-6mo",
"stakeholder_type": "entrepreneur",
"contact_name": "Guest",
"contact_email": "guest@example.com",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT user_id FROM lead_requests WHERE contact_email = 'guest@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] is None # user_id should be NULL for guests
async def test_quote_submit_with_login(self, auth_client, db, test_user):
"""Logged-in user gets user_id set on lead."""
await auth_client.get("/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/leads/quote",
form={
"facility_type": "outdoor",
"court_count": "6",
"country": "DE",
"timeline": "asap",
"stakeholder_type": "entrepreneur",
"contact_name": "Auth User",
"contact_email": "auth@example.com",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT user_id FROM lead_requests WHERE contact_email = 'auth@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] == test_user["id"]
async def test_venue_search_build_context(self, client, db):
"""Build context 'venue_search' is stored correctly."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
form={
"facility_type": "indoor",
"court_count": "4",
"build_context": "venue_search",
"country": "DE",
"timeline": "6-12mo",
"stakeholder_type": "developer",
"contact_name": "Venue Search",
"contact_email": "venue@example.com",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT build_context FROM lead_requests WHERE contact_email = 'venue@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] == "venue_search"
async def test_stakeholder_type_stored(self, client, db):
"""stakeholder_type field is stored correctly."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
form={
"facility_type": "indoor",
"court_count": "6",
"country": "DE",
"timeline": "asap",
"stakeholder_type": "tennis_club",
"contact_name": "Club Owner",
"contact_email": "club@example.com",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
async with db.execute(
"SELECT stakeholder_type FROM lead_requests WHERE contact_email = 'club@example.com'"
) as cur:
row = await cur.fetchone()
assert row is not None
assert row[0] == "tennis_club"
async def test_submitted_page_has_context(self, client):
"""Quote submitted page includes project context."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
form={
"facility_type": "indoor",
"court_count": "6",
"country": "DE",
"timeline": "3-6mo",
"stakeholder_type": "entrepreneur",
"contact_name": "Context Test",
"contact_email": "ctx@example.com",
"csrf_token": csrf,
},
)
assert resp.status_code == 200
html = (await resp.data).decode()
assert "matched" in html.lower()
assert "6-court" in html
assert "DE" in html
async def test_quote_validation_rejects_missing_fields(self, client):
"""POST /leads/quote returns 422 JSON when mandatory fields missing."""
await client.get("/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
json={
"facility_type": "indoor",
"court_count": "4",
"contact_name": "",
"contact_email": "",
},
headers={"X-CSRF-Token": csrf},
)
assert resp.status_code == 422
data = await resp.get_json()
assert data["ok"] is False
assert len(data["errors"]) >= 3 # country, timeline, stakeholder_type + name + email
# ════════════════════════════════════════════════════════════
# Migration / schema
# ════════════════════════════════════════════════════════════
class TestSchema:
async def test_schema_has_new_columns(self, db):
"""Fresh DB from schema.sql has all expanded lead_requests columns."""
async with db.execute("PRAGMA table_info(lead_requests)") as cur:
cols = {r[1] for r in await cur.fetchall()}
for expected in (
"facility_type", "glass_type", "lighting_type",
"build_context", "country", "timeline",
"location_status", "financing_status",
"heat_score", "contact_name", "contact_email",
"contact_phone", "contact_company",
"wants_financing_help", "decision_process",
"previous_supplier_contact", "services_needed",
"additional_info", "stakeholder_type",
):
assert expected in cols, f"Missing column: {expected}"
async def test_user_id_nullable(self, db):
"""lead_requests.user_id should accept NULL for guest leads."""
await db.execute(
"INSERT INTO lead_requests (lead_type, contact_email, created_at) VALUES (?, ?, datetime('now'))",
("quote", "guest@example.com"),
)
await db.commit()
async with db.execute(
"SELECT user_id FROM lead_requests WHERE contact_email = 'guest@example.com'"
) as cur:
row = await cur.fetchone()
assert row[0] is None
# ════════════════════════════════════════════════════════════
# Business plan price in config
# ════════════════════════════════════════════════════════════
class TestBusinessPlanConfig:
def test_business_plan_in_paddle_prices(self):
from padelnomics.core import Config
c = Config()
assert "business_plan" in c.PADDLE_PRICES

View File

@@ -1,2 +1,3 @@
- Make the calculator or at least parts of it public -> check reasoning in padelnomic lead platform md - [x] Make the calculator or at least parts of it public -> check reasoning in padelnomic lead platform md (in plan.md)
- [] Add R2 credentials and setup for litestream to better secure data.
- [] Check if we have rollback/backup restore functionality for database & deployments