Files
padelnomics/CHANGELOG.md
Deeman 76814dade7 feat: landing zone backup to R2 via rclone + Litestream
Landing files (append-only JSON.gz) synced to R2 every 30 min via
systemd timer + rclone. Extraction state DB (.state.sqlite) continuously
replicated via Litestream (second DB entry). Auto-restore on container
startup for both app.db and .state.sqlite. Reuses existing R2 bucket
and credentials — no new env vars needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:06:16 +01:00

68 KiB
Raw Blame History

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog.

[Unreleased]

Added

  • Landing zone backup to R2 — append-only landing files (data/landing/*.json.gz) synced to Cloudflare R2 every 30 minutes via systemd timer + rclone; extraction state DB (.state.sqlite) continuously replicated via Litestream (second DB entry in existing config); auto-restore on container startup for both app.db and .state.sqlite; infra/restore_landing.sh script for disaster recovery of landing files; infra/landing-backup/ systemd service + timer units; rclone installation added to infra/setup_server.sh; reuses existing R2 bucket and credentials (no new env vars)

Changed

  • Extraction: one file per source — replaced monolithic execute.py with per-source modules (overpass.py, eurostat.py, playtomic_tenants.py, playtomic_availability.py); each module has its own CLI entry point (extract-overpass, extract-eurostat, etc.); shared boilerplate extracted to _shared.py with run_extractor() wrapper that handles SQLite state tracking, logging, and session management
  • Transform: 4-layer → 3-layer — removed raw/ layer; staging models now read landing zone JSON files directly via read_json() with @LANDING_DIR variable; model schemas renamed from padelnomics.* to per-layer namespaces (staging.*, foundation.*, serving.*)
  • Two-DuckDB architecture — web app now reads from SERVING_DUCKDB_PATH (analytics.duckdb) instead of DUCKDB_PATH (lakehouse.duckdb); export_serving.py atomically swaps serving tables after each transform run
  • Supervisor: added daily sleep interval between pipeline runs

Added

  • Playtomic availability extractor (playtomic_availability.py) — daily next-day booking slot snapshots for occupancy rate estimation and pricing benchmarking; reads tenant IDs from latest tenants.json.gz, queries /v1/availability per venue with 2s throttle, resumable via cursor, bounded at 10K venues per run
  • Template sync: copier update v0.9.0 → v0.10.0 — export_serving.py module, @padelnomics_glob() macro, setup_server.sh, supervisor export_serving step

Fixed

  • Eurostat JSON-stat parsing — API returns 4-7 dimension sparse dictionaries (583K values) that caused DuckDB OOM; extractor now pre-processes JSON-stat into flat records with configurable dimension filters per dataset
  • Playtomic venue lat/lon — staging model used wrong JSON path (address.coordinate_lat vs actual address.coordinate.lat)
  • dim_cities CTE — unused eurostat_labels CTE caused city_slug_raw column not found error

Removed

  • extract/.../execute.py — replaced by per-source modules
  • models/raw/ directory — raw layer eliminated; staging reads landing files directly

Added

  • Template sync: copier update from 29ac25bv0.9.0 (29 template commits)

    • .claude/CLAUDE.md: project-specific Claude Code instructions (skills, commands, architecture)
    • .claude/coding_philosophy.md: engineering principles guide
    • extract/padelnomics_extract/README.md: extraction patterns & state tracking docs
    • extract/padelnomics_extract/src/padelnomics_extract/utils.py: SQLite state tracking (open_state_db, start_run, end_run, get_last_cursor) + file I/O helpers (landing_path, content_hash, write_gzip_atomic)
    • transform/sqlmesh_padelnomics/README.md: 4-layer SQLMesh architecture guide
    • Per-layer model READMEs (raw, staging, foundation, serving)
    • infra/supervisor/: systemd service + supervisor script for pipeline orchestration
  • Copier answers file now includes enable_daas, enable_cms, enable_directory, enable_i18n toggles (prevents accidental deletion on future copier updates)

  • Expanded programmatic SEO city coverage from 18 to 40 cities (+22 cities across ES, FR, IT, NL, AT, CH, SE, PT, BE, AE, AU, IE) — generates 80 articles (40 cities × EN + DE)

  • scripts/refresh_from_daas.py: syncs template_data rows from DuckDB planner_defaults serving table; supports --dry-run and --generate flags; graceful no-op when DuckDB unavailable

Added

  • analytics.py: DuckDB read-only reader (open_analytics_db, close_analytics_db, fetch_analytics) registered in app lifecycle (startup/shutdown)
  • GET /planner/api/market-data?city_slug=<slug>: returns per-city planner defaults from DuckDB planner_defaults serving table; falls back to {} when analytics DB unavailable

Added

  • transform/sqlmesh_padelnomics workspace member: SQLMesh 4-layer model pipeline over DuckDB
    • Raw: raw_overpass_courts, raw_playtomic_tenants, raw_eurostat_population
    • Staging: stg_padel_courts, stg_playtomic_venues, stg_population
    • Foundation: dim_venues (OSM + Playtomic deduped), dim_cities (with Eurostat population)
    • Serving: city_market_profile (market score OBT), planner_defaults (per-city calculator pre-fill)
  • extract/padelnomics_extract workspace member: Overpass API (padel courts via OSM), Eurostat city demographics (urb_cpop1, ilc_di03), and Playtomic unauthenticated tenant search extractors
  • Landing zone structure at data/landing/ with per-source subdirectories: overpass/, eurostat/, playtomic/
  • .env.example entries for DUCKDB_PATH and LANDING_DIR
  • content: scripts/seed_content.py — seeds two article templates (EN + DE) and 18 cities × 2 language rows into the database; run with uv run python -m padelnomics.scripts.seed_content --generate to produce 36 pre-built SEO articles covering Germany (8 cities), USA (6 cities), and UK (4 cities); each city has realistic per-market overrides for rates, rent, utilities, permits, and court configuration so the financial model produces genuinely unique output per article
  • content: EN template (city-padel-cost-en) at /padel-cost/{{ city_slug }} and DE template (city-padel-cost-de) at /padel-kosten/{{ city_slug }} with Jinja2 Markdown bodies embedding [scenario:slug:section] cards for summary, CAPEX, operating, cashflow, and returns

Fixed

  • content: bake_scenario_cards() now accepts a lang parameter and passes it to scenario partial templates; previously lang was always undefined, causing all cards to render with English labels even for German articles
  • admin: _generate_from_template() extracts language from data row and passes it to calc() and bake_scenario_cards() so German scenario cards use translated CAPEX/OPEX item names
  • admin: _generate_from_template() now derives article_slug as {template_slug}-{city_slug} instead of bare city_slug; bare slugs caused UNIQUE constraint collisions when multiple templates generated articles for the same city
  • admin: _rebuild_article() passes lang from data row (or "en" for manual articles) to bake_scenario_cards() so rebuilt articles render correct language labels
  • content: removed unused g import from content/routes.py

Changed

  • planner: full HTMX refactor — replaced 847-line SPA planner.js with server-rendered Jinja2 tab partials; planner now uses hx-post /planner/calculate + form state; all tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side; Chart.js data embedded as <script type="application/json"> tags, re-initialized on htmx:afterSettle; new planner.js is ~200 lines (chart init, slider sync, toggle management, wizard nav, scenario save/load)
  • planner/i18n: merged _PLANNER_TRANSLATIONS (~200 keys × 2 languages) into _TRANSLATIONS; deleted get_planner_translations() and window.__PADELNOMICS_LOCALE__; all planner strings now via standard {{ t.key }} Jinja2 template variables; adding a new language = one section in _TRANSLATIONS
  • planner/routes: /planner/calculate endpoint now returns HTML partial (HTMX) instead of JSON; added form_to_state() for form serialization, augment_d() for chart data + sensitivity table computation, COUNTRY_PRESETS dict; index() passes full calc result to template on initial load
  • app: added 5 Jinja2 template filters — fmt_currency, fmt_k, fmt_pct, fmt_x, fmt_n — replacing equivalent JS formatting functions
  • copy: switch all German UI copy from formal "Sie/Ihr" to informal "Du/Dein" — covers i18n.py (~60 keys), planner wizard step titles/subtitles, export waitlist page, quote wizard steps, quote submitted/verify pages, directory supplier detail, directory results partial, supplier signup step 4, supplier waitlist confirmed page
  • copy: replace "Platz-Anbieter" with "Anbieter" in CTAs; "Anlage" → "Padel-Platz" in planner wizard step 1 title/subtitle and planner translations (wiz_venue, sl_budget_target); "Anlageplanung" → "Padelplatz-Planung" in service checklist
  • copy: update directory H1 to SEO multi-term "Padelplatz-Hersteller, Platzbauer & Anbieter"; subheading now mentions Hersteller, Platzbauer, schlüsselfertige Lösungen
  • copy: fix mkt_no_results and mkt_search_placeholder — replaced incorrect "Artikel" with "Märkte" in German and English (markets page shows market pages like Miami, not articles)

Added

  • i18n: translate base.html, _cookie_banner.html — "Manage Cookies", "About" footer links, feedback placeholder via {{ t.key }}; cookie banner heading/categories/descriptions/buttons; JS toggle text injected via tojson so "Manage"/"Close" states are also translated; public/routes.py feedback flash messages use get_translations(g.lang) keys
  • i18n: expand i18n.py with ~300 UI template keys, ~200 planner JS locale strings (_PLANNER_TRANSLATIONS), ~35 CAPEX/OPEX item name translations (_CALC_ITEM_NAMES), plus get_planner_translations() and get_calc_item_names() functions

Fixed

  • i18n: planner.html used {% if lang %}...{% block %} nesting which Jinja2 forbids — restructured to {% block title %}{% if lang == 'de' %}...{% endif %}{% endblock %}
  • ruff: unsorted import in planner/routes.py (new get_planner_translations import) — auto-fixed with ruff --fix

Added

  • i18n: localize planner — inject window.__PADELNOMICS_LOCALE__ from server via get_planner_translations(lang), add const L / tr() helpers in planner.js, replace all hardcoded English strings in TABS, WIZ_STEPS, all buildInputs()/rebuildCapexInputs()/rebuildOpexInputs() slider labels, renderWith(), renderCapex(), renderOperating(), renderCashflow(), renderReturns(), renderMetrics(), renderSeasonChart(), resetToDefaults(), saveScenario(), renderWizNav(), and renderWizPreview() with tr('key', 'fallback') calls

  • i18n: localize planner.html — add window.__PADELNOMICS_LOCALE__ script injection, translate wizard step titles/subtitles, toggle labels, chart/section headers, CTA sidebar and inline CTA, signup bar, scenario controls, metrics section headers, and page title/meta via {% if lang == 'de' %} and {{ t.key }} / {{ planner_t.key }}

  • i18n: localize all export templates — export.html, export_success.html, export_generating.html, export_waitlist.html — all strings via {{ t.key }}, feature lists via {% if lang == 'de' %} conditionals

  • i18n: localize partials/scenario_list.html — drawer title, default badge, Load/Delete buttons, updated label, empty state message via {{ t.scenario_* }}

  • calculator: add lang: str = "en" parameter to calc(), import get_calc_item_names, replace all ci()/oi() hardcoded English names with names["key"] lookups, track rent_amount as local variable to replace name-based loop lookup for rentRatio

  • routes: pass lang and planner_t to planner.html render context; pass lang=lang to calc() in both index and /calculate endpoints

  • i18n: translate directory and leads templates — directory.html, supplier_detail.html, partials/results.html, partials/enquiry_result.html, quote_request.html, quote_step_19.html, quote_submitted.html, quote_verify_sent.html — short strings via {{ t.key }}, long paragraphs and context-sensitive text via {% if lang == 'de' %} conditionals, title/meta tags conditional per language

  • i18n: translate supplier signup flow (signup.html, signup_step_14.html, signup_success.html), waitlist pages (waitlist.html, waitlist_confirmed.html), content templates (markets.html, article_detail.html, market_results.html), and all scenario partials (scenario_summary, scenario_capex, scenario_cashflow, scenario_operating, scenario_returns) — step labels via {{ t.key }}, all other strings via {% if lang == 'de' %} conditionals

  • i18n: translate landing.html, features.html, and about.html to German — all short strings via {{ t.key }}, long paragraphs/FAQ answers via {% if lang == 'de' %} conditionals, JSON-LD structured data wrapped per language, title/meta blocks conditional

Changed

  • leads/routes.py: replace hardcoded QUOTE_STEPS list with _get_quote_steps(lang) function — step titles now use i18n keys so the progress bar shows translated step names; all public-facing flash() calls now use get_translations(g.lang) keys instead of hardcoded English strings

Fixed

  • Nav: hamburger button was trapped inside .nav-links--right; parent display: none on mobile hid it too — moved hamburger to be first child of .nav-inner; sign-in button added as always-visible mobile slot (.nav-auth-mobile) on the right; mobile grid is now auto 1fr auto (hamburger | logo | sign-in)
  • Nav: align .nav-inner width with container-page — changed max-width from 80rem to 72rem (--container-6xl) and matched responsive padding (1rem / 1.5rem / 2rem at mobile / sm / lg) so nav edges are flush with page content and footer
  • Planner: "Get Supplier Quotes" button now navigates to the correct lang-prefixed URL (/en/leads/quote etc.) — hardcoded /leads/quote caused a 404 on prod; URL is now injected from the server via window.__PADELNOMICS_QUOTE_URL__

Added

  • Nav: hamburger menu on screens < 900px — clicking opens a full-width mobile panel with all nav links; overlay click and Escape key close it
  • Nav: mobile panel groups links under "Plan", "Explore", and "Account" section headers

Changed

  • Nav: widen container from 72rem (1152px) to 80rem (1280px) — matches Zillow's nav container width, more breathing room for nav items on large monitors
  • Nav: collapse breakpoint raised from 768px to 899px — nav links no longer hide until the screen is actually too narrow
  • Nav: remove redundant inline style="display:grid;grid-template-columns:1fr auto 1fr" on .nav-inner (already in CSS)

Fixed

  • i18n: improve German nav labels — "Verzeichnis" → "Anbieterverzeichnis", "Planer" → "Kostenrechner"
  • CI: add missing env vars to .env heredoc — WAITLIST_MODE, LEADS_EMAIL, UMAMI_API_URL; make Paddle vars optional (:-) so they don't break deploys when unset

Changed

  • Legal pages: replaced home address with virtual office address (c/o COCENTER, Koppoldstr. 1, 86551 Aichach) in all four legal templates (imprint_de, imprint_en, privacy_de, privacy_en, terms_de)

Fixed

  • Litestream: remove local-path replica — v0.5.8 dropped multi-replica support ("multiple replicas on a single database are no longer supported"), keeping only the R2 replica
  • Litestream: extend retention from 7 days to 1 year (8760h) — WAL frames are tiny, R2 storage cost is negligible
  • Deploy: gate deployment on litestream health (kill -0 1, retries 6×5s after 15s start period) so broken backups fail the deploy loudly
  • Deploy: write nginx router config before starting containers so the router health check (nginx -t) passes on first deploy or after volume wipe
  • Deploy: pre-migration DB backup added to deploy.sh; on health-check failure the DB is restored to pre-migration state (prevents old slot from running against new schema)
  • Migrations: removed all conn.commit() and executescript() calls from up() functions in 0000, 0011, 0012, 0013, 0014, 0015 — restores batch-atomicity guarantee (executescript issued implicit COMMITs, breaking rollback on failure)
  • Visual tests: server now builds schema via migrate() instead of the deleted schema.sql; all 12 Playwright tests pass
  • Visual tests: updated assertions to match current landing page (text logo replacing img, .roi-calc replacing .teaser-calc, hero-dark/cta-card allowed as intentional dark sections, card count ≥ 6, i18n-prefixed logo href, h3 brightness threshold relaxed to 150)
  • CSS: removed dead .nav-logo { line-height: 0 } rule (was for image logo, collapsed text logo to zero height); removed dead .nav-logo img rule
  • Ruff: fixed 49 lint errors across src/ and tests/ (unused imports, unused variables, unsorted imports, bare f-strings, ambiguous variable name l)

Added

  • Litestream: R2 replication config with env-var placeholders (LITESTREAM_R2_BUCKET etc.) — fill in GitLab CI variables to enable off-host backup; handles new server, deleted volume, or disaster recovery via auto-restore on container startup
  • .env.example: Litestream R2 vars documented

Added

  • i18n URL prefixes: all public-facing blueprints (public, planner, directory, content, leads, suppliers) now live under /<lang>/ (e.g. /en/, /de/); internal blueprints (auth, dashboard, admin, billing) unchanged; root / detects language from cookie / Accept-Language header and 301-redirects; legacy URLs (/terms, /privacy, etc.) redirect to /en/ equivalents
  • German legal pages: full DSGVO-compliant Datenschutzerklärung (/de/privacy), AGB (/de/terms), and Impressum (/de/imprint) per § 5 DDG — populated with Hendrik Dreesmann's details, Kleinunternehmer § 19 UStG, Oldenburg address
  • Rewritten English legal pages (/en/terms, /en/privacy, /en/imprint) with expanded GDPR sections, correct controller identity, proper data-processing details (Umami self-hosted, Paddle, Resend with SCCs), and German-law jurisdiction
  • Language toggle (EN | DE) in footer; hreflang en, de, and x-default tags in <head> on all lang-prefixed pages
  • lang cookie (1-year, SameSite=Lax) persisted on first visit; lang and t (translation dict) injected into every template context
  • i18n.py: flat translation dicts for ~20 nav/footer keys in en and de; LANG_BLUEPRINTS and SUPPORTED_LANGS constants
  • sitemap.xml and robots.txt moved to app-level root routes (not under /<lang>); sitemap now includes both language variants of every SEO page
  • Cookie consent banner: fixed bottom bar with "Accept all" and "Manage preferences" (toggle for functional/A/B cookies); consent stored in cookie_consent cookie for 1 year; "Manage Cookies" link added to footer Legal section

Changed

  • Defer Paddle.js loading to only the 3 pages that use checkout (export, supplier signup, supplier dashboard) — removed from global base.html head; all other pages no longer receive Paddle's third-party cookies
  • Gate A/B test cookie (ab_*) on functional cookie consent: variant is still picked per-request for rendering, but the cookie is only persisted when the visitor has accepted functional cookies
  • Privacy policy section 6 (Cookies): full disclosure of all cookie categories (essential, functional, payment); fix "Plausible" → "Umami" in service providers list

Changed

  • Auto-create Resend audiences per blueprint: capture_waitlist_email() now derives the audience name from request.blueprints[0] (e.g., waitlist-auth, waitlist-suppliers) and lazily creates audiences via the Resend API on first use, caching IDs in a new resend_audiences table; removes RESEND_AUDIENCE_WAITLIST env var — only RESEND_API_KEY needed

Added

  • Waitlist mode (lean startup smoke test): WAITLIST_MODE config flag intercepts /auth/signup, /suppliers/signup, and /planner/export to capture emails or show "coming soon" messaging before Paddle billing is ready; confirmation emails sent via Resend; optional RESEND_AUDIENCE_WAITLIST for bulk launch blast; flip flag to false and all flows revert to normal

Changed

  • Redesign padel racket SVG logo/favicon: filled dark silhouette with white punched-through holes (3×3 grid), proper throat tapering to handle at bottom, grip tape lines — replaces outline-only stroke approach; bump favicon cache to v4

Added

  • Simple A/B testing with @ab_test decorator and Umami data-tag integration
  • SEO defaults in base.html: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed
  • robots.txt route: disallows /admin/, /auth/, /dashboard/, /billing/, /directory/results; includes Sitemap reference
  • Meta descriptions and OG tags on all public pages: about, terms, privacy, pricing, features, suppliers, directory, supplier detail
  • og:image on landing and features pages pointing to planner screenshot
  • JSON-LD Organization schema on homepage and supplier detail pages
  • JSON-LD FAQPage schema on homepage (5 FAQ entries)
  • JSON-LD Article schema on article detail pages
  • Sitemap supplier slugs: all supplier detail pages now indexed
  • Sitemap <lastmod> on all entries (static pages: today, articles: COALESCE(updated_at, published_at), suppliers: created_at)
  • rel="preconnect" for Google Fonts to reduce font-load latency
  • X-Robots-Tag: noindex header on /directory/results HTMX partial

Fixed

  • Render-blocking Paddle.js: added defer attribute and wrapped Paddle.Initialize in DOMContentLoaded listener
  • Render-blocking Chart.js on planner page: added defer attribute
  • Broken og:image on planner page (og-planner.pngplanner-screenshot.png)
  • Homepage meta description trimmed to under 155 characters
  • Duplicate og:url and canonical tags removed from landing, markets, and article detail pages

Changed

  • Rewrote supplier page hero: pain-first headline ("Stop Chasing Cold Leads"), differentiator copy, micro-proof under CTA
  • Added "Problem With Finding Padel Clients Today" section with trade show / Google Ads / cold outreach pain points
  • Tightened "How It Works" step titles: "Claim Your Listing", "Browse Pre-Qualified Leads", "Win Projects Faster"
  • Added Basic tier (€39/mo / €29/mo yearly) to public pricing section on suppliers page
  • Pricing section now shows 3-column grid (Basic / Growth / Pro) with CSS-only billing period toggle (monthly / yearly, no JS)
  • Added "How We Compare" table: Padelnomics vs trade show / Google Ads / cold directory
  • Upgraded social proof section: two testimonial cards with decorative quote mark, stat bar in subtitle
  • Updated supplier FAQ: three tiers with correct prices (€39/€199/€499), two new entries (what makes leads different, pricing vs alternatives), removed stale €149/€399 figures
  • Strengthened final CTA: "Your Next Client Is Already Building a Business Plan"
  • Fixed stale prices in landing page FAQ: Basic €39/mo, Growth €199/mo, Pro €499/mo (was €149/€399)

Changed

  • Redesigned directory cards: image-first 16:9 layout with cover photos, frosted category badge, CSS court/grid placeholders, and logo avatar straddling the media/body border
  • 4-tier visual ladder: Free (62% opacity, grey placeholder, unverified chip) → Basic (full opacity, green placeholder, verified chip, description, "View Listing →") → Growth (green placeholder, Growth chip + stats, "Request Quote →") → Pro (green 2.5px top border, CSS court media, full stats, green hover glow)
  • Pro card media defaults to CSS court visualization when no cover image; Growth/Basic default to dark-green grid placeholder

Added

  • Cover image upload for suppliers in the dashboard listing form (saves to static/uploads/covers/, 16:9 thumbnail preview)
  • Migration 0013: cover_image TEXT column on suppliers table

Added

  • Basic subscription tier — verified directory listing with contact info, services checklist, social links, and enquiry form; no lead credits
  • Monthly + yearly billing — all paid supplier tiers now offer yearly pricing with annual discount (Basic: €349/yr, Growth: €1,799/yr, Pro: €4,499/yr)
  • Billing period toggle in supplier signup wizard — monthly/yearly pill switch, defaults to yearly; price cards update in real time via CSS sibling selectors
  • Redesigned supplier detail page — navy hero section with court-grid CSS pattern, two-column body (main + 320px sidebar), contact card with avatar/role/social links, stats grid, services checklist, enquiry form for Basic+ listings, tier-adaptive CTA strip
  • Supplier enquiry form on Basic+ listing pages — HTMX-powered inline form, rate-limited at 5 per email per 24 h, email relayed to supplier via worker task
  • New DB columns on suppliers: services_offered, contact_role, linkedin_url, instagram_url, youtube_url
  • supplier_enquiries table for tracking incoming enquiries from listing pages
  • Basic card variant in directory results — shows verified badge, logo, website, short description; sits between growth and free in sort order
  • Dashboard access for Basic tier — overview + listing tabs; leads/boosts tabs hidden; upgrade CTA in sidebar
  • Listing form: new fields — services offered (multi-select checkboxes), contact role, LinkedIn/Instagram/YouTube social links
  • Admin: Basic tier support — tier dropdown updated, new fields in supplier form and detail view, enquiry count shown

Changed

  • Supplier Growth monthly price adjusted to €199/mo (yearly: €150/mo billed at €1,799/yr)
  • Supplier Pro monthly price adjusted to €499/mo (yearly: €375/mo billed at €4,499/yr)
  • Directory sort order updated: pro → growth → basic → free
  • _supplier_required decorator now grants access to basic, growth, and pro tiers
  • New _lead_tier_required decorator restricts lead feed, unlock, and dashboard leads to growth/pro only

Fixed

  • Supplier detail page: locked quote CTA — "Request Quote" button is now visually disabled (greyed-out) for unverified/free-tier suppliers; clicking opens an inline popover explaining the limitation and linking to the general quote wizard instead
  • Supplier signup Paddle checkout — form now intercepts submit, fetches checkout config via JS, and opens Paddle.Checkout.open() overlay instead of displaying raw JSON in the browser
  • Credit balance OOB updates — sidebar and lead feed header credits now update instantly via HTMX OOB swaps after unlocking a lead (no page refresh)
  • Boosts page layout — capped main content column at 720px on wide screens; credit card grid uses auto-fill for graceful responsive adaptation

Added

  • Listing preview live update — form fields (name, tagline, description, website) trigger HTMX hx-get with 500ms debounce to update the directory card preview in real time; new /dashboard/listing/preview endpoint and extracted dashboard_listing_preview.html partial
  • Lead cards: full qualification data — unlocked cards now show decision process, prior supplier contact, financing help preference, with clear section headers (Project, Location & Timeline, Readiness, Contact) and human-readable enum labels; includes "View their plan" link to linked scenario
  • Lead feed search bar — text input with 300ms debounced HTMX filtering on country, facility type, and additional info; mirrors directory search pattern
  • Phone number mandatory in quote form — step 9 now requires phone with HTML required attribute and server-side validation
  • Supplier-aware dashboard redirect/dashboard/ checks if user has a claimed supplier with paid tier and redirects to supplier dashboard
  • Inline SVG logo — replaced PNG logo with inline SVG padel racket icon + Bricolage Grotesque wordmark in navbar and footer

Changed

  • Planner fonts — replaced Inter with DM Sans for body text, JetBrains Mono with Commit Mono for numeric values, added Bricolage Grotesque for planner header and wizard step titles; loaded Commit Mono via fontsource CDN
  • Migration system: single source of truth — eliminated dual-maintenance of schema.sql + versioned migrations; all databases (fresh and existing) now replay migrations in order starting from 0000_initial_schema.py; removed schema.sql, _is_fresh_db(), and the fresh-DB fast-path that skipped migration execution; migrate() accepts an optional db_path parameter for direct use in tests; test fixtures use cached migration replay instead of loading schema.sql directly; removed fragile _old_schema_sql() test helper and TestMigration0001 class; template repo updated to match (deleted 0001_roles_and_billing_customers.py, projects own their migrations)
  • Design system: Bricolage Grotesque + DM Sans — replaced Inter with Bricolage Grotesque (display headings) and DM Sans (body text); added --font-display theme variable; headings use display font via font-family: var(--font-display) in base layer; added --color-forest (#064E3B) to theme palette
  • Glass navbar — replaced opaque white navbar with semi-transparent backdrop-filter: blur(14px) frosted glass effect
  • Landing page: dark hero — navy background with radial blue glow, white text, green badge on dark, white ROI calculator card with stronger shadow; hero section is now full-width outside the container
  • Landing page: journey timeline — replaced 5 left-border cards with numbered step track (01-05) with connecting line, active/upcoming states; CSS grid 5-col desktop, stacks to horizontal layout on mobile
  • Landing page: dark CTA card — replaced plain white CTA section with rounded navy card with noise texture and white inverted button
  • Directory card tiers — pro cards get stronger green left border + subtle box-shadow glow and 48px logo; featured badges more prominent with box-shadow; free/unclaimed cards more visibly muted (lower opacity, lighter border)
  • Supplier dashboard sidebar icons — added inline SVG icons (chart, inbox, building, rocket) to sidebar navigation links
  • Supplier dashboard lead cards — added heat-color left borders (red/amber/gray by heat score) on .lf-card

Added

  • Admin sidebar navigation — new base_admin.html template with persistent sidebar (Overview, Leads, Suppliers, Users, Content, System sections); Heroicons inline SVGs for each nav item; active state via {% set admin_page %} in child templates; mobile: horizontal scroll nav; all 20 admin templates now extend base_admin.html
  • Admin dashboard section labels — stat card groups labeled "Lead Funnel" and "Supplier Funnel" with color-coded left borders (blue for leads, green for suppliers)

Fixed

  • Hardcoded Inter on supplier unlock button.lf-unlock-btn used font-family: 'Inter'; changed to inherit so it picks up DM Sans

Changed

  • Admin auth: password → RBAC — replaced ADMIN_PASSWORD env var and session-based password login with role-based access control; admin access is now granted via ADMIN_EMAILS env var (comma-separated); on login/dev-login, matching emails auto-receive the admin role; removed /admin/login and /admin/logout routes, admin_required decorator, and login.html template; all admin routes now use @role_required("admin") from auth/routes.py
  • Billing: separated billing identity from subscriptions — new billing_customers table stores provider_customer_id (was on subscriptions.paddle_customer_id); subscriptions table renamed paddle_subscription_idprovider_subscription_id and dropped UNIQUE constraint on user_id (allows multiple subscriptions per user); upsert_subscription now finds existing rows by provider_subscription_id instead of user_id; webhook handler calls upsert_billing_customer() for all subscription events
  • Eager-loaded user contextload_user() now JOINs billing_customers, user_roles, and latest subscription in a single query; adds g.subscription and is_admin template context variable (replaces session.get('is_admin'))

Added

  • RBAC decoratorsrole_required(*roles), subscription_required(plans, allowed), grant_role(), revoke_role(), ensure_admin_role() in auth/routes.py
  • user_roles table — stores user-role pairs with UNIQUE(user_id, role)
  • billing_customers table — stores provider customer ID per user
  • ADMIN_EMAILS config — parsed from comma-separated env var in core.py
  • Migration 0011 — adds user_roles and billing_customers tables, migrates paddle_customer_id data, recreates subscriptions table with provider_subscription_id column and no UNIQUE on user_id

Removed

  • ADMIN_PASSWORD env var and password-based admin authentication
  • /admin/login and /admin/logout routes
  • admin/templates/admin/login.html template
  • admin_required decorator (replaced by role_required("admin"))
  • subscription_required from billing/routes.py (replaced by version in auth/routes.py that reads from g.subscription)

Fixed

  • Webhook crash on null custom_data — Paddle sends "custom_data": null on lifecycle events (e.g. subscription.updated); .get("custom_data", {}) returns None when the key exists with a null value, causing AttributeError on the next .get() call; switched to or {} fallback; also guarded subscription.activated to skip when user_id is missing (was inserting user_id=0 causing FK violation), and applied same or {} to current_billing_period
  • Webhook signature verification uses SDK Verifier — replaced manual HMAC implementation with paddle_billing.Notifications.Verifier via a lightweight request wrapper; same algorithm, fewer moving parts

Added

  • Credit system test suite (tests/test_credits.py — 24 tests) — covers get_balance, add_credits, spend_credits, compute_credit_cost, already_unlocked, unlock_lead, monthly_credit_refill, get_ledger; tests run against real in-memory SQLite with no mocking
  • Supplier webhook test suite (tests/test_supplier_webhooks.py — 10 tests) — integration tests POSTing signed payloads to /billing/webhook/paddle and verifying DB state for credit pack purchases, sticky boosts, supplier subscription activation (growth/pro tiers, boost items, noop on missing supplier_id), and business plan PDF export

Removed

  • Dead test test_mobile_nav_no_overflow from test_visual.py — evaluated JS but never asserted the result; always passed regardless of overflow state

Fixed

  • Supplier logo missing on public directory — directory cards and supplier detail page only checked logo_url (external URL); now checks logo_file first (uploaded via dashboard), falling back to logo_url
  • Admin forward history links to wrong page — supplier name in lead forward history linked to /directory/ (public index) instead of /admin/suppliers/<id> (admin detail page)
  • Dashboard lead country filter drops heat/timeline state — hidden inputs used mismatched names (heat_hidden/timeline_hidden vs heat/timeline), silently resetting other filters when country changed
  • Directory pagination drops region filter — paginating after filtering by region lost the region parameter; added to all pagination link templates
  • boost_card_color missing from webhook handlerBOOST_PRICE_KEYS omitted boost_card_color, so purchasing the card color boost via Paddle would silently fail to create the supplier_boosts record; also removed stale boost_newsletter entry (replaced by card color boost)
  • Sticky boost DB operations not atomicboost_sticky_week and boost_sticky_month handlers issued two bare execute() calls (INSERT + UPDATE) without a transaction; wrapped in db_transaction() to prevent partial writes

Added

  • Clickable admin list rows — entire row is clickable on leads, suppliers, and users admin list pages (data-href + JS click handler that skips links/buttons/forms); pointer cursor and hover highlight via CSS
  • Supplier impersonation — "Impersonate Owner" button on admin supplier detail page; reuses existing admin.impersonate route, shown only when the supplier has a claimed_by user
  • Seed data: supplier owner accounts — each claimed supplier now gets its own user + active subscription for realistic impersonation testing

Fixed

  • Insufficient credits UX — unlock error now shows credit balance, cost, and a "Buy Credits" CTA linking to Boost & Upsells tab (was a collapsed red error message with no action)
  • Supplier dashboard sidenav — active tab highlight now updates on click (was only correct on initial page load)
  • Boost/credit buy buttons used wrong price IDs — dashboard boosts tab passed internal keys (boost_logo, credits_25) as Paddle priceId instead of resolved Paddle price IDs; buttons now show "Not configured" when Paddle products aren't set up
  • Listing preview card stretched full width — constrained to 420px to match actual directory card proportions
  • Impersonate redirects to supplier dashboard — impersonating a user who owns a supplier now lands on /suppliers/dashboard instead of the generic demand-side dashboard

Added — Programmatic SEO: Content Generation Engine

  • Database migration 0010published_scenarios, article_templates, template_data, articles tables with FTS5 full-text search on articles; content-sync triggers for INSERT/UPDATE/DELETE
  • Article template system — parameterized content recipes with input_schema (JSON field definitions), url_pattern, title_pattern, body_template (Markdown + Jinja2); supports multiple content types (only calculator built now)
  • Template data rows — per-city/region input data that feeds the generation pipeline; manual add or bulk CSV upload
  • Bulk generation pipelinePOST /admin/templates/<id>/generate computes financial scenarios from data rows via validate_state() + calc(), renders Markdown with baked scenario cards, writes static HTML to data/content/_build/, creates articles with staggered publish dates
  • Published scenarios — standalone financial widgets with pre-computed calc_json; embeddable in articles via [scenario:slug] markers with section variants (:capex, :operating, :cashflow, :returns, :full)
  • Scenario widget templates — 6 partial templates (summary, CAPEX breakdown, revenue & OPEX, 5-year projection, returns & financing, full combined) with dark navy header bar, Commit Mono numbers, responsive metric grids, and "Try with your own numbers" CTA
  • Static file rendering — articles rendered to data/content/_build/ as pre-baked HTML; DB stores metadata only, no body content in DB
  • Content blueprint — catch-all route serves published articles by url_path; registered last to avoid path collisions
  • Markets hub (/markets) — public search + country/region filter page for discovering articles; HTMX live filtering via FTS5 queries
  • Admin template CRUD — list, create, edit, delete article templates; view/add/upload/delete data rows; bulk generation form with start date and articles-per-day controls
  • Admin scenario CRUD — list, create (with curated calculator form), edit + recalculate, delete, preview all widget sections
  • Admin article management — list with status badges (draft/published/ scheduled), create manual articles (Markdown textarea), edit, delete, publish/unpublish toggle, rebuild single or all articles
  • Rebuild systemPOST /admin/articles/<id>/rebuild and /admin/rebuild-all re-render article HTML from source (template+data or markdown file) with fresh scenario calc_json
  • Article detail page — SEO meta tags (description, og:title, og:description, og:type=article, og:image, canonical), article typography styles, bottom CTA linking to planner
  • Scenario widget CSS.scenario-widget component styles in input.css with responsive table scroll and metric grid collapse; .article-body typography for headings, paragraphs, lists, blockquotes, tables, code blocks
  • slugify() utility — added to core.py for URL-safe slug generation
  • mistune dependency — Markdown → HTML rendering for articles
  • Sitemap/markets and all published articles added to /sitemap.xml (only articles with published_at <= now)
  • Footer — "Markets" link added to Product column
  • Admin dashboard — Templates, Scenarios, Articles quick-link buttons
  • Path collision preventionRESERVED_PREFIXES validation rejects article url_path values that conflict with existing routes

Added

  • Dev setup script (scripts/dev_setup.sh) — interactive bootstrap that checks prerequisites, installs deps, creates .env with auto-generated SECRET_KEY, runs migrations, seeds test data, optionally sets up Paddle sandbox products, and builds CSS
  • Dev run script (scripts/dev_run.sh) — resets DB, runs migrations, seeds data, builds CSS, then starts app + worker + CSS watcher in parallel with colored/labeled output and clean Ctrl-C shutdown; when Paddle is configured and ngrok is installed, starts a tunnel and updates the Paddle webhook destination automatically for end-to-end checkout testing
  • Paddle webhook auto-setupsetup_paddle.py now creates a notification destination in Paddle with the 5 event types the webhook handler processes, and writes PADDLE_WEBHOOK_SECRET to .env automatically
  • Resend test email docs — documented Resend test addresses (delivered@resend.dev, bounced@resend.dev, etc.) in .env.example and README for testing email flows without a verified domain

Fixed

  • Webhook signature verification brokenVerifier().verify() was called with raw bytes instead of a request object, causing all signed webhooks to fail with 400; replaced with manual HMAC verification matching Paddle's ts=<unix>;h1=<hmac> format; also added JSON parse error guard (400 instead of 500 on malformed payloads)
  • Billing tests stale after SDK migration — webhook tests used plain HMAC instead of Paddle's ts=...;h1=... signature format; checkout tests expected redirect instead of JSON overlay response; manage/cancel tests mocked httpx instead of Paddle SDK; removed stale PADDLE_PRICES config test (prices now in DB)
  • Quote wizard state loss_accumulated hidden input used " attribute delimiters which broke on tojson output containing literal " characters; switched all 8 step templates to single-quote delimiters (value='...')
  • Admin dashboard crashno such column: amount in credit ledger query; corrected to delta (the actual column name)
  • Admin supplier detail — credit ledger table referenced entry.entry_type and entry.amount instead of entry.event_type and entry.delta
  • Nav bar semi-transparent — replaced rgba(255,255,255,0.85) + backdrop-filter: blur() with opaque #ffffff background
  • Supplier pricing card buttons misaligned — added flexbox to .pricing-card with flex-grow: 1 on feature list and margin-top: auto on buttons

Changed

  • Newsletter boost removed — replaced with "Custom Card Color" boost (€19/mo) across supplier pricing page, setup_paddle.py, and supplier dashboard; directory cards with active card_color boost render a custom border color from supplier_boosts.metadata JSON
  • Removed "Zillow-style" comments from base.html and input.css

Added

  • Migration 0009 — adds metadata TEXT column to supplier_boosts for card color boost configuration
  • Admin supplier/lead CRUDGET/POST /admin/suppliers/new and GET/POST /admin/leads/new routes with form templates; "New Supplier" and "New Lead" buttons on admin list pages
  • Dev seed data script (scripts/seed_dev_data.py) — creates 5 suppliers (mix of tiers), 10 leads (mix of heat scores), 1 dev user, credit ledger entries, and lead forwards for local testing
  • Playwright quote wizard tests (tests/test_quote_wizard.py) — full 9-step flow, back-navigation data preservation, and validation error tests

Added — Phase 2: Scale the Marketplace — Supplier Dashboard + Business Plan PDF

  • Paddle.js overlay checkout — migrated all checkout flows (billing, supplier signup, business plan) from server-side Paddle transaction creation + redirect to client-side Paddle.Checkout.open() overlay; PADDLE_CLIENT_TOKEN config; Paddle.js script in base.html with sandbox/production toggle
  • Paddle products in DB — new paddle_products table replaces 16 PADDLE_PRICE_* env vars; get_paddle_price(key) and get_all_paddle_prices() async helpers in core.py; setup_paddle.py rewritten to write product/price IDs directly to database
  • Migration 0008paddle_products, business_plan_exports, feedback tables; logo_file and tagline columns on suppliers
  • Umami analytics — tracking script in base.html; config vars UMAMI_API_URL, UMAMI_API_TOKEN, UMAMI_WEBSITE_ID; directory click tracking redirect routes (/<slug>/website, /<slug>/quote)
  • Supplier dashboard (/suppliers/dashboard) — tab-based HTMX dashboard with sidebar nav (Overview, Lead Feed, My Listing, Boost & Upsells); each tab loads via hx-get with hx-push-url for deep-linking
    • Overview tab — 4 KPI stat cards (profile views, leads unlocked, credit balance, directory rank), new leads alert banner, recent activity feed
    • Lead feed tab — refactored _get_lead_feed_data() shared function with bidder count; heat/country/timeline filter pills; region matching badges; "No other suppliers yet — be first!" messaging
    • My Listing tab — preview card + inline edit form (company info, categories, service area, logo upload); POST /suppliers/dashboard/listing saves changes
    • Boost & Upsells tab — current plan, active boosts, available boosts with Paddle.Checkout.open() purchase buttons, credit packs grid, summary sidebar with visibility multiplier
  • Business plan PDF exportbusinessplan.py with WeasyPrint PDF engine; plan.html + plan.css A4 templates (executive summary, CAPEX, OPEX, revenue model, 5-year P&L, 12-month cash flow, sensitivity, key metrics); bilingual EN/DE; generate_business_plan worker task
  • Business plan routesGET /planner/export (options page with scenario picker + Paddle checkout), POST /planner/export/checkout, GET /planner/export/success, GET /planner/export/<id> (download); export CTA in planner sidebar
  • Supplier landing page enhanced — live stats from DB (business plans created, avg project value, suppliers listed, monthly leads); real anonymized lead preview cards (fallback to example data); credit explainer (hot=35, warm=20, cool=8); "Most Popular" badge on Growth plan; expanded FAQ (8 questions including credits, countries, cancellation); social proof section
  • Admin supplier managementGET /admin/suppliers with tier/country/name filters (HTMX search), GET /admin/suppliers/<id> detail with profile info, credit balance + ledger, active boosts, lead forward history; POST /admin/suppliers/<id>/credits manual credit adjustment; POST /admin/suppliers/<id>/tier manual tier change; supplier stats on admin dashboard (claimed, growth, pro, credits spent, leads forwarded)
  • Feedback widget — compact "Feedback" button in navbar opens HTMX popover with textarea; POST /feedback rate-limited (5/hr per IP), inserts into feedback table; GET /admin/feedback paginated admin view with user email
    • page URL

Added — Phase 1: Lead Operations + Builder Directory Monetization

  • SDK migration — replaced raw httpx calls with official paddle-python-sdk and resend SDKs for type safety, built-in webhook verification, and cleaner code; send_email() now accepts from_addr parameter; all Paddle API calls use SDK client
  • EMAIL_ADDRESSES dict — hardcoded transactional, leads, and nurture from-addresses sharing the notification.padelnomics.io Resend domain
  • Paddle product setup scriptscripts/setup_paddle.py creates all 14 products/prices programmatically via SDK; outputs .env snippet for CI
  • Claude Code skills.claude/skills/paddle-integration/SKILL.md and .claude/skills/resend-emails/SKILL.md for consistent SDK usage patterns
  • Migration 0007credit_ledger, lead_forwards, supplier_boosts tables; 12 new columns on suppliers (profile, credits); credit_cost and unlock_count on lead_requests
  • Credit system (credits.py) — get_balance, add_credits, spend_credits, unlock_lead, compute_credit_cost, monthly_credit_refill, get_ledger; InsufficientCredits exception; heat-based pricing (hot=35, warm=20, cool=8 credits); refill_monthly_credits worker task + scheduler
  • Admin lead managementGET /admin/leads with status/heat/country filters (HTMX search), GET /admin/leads/<id> detail with project brief + forward history, POST /admin/leads/<id>/status update, POST /admin/leads/<id>/forward manual forward (no credit cost); lead funnel stats on admin dashboard (planner users → leads → verified → unlocked)
  • Lead forwarding emailssend_lead_forward_email worker task sends full project brief + contact details to supplier; send_lead_matched_notification notifies entrepreneur when a supplier unlocks their lead
  • Credit cost computed on submissioncredit_cost set from heat score both on verified-user submission and on email verification
  • Supplier signup wizard (/suppliers/signup) — 4-step HTMX wizard: plan selection (Growth €149/mo, Pro €399/mo), boost add-ons (logo, highlight, verified, newsletter), credit packs (25-250), account details + order summary; builds multi-item Paddle transaction; _accumulated hidden JSON pattern
  • Supplier claim flowGET /suppliers/claim/<slug> verifies unclaimed and redirects to signup with pre-fill
  • Webhook handlerssubscription.activated with supplier_* plan creates supplier record with tier, credits, and boosts; transaction.completed handles credit pack purchases and sticky boost purchases with expiry
  • Supplier profile page (/directory/<slug>) — public profile with logo, verified badge, description, service categories as pills, service area, years in business, project count, website; "Request Quote" and "Claim This Listing" CTAs
  • Directory card links — all directory cards now link to supplier profile pages; paid-tier cards show "Request Quote" mini-CTA
  • Supplier lead feed (/suppliers/leads) — requires login + paid supplier tier; shows anonymized lead cards with heat badge, facility type, courts, country, timeline, budget range, credit cost, unlock count; POST /suppliers/leads/<id>/unlock spends credits, creates lead_forward, sends emails, returns full-details card via HTMX swap
  • Email nurture via Resend Audiences — on first scenario save, user is added to "Planner Users" audience (triggers 3-email automation); on quote submission, user is removed from audience (stops nurture)
  • PADDLE_PRICES expanded — 13 new price keys for supplier plans, boosts, credit packs; PADDLE_ENVIRONMENT config for sandbox/production switching

Changed

  • Supplier marketing page CTAs link to /suppliers/signup instead of mailto
  • httpx removed from direct dependencies (transitive via paddle SDK)

Added

  • Double opt-in email verification for quote requests — guest quote submissions now require email verification before the lead goes live; verification click also creates a user account and logs them in automatically (GDPR-friendly consent trail)
  • GET /leads/verify route — validates token, activates lead (pending_verificationnew, sets verified_at), logs user in, sends admin notification and welcome email
  • send_quote_verification worker task — branded verification email with project details and "Verify & Activate Quote" CTA button (DEBUG mode prints link to console)
  • quote_verify_sent.html template — "Check your email" page shown after guest quote submission
  • Migration 0006 — adds verified_at TEXT column to lead_requests
  • 9 new tests in TestQuoteVerification class covering the full verification flow, expired tokens, duplicate verification, and user creation

Changed

  • Inline CTA full copy — mobile/narrow-screen inline quote CTA now matches sidebar: "Next Step" label, full title, description, 4 checkmark benefits, "Get Supplier Quotes" button, and "Takes ~2 minutes" hint
  • Signup bar simplified — removed × close button from guest signup bar; now a non-dismissable nudge (still only shown on results tabs via JS)
  • Investment tab narrower — CAPEX tab content constrained to 800px max-width so 3-column card grid, table, and chart don't stretch across full 1100px on wide screens

Changed

  • Quote form → standalone 9-step HTMX wizard — extracted "Get Quotes" from planner Step 5 into a standalone multi-step wizard at /leads/quote using server-rendered HTMX partials; each step validates server-side and swaps via hx-post/hx-get with OOB dot progress updates; accumulated state passed forward as hidden JSON field (no JS state management)
  • Planner reduced to 4 steps — removed embedded quote form (Step 5) from planner wizard; Step 4 "Get Quotes →" now navigates to /leads/quote with pre-filled params (venue, courts, glass, lighting, country, budget)
  • Planner sidebar CTA — "Get Supplier Quotes" button now links to standalone quote wizard instead of scrolling to embedded Step 5; sidebar now visible on all tabs including assumptions (was results-only)
  • Sticky wizard nav — planner preview bar (CAPEX/CF/IRR) and back/next buttons now stick to the bottom of the viewport so users don't have to scroll to navigate between steps
  • Mobile quote CTA — inline "Get Quotes" card shown below main content on screens narrower than 1400px (where the fixed sidebar is hidden)
  • Step 4 → "Show Results" — final planner wizard step now says "Show Results" instead of "Get Quotes" since quote flow is a separate standalone wizard
  • Removed "2-5 suppliers" cap language — replaced specific supplier count promises with "matched suppliers" across landing page, supplier FAQ, planner sidebar, and quote form privacy box

Removed

  • Inline quote form from planner (Step 5 HTML, #wizSuccess, hidden inputs)
  • populateWizAutoFill(), submitQuote(), COUNTRY_NAMES from planner.js
  • __PADELNOMICS_QUOTE_URL__ JS variable from planner template
  • Step 5 scoped CSS (~155 lines): #wizQuoteForm, .wiz-autofill-summary, .wiz-input, .wiz-privacy-box, .consent-group, .wiz-success, .wiz-signup-nudge, .wiz-checkbox-label

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 testtest_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 endpointPOST /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 calculatorcountry 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 variablesbudgetTarget (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 scoringcalculate_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

  • 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

Changed

  • Pico CSS → Tailwind CSS v4 — full design system migration across all templates (except planner, which keeps its own CSS)
    • Standalone Tailwind CLI binary (no Node.js) with make css-build / make css-watch
    • Court Tech brand theme: navy/charcoal/electric/accent color palette
    • Component classes (.btn, .card, .form-input, .table, .badge, .flash, etc.) in input.css for consistent styling
    • Self-hosted Commit Mono font (replaces JetBrains Mono) for monospace data display
    • Docker multi-stage build: CSS compiled in dedicated stage before Python build
  • Landing page teaser calculator restyled with Tailwind utilities and brand colors

Removed

  • Pico CSS CDN dependency
  • custom.css (replaced by Tailwind input.css with @layer components)
  • JetBrains Mono font (replaced by self-hosted Commit Mono)

Fixed

  • Empty env vars (e.g. SECRET_KEY=) now fall back to defaults instead of silently using "" — fixes 500 on every request when .env has blank values

Added

  • Comprehensive migration test suite (tests/test_migrations.py — 20 tests) covering fresh DB, existing DB, up-to-date DB, idempotent migration, version discovery, _is_fresh_db, migration 0001 correctness, and ordering
  • Expanded migrate.py module docstring documenting the 8-step algorithm, protocol for adding migrations, and design decisions
  • Sequential migration system (migrations/migrate.py) — tracks applied versions in _migrations table, auto-detects fresh vs existing DBs, runs pending migrations in order
  • migrations/versions/0001_rename_ls_to_paddle.py — first versioned migration (absorbed from scripts/migrate_to_paddle.py)
  • Server-side financial calculator (planner/calculator.py) — ported JS calc(), pmt(), calcIRR() to Python so the full financial model is no longer exposed in client-side JavaScript
  • POST /planner/calculate endpoint for server-side computation
  • Pre-computed initial data (window.__PADELNOMICS_INITIAL_D__) injected on page load for instant first render
  • Debounced API fetch pattern in planner.js with AbortController for in-flight request cancellation
  • Computing indicator CSS (.planner-app--computing) with subtle "computing..." text
  • Comprehensive test suite for calculator (tests/test_calculator.py — 227 tests) covering all 4 venue/ownership combos, edge cases, and Hypothesis property-based fuzzing
  • Comprehensive billing test suite (371 tests total):
    • tests/conftest.py — shared fixtures (DB, app, clients, subscriptions, webhook helpers)
    • tests/test_billing_helpers.py — unit tests for SQL helpers, feature/limit access, plan determination (60+ tests + parameterized + Hypothesis)
    • tests/test_billing_webhooks.py — integration tests for LemonSqueezy webhooks (signature verification, all lifecycle events, Hypothesis fuzzing)
    • tests/test_billing_routes.py — route tests (pricing, checkout, manage, cancel, resume, subscription_required decorator)
    • Added hypothesis>=6.100.0 and respx>=0.22.0 to dev dependencies for property-based testing and httpx mocking
    • Factored into Copier template — all billing tests now generate as .jinja templates with provider-specific conditionals for Stripe, Paddle, and LemonSqueezy
  • GitLab CI/CD pipeline (.gitlab-ci.yml) — runs pytest + ruff on master/MRs, auto-deploys on master
  • Blue-green deployment with Docker Compose profiles (docker-compose.prod.yml, deploy.sh)
    • nginx router on port 5000 proxies to active blue/green slot
    • Zero-downtime: new slot health-checked before traffic switch
    • Automatic rollback on failed health check

Removed

  • scripts/migrate_to_paddle.py — superseded by versions/0001_rename_ls_to_paddle.py

Changed

  • planner.js no longer contains calc(), pmt(), or calcIRR() functions — computation moved server-side
  • render() split into render() (tab switching + schedule calc) and renderWith(d) (DOM updates from data)
  • Tab switching now renders from _lastD cache (instant, no API call)
  • Slider input triggers 200ms debounced server call instead of synchronous client-side calc