# Changelog

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

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed
- **pSEO error details collapse** — clicking "Error" on a job row expanded the details, but they collapsed after ~2s because HTMX polling replaced the `<tr>`. Jobs with errors now stop polling, keeping the `<details>` element stable.
- **UNIQUE constraint on article slug** — `ON CONFLICT(url_path, language)` upsert failed because a separate single-column `UNIQUE` on `slug` fired first. Migration 0030 drops the redundant `UNIQUE` from `slug` (keeps the index for lookups and the composite `UNIQUE(url_path, language)`).

### Changed
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
- **Single-score simplification** — consolidated two public-facing scores (Market Score + Opportunity Score) into one **Padelnomics Score** (internally: `opportunity_score`). All maps, tooltips, article templates, and the methodology page now show a single score. Dual-ring markers reverted to single-color markers. `/market-score` route renamed to `/padelnomics-score` (old URL 301-redirects). All `mscore_*` i18n keys replaced with `pnscore_*`. Business plan queries `opportunity_score` from `location_profiles` (replaces legacy `city_market_overview` view). Map tooltip strings now i18n'd via `window.__MAP_T` (12 keys, EN + DE).

### Fixed
- **Non-Latin city names on map** — GeoNames entries with CJK/Cyrillic/Arabic characters (e.g. "Seelow" showing Japanese) now filtered in `stg_population_geonames` via Latin-only regex.
- **Score range safety** — `location_profiles` clamps both scores to 0-100 via `LEAST/GREATEST`.
- **Pipeline cast fix** — `venue_pricing_benchmarks.sql` defensively casts `snapshot_date` VARCHAR to DATE.

### Changed
- **Dual-ring map markers** — map markers now encode two scores visually: inner core = primary score, outer ring = secondary score. Markets hub and country overview show Market Score (core) + Opportunity Score (ring). Opportunity map shows Opportunity Score (core) + Market Score (ring). City venue maps unchanged (navy dots). Color scale upgraded from 3-tier (green/amber/red) to 5-tier (deep green ≥80, teal ≥60, amber ≥40, orange-red ≥20, red <20) with distinct luminance at each tier for colorblind safety. Markers < 18px fall back to single-layer (no ring). Muted markers (cities without articles) show dashed ring outline. Highlighted markers (user's geo city) get blue outer glow. Opportunity map markers with score ≥75 pulse gently to highlight top investment targets. Tooltip lines now have inline color dots matching marker layers. All map scripts share a single `map-markers.js` module (`PNMarkers.scoreColor` + `PNMarkers.makeIcon`), replacing 3 duplicated implementations.

### Added
- **Geo headers on city/region hubs** — Cloudflare geo headers (`CF-IPCountry`, `CF-IPCity`) now used across location-based pages. Opportunity map pre-selects and auto-loads the user's country. Country overview maps highlight the user's city with a blue ring (best-effort CF-IPCity name match). `window.__GEO` JS global injected via `base.html` for client-side map code.

### Fixed
- **Opportunity map color scale** — low-score bubbles used blue (`#3B82F6`) instead of red (`#DC2626`), inconsistent with the unified `scoreColor()` scale used everywhere else. Fixed in `oppColor()`, legend, and `article-maps.js` tooltip colors. Thresholds aligned to ≥60/30/\<30.

### Changed
- **Opportunity Score v5 → v6** — calibrates for saturated markets (Spain avg dropped from ~78 to ~50-60 range). Density ceiling lowered from 8 → 5/100k (Spain at 6-16/100k now hits zero-gap). Supply deficit weight increased from 35 → 40 pts. Addressable market reduced from 25 → 20 pts. Market validation inverted → "market headroom": high country avg maturity now reduces opportunity (saturated market = less room for new entrants).
- **Markets page map legend** — bubble map now has a visual legend explaining size = venue count, color = Market Score. Opportunity score tooltip color unified to same green/amber/red scale (was using blue for low scores, inconsistent).
- **Geo-localized article sorting** — `/markets` page sorts articles by user proximity using Cloudflare CF-IPCountry header. User's country first, nearby countries second (DACH, Iberia, Nordics, etc.), rest by published_at. Map bubbles re-ordered so user's country renders on top. Falls back to chronological order when header is absent (local dev).
- **Score v6: Global economic data** — `dim_countries.median_income_pps` and `pli_construction` now cover all target markets, not just EU. World Bank WDI indicators (GNI per capita PPP + price level ratio) fill gaps for non-EU countries (AR, MX, AE, AU, etc.) with values calibrated to the Eurostat scale using Germany as anchor. EU countries keep exact Eurostat values. New extractor (`worldbank.py`), staging model (`stg_worldbank_income`), and `dim_countries` fallback CTEs. No changes to scoring formulas — the fix is upstream in the data layer.
- **Market Score v3 → v4** — fixes Spain averaging 54 (should be 65-80). Four calibration changes: count gate threshold lowered from 5 → 3 venues (3 establishes a market pattern), density ceiling lowered from LN(21) → LN(11) (10/100k is reachable for mature markets), demand evidence fallback raised from 0.4 → 0.65 multiplier with 0.3 floor (existence of venues IS evidence of demand), economic context ceiling changed from income/200 → income/25000 (actual discrimination instead of free 10 pts for everyone).
- **Opportunity Score v4 → v5** — fixes structural flaws: supply gap (30pts) + catchment gap (15pts) merged into single supply deficit (35pts, GREATEST of density gap and distance gap) eliminating ~80% correlated double-count. New sports culture signal (10pts) using tennis court density as racquet-sport adoption proxy. New construction affordability signal (5pts) using income relative to PLI construction costs from `dim_countries`. Economic power reduced from 20 → 15pts. New dependency on `foundation.dim_countries` for `pli_construction`.

- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
- **Marktpotenzial-Score v3: H3 catchment lens** — addressable market (25pts) and supply gap (30pts) now use a regional H3 catchment (~15-18km radius, res-4 cell + 6 neighbours, ~462km²) instead of local city population and 5km court count. Mid-size cities surrounded by dense Gemeinden (e.g. Oldenburg) now score correctly. New output columns: `catchment_population`, `catchment_padel_courts`, `catchment_venues_per_100k`. Requires one-time `INSTALL h3 FROM community` in DuckDB on each machine.

### Added
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.

### Fixed
- **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise).
- **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint.
- **Silent 500 errors in dev** — `dev_run.sh` used Granian which swallowed Quart's debug error pages, showing generic "Internal Server Error" with no traceback. Switched to `uv run python -m padelnomics.app` for proper debug mode with browser tracebacks. Added `@app.errorhandler(500)` to log exceptions even when running under Granian in production.
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.

### Added
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
- **Running state indicator** — extraction cards show a spinner + "Running" label with a blue-tinted border when an extraction is actively running, replacing the plain Run button. Cards also display the start time with "running..." text.

- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
  - **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
  - **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
  - **City cost articles**: venue dot map centered on city lat/lon (zoom 13), navy dots per venue with tooltip showing name + court breakdown (indoor/outdoor).
  - **Opportunity map** (`/<lang>/opportunity-map`): standalone full-width page with country selector. Circles sized by population, colored by opportunity score (green ≥ 70, amber 40-70, blue < 40). Existing venues shown as gray reference dots.
  - New `/api` blueprint with 4 JSON endpoints (`/api/markets/countries.json`, `/api/markets/<country>/cities.json`, `/api/markets/<country>/<city>/venues.json`, `/api/opportunity/<country>.json`) — 1-hour public cache headers, all queries against `analytics.duckdb` via `fetch_analytics`.
  - New SQLMesh serving model `city_venue_locations` exposing venue lat/lon + court counts per city.
  - `pseo_city_costs_de` serving model: added `lat`/`lon` columns for city map data attributes in baked articles.
  - Leaflet CSS included on all article pages (5KB, cached). JS loaded dynamically only when a map container is present.
- **Individualised article financial calculations with real per-country cost data** — ~30 CAPEX/OPEX calculator fields now scale to each country's actual cost level via Eurostat data, eliminating the identical DE-hardcoded numbers shown for every city globally.
  - **New Eurostat datasets extracted** (8 new landing files): electricity prices (`nrg_pc_205`), gas prices (`nrg_pc_203`), labour costs (`lc_lci_lev`), and 5 price level index categories from `prc_ppp_ind` (construction, housing, services, misc, government).
  - `extract/padelnomics_extract/src/padelnomics_extract/eurostat.py`: added 8 dataset entries; added `dataset_code` field support so multiple dict entries can share one Eurostat API endpoint (needed for 5 prc_ppp_ind variants).
  - **4 new staging models**: `stg_electricity_prices`, `stg_gas_prices`, `stg_labour_costs`, `stg_price_levels` — all read from landing zone with ISO code normalisation (EL→GR, UK→GB).
  - **New `foundation.dim_countries`** — conformed country dimension (grain: `country_code`). Consolidates country names/slugs and income data previously duplicated in `dim_cities` and `dim_locations` as ~50-line CASE blocks. Computes ~29 calculator cost override columns from Eurostat PLI indices and energy prices relative to DE baseline.
  - **Refactored `dim_cities`** — removed ~50-line CASE blocks and `country_income` CTE; JOIN `dim_countries` for `country_name_en`, `country_slug`, `median_income_pps`, `income_year`.
  - **Refactored `dim_locations`** — same refactor as `dim_cities`; income cascade still cascades EU NUTS-2 → US state → `dim_countries` country-level.
  - **Updated `serving.pseo_city_costs_de`** — JOIN `dim_countries`; 29 new camelCase override columns (`electricity`, `heating`, `rentSqm`, `hallCostSqm`, …, `permitsCompliance`) auto-applied by calculator.
  - **Updated `serving.planner_defaults`** — JOIN `dim_countries`; same 29 cost columns flow through to the planner API `/api/market-data` endpoint.
- **Bulk actions for articles and leads** — checkbox selection + floating action bar on admin articles and leads pages (same pattern as suppliers). Articles: publish, unpublish, toggle noindex, rebuild, delete. Leads: set status, set heat. Re-renders results via HTMX after each action.
- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active.
  - `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing)
  - `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module
  - `billing/routes.py`: provider-agnostic dispatch layer — checkout, manage, cancel routes call `_provider().xxx()`
  - `_payment_js.html`: dual-path JS — conditionally loads Paddle.js SDK, universal `startCheckout()` handles both overlay (Paddle) and redirect (Stripe)
  - `scripts/setup_stripe.py`: mirrors `setup_paddle.py` — creates 17 products + prices in Stripe, registers webhook endpoint
  - Migration 0028: `payment_products` table generalizing `paddle_products` with `provider` column; existing Paddle rows copied
  - `get_price_id()` / `get_all_price_ids()` replace `get_paddle_price()` for provider-agnostic lookups
  - Stripe config vars: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`
  - Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint
  - Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard)

### Fixed
- **City slug transliteration** — replaced broken inline `REGEXP_REPLACE(LOWER(...), '[^a-z0-9]+', '-')` with new `@slugify` SQLMesh macro that uses `STRIP_ACCENTS` + `ß→ss` pre-replacement. Fixes: `Düsseldorf` → `dusseldorf` (was `d-sseldorf`), `Überlingen` → `uberlingen` (was `-berlingen`). Applied to `dim_venues`, `dim_cities`, `dim_locations`. Python `slugify()` in `core.py` updated to match.
- **B2B article market links** — added missing language prefix (`/markets/germany` → `/de/markets/germany` and `/en/markets/germany`). Without the prefix, Quart interpreted `markets` as a language code → 500 error.
- **Country overview top-5 city list** — changed ranking from raw `market_score DESC` (which inflated tiny towns with high density scores) to `padel_venue_count DESC` for top cities and `population DESC` for top opportunity cities. Germany now shows Berlin, Hamburg, München instead of Überlingen, Schwaigern.

### Changed
- **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster."
  - **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
  - **Proof strip**: live stats bar below hero (business plans created, suppliers, countries, project volume)
  - **"Sound familiar?" section**: replaces the 5-step journey timeline (3 items said "SOON") with 4 struggling-moment cards from JTBD research
  - **Feature cards reframed as outcomes**: "60+ Variables" → "Know Your Numbers Inside Out", "6 Analysis Tabs" → "Bank-Ready from Day One", "Sensitivity Analysis" → "Stress-Test Before You Commit", etc.
  - **"Why Padelnomics" comparison**: 3-column section (DIY Spreadsheet vs. Hired Consultant vs. Padelnomics) from JTBD Competitive Job Map
  - **FAQ rewritten**: customer-first questions ("How much does it cost to open a padel facility?", "Will a bank accept this?") replace product-internal questions
  - **Final CTA**: "Your Bank Meeting Is Coming. Be Ready." replaces generic "Start Planning Today"
  - **Supplier page**: "Is this your sales team?" struggling-moments section, conditional stats display (hides zeros), data-backed proof points replacing anonymous testimonials, ROI math moved above pricing, tier-specific CTAs
  - **Meta/SEO**: updated page title and description for search intent
  - All changes in both EN and DE (native-quality German, generisches Maskulinum)

### Fixed
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
  - `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
  - `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
  - `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
  - `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
  - `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
  - `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
  - CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles


- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.

### Changed
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
  - **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
  - **`_forward_lead()` helper** (`web/admin/routes.py`): extracts shared DB logic from `lead_forward` and `lead_forward_htmx` — both routes now call the helper and differ only in response format.
  - **SQLMesh macros** (`transform/macros/__init__.py`): 5 new macros compress repeated country code patterns across 7 SQL models: `@country_name`, `@country_slug`, `@normalize_eurostat_country`, `@normalize_eurostat_nuts`, `@infer_country_from_coords`.
  - **Extract helpers** (`extract/utils.py`): `skip_if_current()` compresses cursor-check + early-return pattern (3 extractors); `write_jsonl_atomic()` compresses working-file → JSONL → compress pattern (2 extractors).
- **Coding philosophy updated** (`~/.claude/coding_philosophy.md`): added `<compression>` section documenting the workflow, the test ("Did this abstraction make the total codebase smaller?"), and distinction from premature DRY.
- **Test suite compression pass** — applied same compression workflow to `web/tests/` (30 files, 13,949 lines). Net result: -197 lines across 11 files.
  - **`admin_client` fixture** lifted from 7 duplicate definitions into `conftest.py`.
  - **`mock_send_email` fixture** added to `conftest.py`, replacing 60 inline `with patch("padelnomics.worker.send_email", ...)` blocks across `test_emails.py` (51), `test_waitlist.py` (4), `test_businessplan.py` (2). Each refactored test drops one indentation level.

### Fixed
- **Admin: empty confirm dialog on auto-poll** — `htmx:confirm` handler now guards with `if (!evt.detail.question) return` so auto-poll requests (`hx-trigger="every 5s"`, no `hx-confirm` attribute) no longer trigger an empty dialog every 5 seconds.

### Changed
- **Admin: styled confirm dialog for all destructive actions** — replaced all native `window.confirm()` calls with the existing `#confirm-dialog` styled `<dialog>`. A new global `htmx:confirm` handler intercepts HTMX confirmation prompts and shows the dialog; form-submit buttons on affiliate pages were updated to use `confirmAction()`. Affected: pipeline Transform tab (Run Transform, Run Export, Run Full Pipeline), pipeline Overview tab (Run extractor), affiliate product delete, affiliate program delete (both form and list variants).
- **Pipeline tabs: no scrollbar** — added `scrollbar-width: none` and `::-webkit-scrollbar { display: none }` to `.pipeline-tabs` to suppress the spurious horizontal scrollbar on narrow viewports.

### Fixed
- **Stale-tier failures no longer exhaust the next proxy tier** — with parallel workers, threads that fetched a proxy just before tier escalation reported failures after the tier changed, immediately blowing through the new tier's circuit breaker before it ever got tried (Rayobyte was skipped entirely). `record_failure(proxy_url)` now checks which tier the proxy belongs to and ignores the circuit breaker when the proxy is from an already-escalated tier.

- **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry.

### Changed
- **Unified confirm dialog — pure HTMX `hx-confirm` + `<form method="dialog">`** — eliminated the `confirmAction()` JS function and the duplicate `cloneNode` hack. All confirmation prompts now go through a single `showConfirm()` Promise-based function called by the `htmx:confirm` interceptor. The dialog HTML uses `<form method="dialog">` for native close semantics (`returnValue` is `"ok"` or `"cancel"`), removing the need to clone and replace buttons on every invocation. All 12 Padelnomics call sites converted from `onclick=confirmAction(...)` to `hx-boost="true"` + `hx-confirm="..."` on the submit button. Pipeline trigger endpoints updated to treat `HX-Boosted: true` requests as non-HTMX (returning a redirect rather than an inline partial) so boosted form submissions flow through the normal redirect cycle. Same changes applied to BeanFlows and the quart-saas-boilerplate template.
  - `web/src/padelnomics/admin/templates/admin/base_admin.html`: replaced dialog `<div>` with `<form method="dialog">`, replaced `confirmAction()` + inline `htmx:confirm` handler with unified `showConfirm()` + single `htmx:confirm` listener
  - `web/src/padelnomics/admin/pipeline_routes.py`: `pipeline_trigger_extract` and `pipeline_trigger_transform` now exclude `HX-Boosted: true` from the HTMX partial path
  - 12 templates updated: `pipeline.html`, `partials/pipeline_extractions.html`, `affiliate_form.html`, `affiliate_program_form.html`, `partials/affiliate_program_results.html`, `partials/affiliate_row.html`, `generate_form.html`, `articles.html`, `audience_contacts.html`, `template_detail.html`, `partials/scenario_results.html`
  - Same changes mirrored to BeanFlows and quart-saas-boilerplate template

- **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring.
  - `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count`
  - `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure`
  - `extract/padelnomics_extract/src/padelnomics_extract/playtomic_availability.py`: `_worker` returns `(proxy_url, result)` tuple; serial loops in `extract` and `extract_recheck` capture `proxy_url` before passing to `record_success`/`record_failure`
  - `web/tests/test_supervisor.py`: 11 new tests in `TestTieredCyclerDeadProxyTracking` covering dead proxy skipping, auto-escalation, `dead_proxy_count`, backward compat, and thread safety

### Added
- **Visual upgrades for longform articles** — 4 reusable CSS article components added to `input.css` and applied across 6 cornerstone articles (EN + DE):
  - `article-timeline`: horizontal numbered phase diagram with connecting lines; collapses to vertical stack on mobile. Replaces ASCII art code blocks in build guide articles.
  - `article-callout` (warning/tip/info variants): left-bordered callout box with icon, title, and body. Replaces `>` blockquotes and bold-text warnings in build and risk guides.
  - `article-cards`: 2-column card grid with colored accent bars (success/failure/neutral/established/growth/emerging). Replaces sequential bold-text pattern paragraphs in build, risk, and location guides.
  - `severity` pills: inline colored badge for High/Medium-High/Medium/Low-Medium/Low. Applied to risk overview tables in both risk guide articles.
  - Articles updated: `padel-hall-build-guide-en`, `padel-halle-bauen-de`, `padel-hall-investment-risks-en`, `padel-halle-risiken-de`, `padel-hall-location-guide-en`, `padel-standort-analyse-de`

- **Pipeline Transform tab + live extraction status** — new "Transform" tab in the pipeline admin with status cards for SQLMesh transform and export-serving tasks, a "Run Full Pipeline" button, and a recent run history table. The Overview tab now auto-polls every 5 s while an extraction task is pending and stops automatically when quiet. Per-extractor "Run" buttons use HTMX in-place updates instead of redirects. The header "Run Pipeline" button now enqueues the full ELT pipeline (extract → transform → export) instead of extraction only. Three new worker task handlers: `run_transform` (sqlmesh plan prod --auto-apply, 2 h timeout), `run_export` (export_serving.py, 10 min timeout), `run_pipeline` (sequential, stops on first failure). Concurrency guard prevents double-enqueuing the same step.
  - `web/src/padelnomics/worker.py`: `handle_run_transform`, `handle_run_export`, `handle_run_pipeline`
  - `web/src/padelnomics/admin/pipeline_routes.py`: `_render_overview_partial()`, `_fetch_pipeline_tasks()`, `_format_duration()`, `pipeline_transform()`, `pipeline_trigger_transform()`; `pipeline_trigger_extract()` now HTMX-aware
  - `web/src/padelnomics/admin/templates/admin/pipeline.html`: pulse animation on `.status-dot.running`, Transform tab button, rewired header button
  - `web/src/padelnomics/admin/templates/admin/partials/pipeline_overview.html`: self-polling wrapper, HTMX Run buttons
  - `web/src/padelnomics/admin/templates/admin/partials/pipeline_transform.html`: new file

- **Affiliate programs management** — centralised retailer config (`affiliate_programs` table) with URL template + tracking tag + commission %. Products now use a program dropdown + product identifier (e.g. ASIN) instead of manually baking full URLs. URL is assembled at redirect time via `build_affiliate_url()`, so changing a tag propagates instantly to all products. Legacy products (baked `affiliate_url`) continue to work via fallback. Amazon OneLink configured in the Associates dashboard handles geo-redirect to local marketplaces — no per-country programs needed.
  - `web/src/padelnomics/migrations/versions/0027_affiliate_programs.py`: `affiliate_programs` table, nullable `program_id` + `product_identifier` columns on `affiliate_products`, seeds "Amazon" program, backfills ASINs from existing URLs
  - `web/src/padelnomics/affiliate.py`: `get_all_programs()`, `get_program()`, `get_program_by_slug()`, `build_affiliate_url()`; `get_product()` JOINs program for redirect assembly; `_parse_product()` extracts `_program` sub-dict
  - `web/src/padelnomics/app.py`: `/go/<slug>` uses `build_affiliate_url()` — program-based products get URLs assembled at redirect time
  - `web/src/padelnomics/admin/routes.py`: program CRUD routes (list, new, edit, delete — delete blocked if products reference the program); product form updated to program dropdown + identifier; `retailer` auto-populated from program name
  - New templates: `admin/affiliate_programs.html`, `admin/affiliate_program_form.html`, `admin/partials/affiliate_program_results.html`
  - Updated templates: `admin/affiliate_form.html` (program dropdown + JS toggle), `admin/base_admin.html` (Programs subnav tab)
  - 15 new tests in `web/tests/test_affiliate.py` (41 total)

### Fixed
- **Data Platform admin view showing stale/zero row counts** — Docker web containers were mounting `/opt/padelnomics/data` (stale copy) instead of `/data/padelnomics` (live supervisor output). Fixed volume mount in all 6 containers (blue/green × app/worker/scheduler) and added `LANDING_DIR=/app/data/pipeline/landing` so extraction stats and landing zone file stats are visible to the web app.
- **`workflows.toml` never found in dev** — `_REPO_ROOT` in `pipeline_routes.py` used `parents[5]` (one level too far up) instead of `parents[4]`. Workflow schedules now display correctly on the pipeline overview tab in dev.
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.

### Added
- **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/<slug>` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded.
  - `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema
  - `web/src/padelnomics/affiliate.py`: `get_product()`, `get_products_by_category()`, `get_all_products()`, `log_click()`, `hash_ip()`, `get_click_stats()`, `get_click_counts()`, `get_distinct_retailers()`
  - `web/src/padelnomics/content/routes.py`: `PRODUCT_RE`, `PRODUCT_GROUP_RE`, `bake_product_cards()` — chained after `bake_scenario_cards()` in `generate_articles()` and `preview_article()`
  - `web/src/padelnomics/app.py`: `/go/<slug>` route with rate limiting (60/min per IP) and referer-based article/language extraction
  - `web/src/padelnomics/admin/routes.py`: affiliate CRUD routes + `bake_product_cards()` chained in article rebuild flows
  - New templates: `partials/product_card.html`, `partials/product_group.html`, `admin/affiliate_products.html`, `admin/affiliate_form.html`, `admin/affiliate_dashboard.html`, `admin/partials/affiliate_results.html`, `admin/partials/affiliate_row.html`
  - `locales/en.json` + `locales/de.json`: 6 new affiliate i18n keys
  - `data/content/articles/`: 10 new German equipment review scaffolds (rackets, balls, shoes, accessories, gifts)
  - 26 tests in `web/tests/test_affiliate.py`

### Added
- **Three-tier proxy system** for extraction pipeline: free (Webshare auto-fetched) → datacenter (`PROXY_URLS_DATACENTER`) → residential (`PROXY_URLS_RESIDENTIAL`). Webshare free proxies are now auto-fetched from their download API on each run — no more manually copying stale proxy lists.
  - `proxy.py`: added `fetch_webshare_proxies()` (stdlib urllib, bounded read + timeout), `load_proxy_tiers()` (assembles N tiers from env), generalised `make_tiered_cycler()` to accept `list[list[str]]` with N-level escalation. Exposes `is_exhausted()`, `active_tier_index()`, `tier_count()`.
  - `playtomic_availability.py`: both `extract()` and `extract_recheck()` now use `load_proxy_tiers()` + N-tier cycler. `_fetch_venues_parallel` `fallback_urls` param removed. `is_fallback_active()` replaced by `is_exhausted()`.
  - `playtomic_tenants.py`: uses `load_proxy_tiers()` flattened for simple round-robin.

### Changed
- **Env vars renamed** (breaking): `PROXY_URLS` → removed, `PROXY_URLS_FALLBACK` → removed. New vars: `WEBSHARE_DOWNLOAD_URL`, `PROXY_URLS_DATACENTER`, `PROXY_URLS_RESIDENTIAL`.

### Added
- **Phase 2a — NUTS-1 regional income differentiation** (`opportunity_score`): Munich and Berlin no longer share the same income figure as Chemnitz.
  - `eurostat.py`: added `nama_10r_2hhinc` dataset config (NUTS-2 cube with NUTS-1 entries); filter params now appended to API URL so the server pre-filters the large cube before download (also makes `ilc_di03` requests smaller).
  - `stg_regional_income.sql`: new staging model — reads `nama_10r_2hhinc.json.gz`, filters to 3-char NUTS-1 codes, normalises `EL→GR` / `UK→GB`. Grain: `(nuts1_code, ref_year)`.
  - `dim_locations.sql`: `admin1_to_nuts1` VALUES CTE (16 German Bundesländer mapping GeoNames `admin1_code` → NUTS-1) + `regional_income` CTE (latest year per region). Final SELECT: `COALESCE(regional_income_pps, country_income_pps) AS median_income_pps` — all downstream consumers (`location_opportunity_profile`, `opportunity_score`) work unchanged.
  - `init_landing_seeds.py`: seed entry for `eurostat/1970/01/nama_10r_2hhinc.json.gz`.
  - Verified income spread: Bayern (DE2) ~29K PPS > Hamburg (DE6) ~27K > Berlin (DE3) ~24K > Sachsen (DED) ~19K PPS. Non-mapped countries (ES, FR, IT) continue with country-level fallback.

- **Phase 2b — EU NUTS-2 spatial join + US state income** (`dim_locations`): all EU-27 + EFTA + UK locations now resolve to their NUTS-2 region automatically via a spatial join; US locations now use Census ACS state-level income instead of a flat country fallback.
  - `stg_regional_income.sql`: expanded from NUTS-1 only (`LENGTH = 3`) to NUTS-1 + NUTS-2 (`LENGTH IN (3,4)`); column renamed `nuts1_code → nuts_code`; added `nuts_level` derived column (1 or 2).
  - `scripts/download_gisco_nuts.py`: new one-time download script for NUTS-2 boundary GeoJSON from Eurostat GISCO (`NUTS_RG_20M_2021_4326_LEVL_2.geojson`, ~5 MB, NUTS revision 2021). Saves uncompressed — `ST_Read` cannot read `.gz`.
  - `stg_nuts2_boundaries.sql`: new staging model — reads GeoJSON via `ST_Read`; extracts `nuts2_code`, `country_code`, `geometry`, and pre-computed bbox columns (`bbox_lat_min/max`, `bbox_lon_min/max`) for spatial pre-filter; normalises `EL→GR` / `UK→GB`. Grain: `nuts2_code`.
  - `census_usa_income.py`: new extractor — fetches `B19013_001E` (median household income) at state level from Census ACS 5-year; saves to `census_usa/{year}/{month}/acs5_state_income.json.gz`; registered in `all.py` and `pyproject.toml`.
  - `stg_income_usa.sql`: new staging model for US state income. Grain: `(state_fips, ref_year)`. Income kept in nominal USD — PPS conversion happens in `dim_locations`.
  - `dim_locations.sql`: replaced `admin1_to_nuts1` VALUES CTE (16 DE rows) with full spatial join: `nuts2_match` (bbox pre-filter + `ST_Contains`) → `nuts2_income` / `nuts1_income` (latest year per level) → `regional_income` (COALESCE NUTS-2 → NUTS-1). Added `us_state_fips` (51-row VALUES CTE, admin1 abbreviation → FIPS) + `us_income` (PPS normalisation: `state_income / 80610.0 × 30000.0`). Final income cascade: EU NUTS-2 → EU NUTS-1 → US state → country-level. Germany now resolves to 38 Regierungsbezirke; Spain, France, Italy, Netherlands etc. all get NUTS-2 differentiation automatically.
  - `init_landing_seeds.py`: `create_seed` extended with `compress=False` for files consumed by `ST_Read` (cannot read `.gz`); added `census_usa/1970/01/acs5_state_income.json.gz` seed and uncompressed `gisco/1970/01/nuts2_boundaries.geojson` empty-FeatureCollection seed.

### Changed
- **Opportunity Score v2 — income ceiling fix** (`location_opportunity_profile.sql`): income PPS normalisation changed from `/200.0` (caused LEAST(1.0, 115)=1.0 for ALL countries — no differentiation) to `/35000.0` with country-spread-matched ceiling. Default for missing data changed from 100 to 15000 (developing-market assumption). Country scores now reflect real PPS spread: LU 20.0, SE 14.3, DE 13.2, ES 10.7, GB 10.5 pts (was 20.0 everywhere).
- **dim_cities population coverage 70.5% → 98.5%** — added GeoNames spatial fallback CTE that finds the nearest GeoNames location within ~15 km when string name matching fails (~29% of cities). Fixes localization mismatches (Milano≠Milan, Wien≠Vienna, München≠Munich): Wien 0→1,691,468; Milano 0→1,371,498. Population cascade now: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0.

### Added
- **overpass_tennis** workflow added to `infra/supervisor/workflows.toml` — tennis courts extraction was only in the combined `all.py` extractor; now scheduled monthly by the supervisor so it runs automatically in production.

- **Market Score v3 (Marktreife-Score recalibration)** — fixes ranking inversion where early-stage markets (Germany 1/100k) outscored mature markets (Spain 36/100k):
  - **Formula rewrite** (`city_market_profile.sql`): supply development now 40 pts (log-scaled density LN(d+1)/LN(21) × count gate min(1,count/5)); demand evidence 25 pts (occupancy or 40% density proxy); population reduced to 15 pts (context); income to 10 pts (context); data quality to 10 pts; saturation discount removed
  - **Count gate** eliminates small-town inflation: a single venue in a 5k-resident town can no longer outscore Berlin (was 92.7 → now 43.9 for Bernau bei Berlin)
  - **LN ceiling at 20/100k** (was linear 4/100k) gives meaningful differentiation from 0 to 20: Málaga 70.1, Barcelona 67.4, Madrid 66.9, Amsterdam 58.4, Berlin 42.2, London 44.1
  - **Template thresholds updated** across all 3 pSEO templates (city-cost-de, country-overview, city-pricing): color coding green ≥55 (was ≥65) / amber ≥35 (was ≥40); intro/FAQ tiers strong ≥55 (was ≥70) / mid ≥35 (was ≥45); white-space signal interplay market_score < 40 (was < 50)

- **Opportunity Score supply gap ceiling raised 4→8/100k** (`location_opportunity_profile.sql`) — gentler gradient for partially-served markets; accounts for ~87% data undercount vs FIP real-world totals. Documents discovered formula behaviour: DuckDB `LEAST(1.0, NULL)=1.0` means NULL catchment already yields full 15 pts; income PPS saturates for all EU countries; tennis courts data currently empty (formula correct, data pending)

### Added
- **group_key static article grouping** — admin articles list now groups EN/DE static cornerstone articles into one row with language chips, matching the existing pSEO grouping behavior:
  - Migration 0020: `group_key TEXT` column + index on `articles` table
  - `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on every admin articles page load; reads `cornerstone` frontmatter → `group_key`
  - `_get_article_list_grouped()` uses `COALESCE(group_key, url_path)` as group key; pSEO articles unchanged (group_key NULL falls back to url_path)
- **Email-gated State of Padel Q1 2026 report PDF**:
  - `data/content/reports/` — new directory for market intelligence reports; state-of-padel .md files moved here from articles/
  - `reports/` blueprint: `GET/POST /<lang>/reports/<slug>` (email capture gate), `GET /<lang>/reports/<slug>/download` (PDF serve)
  - Premium WeasyPrint PDF: full-bleed navy cover, Padelnomics wordmark watermark at 3.5% opacity repeated every page via `position:fixed`, gold/teal accents, Georgia headings, running headers/footers via CSS named strings
  - `make report-pdf` target builds EN + DE PDFs from the .md files
  - 26 new `report_q1_*` i18n keys in EN + DE (native German via linguistic-mediation)
  - `/reports` added to `RESERVED_PREFIXES`; `data/content/reports/_build/` gitignored

- **Opportunity Score integration** — second scoring dimension (`Marktpotenzial`) now visible in city and country articles:
  - **SQL chain**: `dim_cities` now carries `geoname_id` (from the existing GeoNames LEFT JOIN); threaded through `city_market_profile` → `pseo_city_costs_de` which LEFT JOINs `location_opportunity_profile` on `(country_code, geoname_id)`; `pseo_country_overview` gains `avg_opportunity_score`, `top_opportunity_score`, `top_opportunity_slugs`, `top_opportunity_names`
  - **71.4% match rate** — 3,350 of 4,693 cities matched to a GeoNames `geoname_id`; unmatched cities gracefully show no Opportunity Score
  - **City articles** (`city-cost-de.md.jinja`) — `{% if opportunity_score %}` guard adds: 5th stats-strip item with green/amber/red color coding (≥65/≥40/<40), contextual intro sentence explaining the score interplay, table row in Market Overview, score explainer FAQ (DE + EN)
  - **Country overview articles** (`country-overview.md.jinja`) — adds: `avg_opportunity_score` as 5th stats-strip item, opportunity interplay paragraph in market landscape section, "Top Locations by Investment Potential" table (distinct from top Market Score cities), score explainer FAQ (DE + EN)
  - **CSS**: stats-strip changed from `repeat(4, 1fr)` to `repeat(auto-fit, minmax(140px, 1fr))` — supports 4-item country and 5-item city strips without layout breakage

- **Pipeline Console admin section** — full operational visibility into the data engineering pipeline at `/admin/pipeline/`:
  - **Overview tab** — extraction status grid (one card per workflow with status dot, schedule, last-run timestamp, error preview), serving table row counts from `_serving_meta.json`, landing zone file stats (per-source file count + total size)
  - **Extractions tab** — filterable, paginated run history table from `.state.sqlite` (extractor + status dropdowns, HTMX live filter); stale "running" row detection (amber highlight) with "Mark Failed" button; "Run All Extractors" button enqueues `run_extraction` task
  - **Catalog tab** — accordion list of serving tables with row count badges; click-to-expand lazy-loads column schema + 10-row sample data per table
  - **Query editor tab** — dark-themed SQL textarea (`Commit Mono`, navy background, electric blue focus glow); schema sidebar (collapsible table/column list with types); Tab-key indent and Cmd/Ctrl+Enter submit; results table with sticky headers + row count + elapsed time; query security (read-only DuckDB, blocklist regex, 10k char limit, 1000 row cap, 10s timeout)
  - **`analytics.execute_user_query()`** — new function returning `(columns, rows, error, elapsed_ms)` for admin query editor
  - **`worker.run_extraction` task** — background handler shells out to `uv run extract` from repo root (2h timeout)
  - 29 new tests covering all routes, data access helpers, security checks, and `execute_user_query()`

- **Outreach follow-up scheduling + activity timeline** — extends the outreach pipeline (migration 0024):
  - **Migration 0025** — adds `follow_up_at TEXT DEFAULT NULL` to `suppliers` and `noindex INTEGER NOT NULL DEFAULT 0` to `articles`
  - **Follow-up date picker** (`POST /admin/outreach/<id>/follow-up`) — HTMX date input on each outreach row; sets/clears `follow_up_at`; returns updated row via outerHTML swap
  - **Follow-up due banner** on `/admin/outreach` — amber alert banner shows count of overdue follow-ups with "Show them" link (`?follow_up=due` filter)
  - **`?follow_up=due` / `?follow_up=set` filters** in `get_outreach_suppliers()` — querystring params passed through dashboard and results partial
  - **`get_follow_up_due_count()`** query function counts suppliers with `follow_up_at <= date('now')`
  - **Activity timeline** on `/admin/suppliers/<id>` — merges sent outreach emails (`email_log WHERE email_type='outreach'`) and received emails (`inbound_emails`) matched by `contact_email`; sorted by date descending; max 50 entries; empty state shown when no history
  - 29 new tests (follow-up CRUD, due count, due filter, timeline with sent+received, timeline empty state)

- **pSEO article noindex** — prevents thin-data articles from diluting crawl budget and index quality:
  - **`NOINDEX_THRESHOLDS` dict** in `content/__init__.py` — per-template lambda: `city-pricing` (venue_count < 3), `city-cost-de` (data_confidence < 1.0), `country-overview` (total_venues < 5)
  - **`generate_articles()` upsert** now evaluates the threshold and stores `noindex = 1` for articles that fail it; existing articles are updated on re-generation
  - **`<meta name="robots" content="noindex, follow">`** injected in `article_detail.html` head block when `article.noindex` is truthy
  - **Sitemap exclusion** — `sitemap.py` articles query adds `AND noindex = 0`; thin-data articles excluded from `sitemap.xml`
  - **pSEO dashboard noindex card** — 4th summary card shows count of noindex articles (amber highlight when > 0)
  - **Article row noindex badge** — amber pill badge on `partials/article_row.html` when `a.noindex`
  - 20 new tests (threshold unit tests per template, sitemap exclusion, article detail robots meta tag)

- **Outreach pipeline** — cold B2B supplier outreach isolated from transactional emails:
  - **Separate sending domain** (`hello.padelnomics.io`) — added `"outreach"` key to `EMAIL_ADDRESSES`; reputation isolated from `notifications.padelnomics.io` magic-link/lead-forward traffic (manual DNS step: add domain in Resend dashboard)
  - **Migration 0024** — 4 new columns on `suppliers`: `outreach_status`, `outreach_notes`, `last_contacted_at`, `outreach_sequence_step`; `NULL` status = not in pipeline (no backfill needed for existing suppliers)
  - **Admin outreach pipeline tab** (`/admin/outreach`) — 6 pipeline cards (prospect → contacted → replied → signed_up → declined → not_interested) with click-to-filter; HTMX-powered supplier table with inline status dropdown + note editing; sidebar link added
  - **HTMX endpoints** — `POST /admin/outreach/<id>/status` returns updated row; `POST /admin/outreach/<id>/note` returns truncated note text
  - **Bulk add-to-pipeline** — checkbox column on `/admin/suppliers`, "Add to Outreach Pipeline" form action → `POST /admin/outreach/add-prospects`; skips suppliers already in pipeline
  - **CSV import** (`GET/POST /admin/outreach/import`) — uploads CSV (`name`, `contact_email` required; `country_code`, `category`, `website` optional); creates new supplier rows as `prospect`; auto-generates slug; deduplicates by `contact_email`; capped at 500 rows
  - **Compose integration** — `GET /admin/emails/compose` now accepts `?from_key=outreach&email_type=outreach&supplier_id=<id>` query params; pre-selects outreach from-address and unchecks HTML wrap (plain text best practice for cold email); on successful send with `email_type=outreach` + `supplier_id`, auto-updates supplier: `prospect→contacted`, `last_contacted_at=now`, `outreach_sequence_step+1`
  - **Supplier detail outreach card** — shown when supplier is in the outreach pipeline; displays status, step, last contact date, notes, and "Send Outreach Email" compose link
  - 44 new tests in `web/tests/test_outreach.py`
- **Email template system** — all 11 transactional emails migrated from inline f-string HTML in `worker.py` to Jinja2 templates:
  - **Standalone renderer** (`email_templates.py`) — `render_email_template()` uses a module-level `jinja2.Environment` with `autoescape=True`, works outside Quart request context (worker process); `tformat` filter mirrors the one in `app.py`
  - **`_base.html`** — branded shell (dark header, 3px blue accent, white card body, footer with tagline + copyright); replaces the old `_email_wrap()` helper
  - **`_macros.html`** — reusable Jinja2 macros: `email_button`, `heat_badge`, `heat_badge_sm`, `section_heading`, `info_box`
  - **11 email templates**: `magic_link`, `quote_verification`, `welcome`, `waitlist_supplier`, `waitlist_general`, `lead_matched`, `lead_forward`, `lead_match_notify`, `weekly_digest`, `business_plan`, `admin_compose`
  - **`EMAIL_TEMPLATE_REGISTRY`** — dict mapping slug → `{template, label, description, email_type, sample_data}` with realistic sample data callables for each template
  - **Admin email gallery** (`/admin/emails/gallery`) — card grid of all email types; preview page with EN/DE language toggle renders each template in a sandboxed iframe (`srcdoc`); "View in sent log →" cross-link; gallery link added to admin sidebar
  - **Compose live preview** — two-column compose layout: form on the left, HTMX-powered preview iframe on the right; `hx-trigger="input delay:500ms"` on the textarea; `POST /admin/emails/compose/preview` endpoint supports plain body or branded wrapper via `wrap` checkbox
  - 50 new tests covering all template renders (EN + DE), registry structure, gallery routes (access control, list, preview, lang fallback), and compose preview endpoint
- **JSONL streaming landing format** — extractors now write one JSON object per line (`.jsonl.gz`) instead of a single large blob, eliminating in-memory accumulation and `maximum_object_size` workarounds:
  - `playtomic_tenants.py` → `tenants.jsonl.gz` (one tenant per line; dedup still happens in memory before write)
  - `playtomic_availability.py` → `availability_{date}.jsonl.gz` (morning) + `availability_{date}_recheck_{HH}.jsonl.gz` (recheck); one venue per line with `date`/`captured_at_utc`/`recheck_hour` injected
  - `geonames.py` → `cities_global.jsonl.gz` (one city per line; eliminates 30 MB blob and its `maximum_object_size` workaround)
  - `compress_jsonl_atomic(jsonl_path, dest_path)` utility added to `utils.py` — streams compression in 1 MB chunks, atomic `.tmp` rename, deletes source
- **Regional Overpass splitting for tennis courts** — replaces single global query (150K+ elements, timed out) with 10 regional bbox queries (~10-40K elements each, 150s server / 180s client):
  - Regions: europe\_west, europe\_central, europe\_east, north\_america, south\_america, asia\_east, asia\_west, oceania, africa, asia\_north
  - Per-region retry (2 attempts, 30s cooldown) + 5s inter-region polite delay
  - Crash recovery via `working.jsonl` accumulation — already-written element IDs skipped on restart; completed regions produce 0 new elements on re-query
  - Output: `courts.jsonl.gz` (one OSM element per line)
- **`scripts/init_landing_seeds.py`** — creates minimal `.jsonl.gz` and `.json.gz` seed files in `1970/01/` so SQLMesh staging models can run before real extraction data arrives; idempotent

### Changed
- All modified staging SQL models use **UNION ALL transition CTEs** — both JSONL (new) and blob (old) formats are readable simultaneously; old `.json.gz` files in the landing zone continue working until they rotate out naturally:
  - `stg_playtomic_venues`, `stg_playtomic_resources`, `stg_playtomic_opening_hours` — JSONL top-level columns (no `UNNEST(tenants)`)
  - `stg_playtomic_availability` — JSONL morning + recheck files; blob morning + recheck kept for transition
  - `stg_population_geonames` — JSONL city rows (no `UNNEST(rows)`, no `maximum_object_size`)
  - `stg_tennis_courts` — JSONL elements with `COALESCE(lat, center.lat)` for way/relation centre coords; blob UNNEST kept for old files

### Removed
- `_email_wrap()` and `_email_button()` helper functions removed from `worker.py` — replaced by templates
- **Marketplace admin dashboard** (`/admin/marketplace`) — single-screen health view for the two-sided market:
  - **Lead funnel** — total / verified-new (ready to unlock) / unlocked / won / conversion rate
  - **Credit economy** — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate
  - **Supplier engagement** — active paid supplier count, avg lead unlocks per supplier, forward response rate
  - **Feature flag toggles** — `lead_unlock` and `supplier_signup` flags togglable inline; sidebar nav entry added
  - **Live activity stream** (HTMX partial) — last 50 events across leads, unlocks, and credit ledger in a single feed
- **Lead matching notifications** (`notify_matching_suppliers` worker task) — on quote verification, finds growth/pro suppliers whose `service_area` includes the lead's country and sends an instant alert email; bounded to 20 suppliers per lead
- **Weekly lead digest** (`send_weekly_lead_digest` worker task) — every Monday at 08:00 UTC, sends paid suppliers a summary table of new matching leads from the past 7 days they haven't unlocked yet (max 5 rows per email)
- **One-click CTA token** — lead-forward emails now include a "Mark as contacted" footer link backed by a unique `cta_token`; clicking it sets the forward status to `contacted` and redirects to the supplier dashboard; token stored on `lead_forwards` after send
- **Supplier `lead_respond` endpoint** — HTMX status update for forwarded leads: `sent / viewed / contacted / quoted / won / lost / no_response`
- **Supplier `lead_cta_contacted` endpoint** (`/suppliers/leads/cta/<token>`) — one-click email handler; idempotent (only advances from `sent` → `contacted`)
- **Migration 0022** — adds `status_updated_at`, `supplier_note`, `cta_token` to `lead_forwards`; unique partial index on `cta_token`
- **Admin leads list improvements** — summary cards (total / new+unverified / hot pipeline credits / forward rate); text search across name, email, company; period filter pills (Today / 7d / 30d / All); `get_leads()` now returns `(rows, total_count)` and supports `search` + `days` params
- **Admin lead detail — HTMX inline actions** — status change returns an updated status badge partial; forward-to-supplier form returns an updated forward history table; no full-page reload
- **Quote form extended** — captures `build_context`, `glass_type`, `lighting_type`, `location_status`, `financing_status`, `services_needed`, `additional_info`; displayed in lead detail view

- **pSEO Engine admin tab** (`/admin/pseo`) — operational visibility for the programmatic SEO system:
  - **Content gap detection** — queries DuckDB serving tables vs SQLite articles to find rows with no matching article per language; per-template HTMX-loaded gap list
  - **Data freshness signals** — compares `_serving_meta.json` export timestamp vs `MAX(updated_at)` in articles; per-template status: 🟢 Fresh / 🟡 Stale / 🟣 No articles / ⚫ No data
  - **Article health checks** (HTMX partial) — hreflang orphans (EN exists, DE missing), missing HTML build files, broken `[scenario:slug]` references in article markdown
  - **Generation job monitoring** — live progress bars polling every 2s while jobs run; stops polling on completion; error drilldown via `<details>`; dedicated `/admin/pseo/jobs` list page
  - **`_serving_meta.json`** — written by `export_serving.py` after atomic rename; records `exported_at_utc` and per-table row counts; drives freshness signals in pSEO Engine dashboard
  - **Progress tracking columns** on `tasks` table (migration 0021): `progress_current`, `progress_total`, `error_log`; `generate_articles()` writes progress every 50 articles and on completion
  - 45 new tests covering all health functions + pSEO routes (access control, rendering, gap detection, generate-gaps POST, job status HTMX polling)


- **Dual market score system** — split the single market score into two branded scores:
  - **padelnomics Marktreife-Score™** (market maturity): existing score, refined — only for cities
    with ≥1 padel venue. Adds ×0.85 saturation discount when `venues_per_100k > 8`.
  - **padelnomics Marktpotenzial-Score™** (investment opportunity): new score covering ALL
    GeoNames locations globally (pop ≥1K), including zero-court locations. Rewards supply gaps,
    underserved catchment areas, and racket sport culture via inverted venue density signal.
- **Tennis court Overpass extractor** — `extract-overpass-tennis` downloads all OSM
  `sport=tennis` nodes/ways/relations globally (~150K+ features). Lands at
  `overpass_tennis/{year}/{month}/courts.json.gz`. Staged in `stg_tennis_courts`.
- **`foundation.dim_locations`** — new conformed dimension seeded from GeoNames (all locations
  ≥1K pop), not from padel venues. Grain `(country_code, geoname_id)`. Enriched with:
  - `nearest_padel_court_km` via `ST_Distance_Sphere` (DuckDB spatial extension)
  - `padel_venue_count` / `padel_venues_per_100k` (venues within 5km)
  - `tennis_courts_within_25km` (courts within 25km)
- **GeoNames expanded** — extractor switched from `cities15000` (50K+ filter, ~24K rows) to
  `cities1000` (~140K locations, pop ≥1K). Added `lat`, `lon`, `admin1_code`, `admin2_code`
  to output. Expanded feature codes to include `PPLA3/4/5` (Gemeinden/cantons).
- **DuckDB spatial extension** — `extensions: [spatial]` added to `config.yaml`. Enables
  `ST_Distance_Sphere` for great-circle distance and future map features (bounding box
  queries, geometry columns).
- **SOPS secrets** — `GEONAMES_USERNAME=padelnomics` and `CENSUS_API_KEY` added to both
  `.env.dev.sops` and `.env.prod.sops`.
- **Crash-safe partial JSONL** — `utils.load_partial_results()` and `flush_partial_batch()`
  provide a generic opt-in mechanism for incremental progress flushing during long extractions.
  Any extractor processing items one-by-one can flush every N records and resume from a
  `.partial.jsonl` sidecar file after a crash.
- **Methodology page updated** — `/en/market-score` now documents both scores with:
  Two Scores intro section, component cards for each score (4 Marktreife + 5 Marktpotenzial),
  score band interpretations, expanded FAQ (7 entries). Section headings use the padelnomics
  wordmark span (Bricolage Grotesque). Bilingual EN + DE (native-quality German, no calques).
- **Market Score methodology page** — standalone page at `/{lang}/market-score`
  explaining the padelnomics Market Score (Zillow Zestimate-style). Reveals four
  input categories (demographics, economic strength, demand evidence, data
  completeness) and score band interpretations without exposing weights or
  formulas. Full JSON-LD (WebPage + FAQPage + BreadcrumbList), OG tags, and
  bilingual content (EN professional, DE Du-form). Added to sitemap and footer.
  First "padelnomics Market Score" mention in each article template now links
  to the methodology page (hub-and-spoke internal linking).

### Changed
- **`EXTRACT_WORKERS` env var removed** — worker count is now derived from `PROXY_URLS` length
  (one worker per proxy). No proxies → single-threaded. No manual tuning needed.
- **Playtomic tenants extractor** — parallel batch page fetching when proxies are configured.
  Each page in a batch fires concurrently using its own session + proxy. Expected speedup:
  ~2.5 min → ~15 s with 10 Webshare datacenter proxies.
- **Playtomic availability extractor** — three performance changes:
  1. No per-request `time.sleep()` on success when a proxy is active (throttle only when
     running direct). Retry/backoff sleeps for 429 and 5xx responses are unchanged.
  2. Worker count auto-detected from proxy count (drops `EXTRACT_WORKERS`).
  3. True crash resumption via `.partial.jsonl` sidecar: progress flushed every 50 venues,
     resume skips already-fetched venues and merges prior results into the final file.
- **Lead-Back Guarantee** — suppliers can claim credits back for non-responding leads
  with one click after 3 business days. Route `POST /suppliers/leads/<id>/guarantee-claim`,
  `refund_lead_guarantee()` in credits.py, "Lead didn't respond" button on unlocked
  lead cards (visible 3–30 days after unlock). Migration 0020 adds `guarantee_claimed_at`
  and `guarantee_contact_method` columns to `lead_forwards`.
- **Supplier page CRO restructure** — `/suppliers` page reordered to lead with value
  before pricing (Why Padelnomics → Lead-Back Guarantee → lead preview → social proof
  → pricing). All CTAs changed from "See Plans & Pricing" to "Get Started Free".
- **Static ROI line** — one-sentence ROI callout near pricing grounded in
  `research/padel-hall-economics.md` data (4-court project = €30K+ contractor profit).
- **Credits-only callout** — below pricing grid: "Not ready for a subscription? Buy
  a credit pack and unlock leads one at a time."


### Fixed
- **`datetime.utcnow()` deprecation warnings** — replaced all 94 occurrences
  across 22 files (source + tests) with `utcnow()` / `utcnow_iso()` helpers
  from `core.py`. `utcnow_iso()` produces `YYYY-MM-DD HH:MM:SS` (space
  separator) matching SQLite's `datetime('now')` format so lexicographic SQL
  comparisons stay correct. `datetime.utcfromtimestamp()` in `seo/_bing.py`
  also replaced with `datetime.fromtimestamp(ts, tz=UTC)`. Zero deprecation
  warnings remain.
- **Credit ledger ordering** — `get_ledger()` now uses `ORDER BY created_at
  DESC, id DESC` to preserve insertion order when multiple credits are added
  within the same second.


- **Double language prefix in article URLs** — articles were served at
  `/en/en/markets/italy` (double prefix) because `generate_articles()` stored
  `url_path` with the lang prefix baked in, but the blueprint is already mounted
  at `/<lang>`. Now `url_path` is stored without the prefix; canonical URLs,
  breadcrumbs, sitemap, and admin links all generate correct single-prefix URLs.
- **`/markets` removed from RESERVED_PREFIXES** — pSEO articles live under
  `/markets/` and the explicit `/markets` route takes priority over the catch-all,
  so the reservation was blocking article generation.
- **`country-overview` schema_type** — changed from `[Article]` to
  `[Article, FAQPage]` to enable FAQ rich results for existing FAQ content.

### Added
- **Bilingual pSEO templates (DE + EN)** — all 3 article templates
  (`city-cost-de`, `city-pricing`, `country-overview`) now generate proper
  German prose via `{% if language == "de" %}` conditionals. German text uses
  informal "Du/Dein", natural business German (not calque translation), and
  localized labels/units (€/Std, Hauptzeit/Nebenzeit, etc.).
- **Expanded English pSEO content** — all 3 templates expanded from ~400–900
  words to ~1300–1500 words each. Added: Market Context/Landscape sections,
  analytical commentary after scenario markers, cross-template links
  (cost ↔ pricing ↔ country), planner links in FAQ answers, second CTA at
  bottom of each article, 2 additional FAQ questions per template.
- **Scenario cross-reference** — `city-pricing` template now embeds
  `[scenario:city-cost-de-{{ city_key }}:operating]` to show operating cost
  data from the investment analysis template.
- **CMS admin improvement** — articles list now has HTMX filter bar (search,
  status, template, language), pagination (50/page), and stats strip
  (total/live/scheduled/draft counts). Article actions (publish/unpublish,
  delete) are inline HTMX operations — no full page reload. "View" link opens
  live articles on the public site. Article generation and rebuild-all now
  enqueue to the background worker instead of blocking the HTTP request.
  Markdown source is written to disk during generation so the edit form shows
  content. Sitemap cache is invalidated when articles are published, deleted,
  or created. Fixed broken "Scheduled"/"Published" status display (was always
  showing "Scheduled") and stale `template_data_id` column reference.

### Changed
- **Visual test overhaul** — consolidated 3 separate Playwright server processes
  (ports 5111/5112/5113) into 1 session-scoped fixture in `conftest.py`; 77 tests
  pass in ~59 seconds (was ~3× slower with 3 independent servers). Fixed `init_db`
  mock bypass (must patch `padelnomics.app.init_db`, not `core.init_db`, since
  `from .core import init_db` creates a local binding). Forces `RESEND_API_KEY=""`
  and `WAITLIST_MODE=false` in subprocess so visual tests never send real emails or
  render waitlist pages. Added sections J–N: pricing, checkout, supplier signup,
  supplier dashboard, business plan export.

### Added
- **SOPS + age encrypted secrets** — `.env.dev.sops` and `.env.prod.sops` replace
  `.env.example` and GitLab CI/CD variables; age keypair for encryption/decryption;
  `deploy.sh` auto-decrypts on server; `infra/setup_server.sh` installs sops + age
  and generates server keypair; Makefile targets: `secrets-decrypt-dev`,
  `secrets-decrypt-prod`, `secrets-edit-dev`, `secrets-edit-prod`

### Removed
- `.env.example` — replaced by `.env.dev.sops` (decrypt with `make secrets-decrypt-dev`)
- GitLab CI heredoc that wrote `.env` via SSH — deploy.sh now handles decryption
- Dead `ADMIN_PASSWORD` CI variable reference
- Deprecated `WAITLIST_MODE` from env files (replaced by DB-backed feature flags)

- **Python supervisor** (`src/padelnomics/supervisor.py`) — replaces `supervisor.sh`;
  reads `infra/supervisor/workflows.toml` (module, schedule, entry, depends_on,
  proxy_mode); runs due workflows in topological waves (parallel within each wave);
  croniter-based `is_due()` check; systemd service updated to use `uv run python`
- **`workflows.toml` workflow registry** — 5 extractors registered: overpass,
  eurostat, playtomic_tenants, playtomic_availability, playtomic_prices; cron
  presets: hourly/daily/weekly/monthly; `playtomic_availability` depends on
  `playtomic_tenants`
- **`proxy.py` proxy rotation** (`extract/padelnomics_extract/proxy.py`) — reads
  `PROXY_URLS` env var; `make_round_robin_cycler()` for thread-safe round-robin;
  `make_sticky_selector()` for consistent per-tenant proxy assignment (hash-based)
- **DB-backed feature flags** — `feature_flags` table (migration 0019);
  `is_flag_enabled(name, default)` helper; `feature_gate(flag, template)` decorator
  replaces `WAITLIST_MODE`/`waitlist_gate`; 5 flags seeded: `markets` (on),
  `payments`, `planner_export`, `supplier_signup`, `lead_unlock` (all off)
- **Admin feature flags UI** — `/admin/flags` lists all flags with toggle;
  `POST /admin/flags/toggle` flips enabled bit; requires admin role; flash message
  on unknown flag
- **`lead_unlock` gate** — `unlock_lead` route returns HTTP 403 when `lead_unlock`
  flag is disabled
- **SEO/GEO admin hub** — syncs search performance data from Google Search Console (service
  account auth), Bing Webmaster Tools (API key), and Umami (bearer token) into 3 new SQLite
  tables (`seo_search_metrics`, `seo_analytics_metrics`, `seo_sync_log`); daily background
  sync via worker scheduler at 6am UTC; admin dashboard at `/admin/seo` with three HTMX tab
  views: search performance (top queries, top pages, country/device breakdown), full funnel
  (impressions → clicks → pageviews → visitors → planner users → leads), and per-article
  scorecard with attention flags (low CTR, no clicks); manual "Sync Now" button; 12-month
  data retention with automatic cleanup; all data sources optional (skip silently if not
  configured)
- **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)
- **Admin Email Hub** (`/admin/emails`) — full email management dashboard with:
  sent log (filterable by type/event/search, HTMX partial updates), email detail
  with Resend API enrichment for HTML preview, inbound inbox with unread badges
  and inline reply, compose form with branded template wrapping, and Resend
  audience management with contact list/remove
- **Email delivery tracking** — `email_log` table records every outgoing email
  with resend_id; Resend webhook handler (`/webhooks/resend`) updates delivery
  events (delivered, bounced, opened, clicked, complained) in real-time;
  `inbound_emails` table stores received messages with full body
- **send_email() returns resend_id** — changed return type from `bool` to
  `str | None` (backward-compatible: truthy string works like True); all 9
  worker handlers now pass `email_type=` for per-type filtering in the log
- **Playtomic full data extraction** — expanded venue bounding boxes from 4 regions
  (ES, UK, DE, FR) to 23 globally (Italy, Portugal, NL, BE, AT, CH, Nordics, Mexico,
  Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability
  extractor throttle reduced from 2s to 1s for ~4.5h runtime at 16K venues
- **Playtomic pricing & occupancy pipeline** — 4 new staging models:
  `stg_playtomic_resources` (per-court: indoor/outdoor, surface type, size),
  `stg_playtomic_opening_hours` (per-day: open/close times, hours_open),
  `stg_playtomic_availability` (per-slot: 60-min bookable windows with real prices);
  `stg_playtomic_venues` rewritten to extract all metadata (opening_hours, resources,
  VAT rate, currency, timezone, booking settings)
- **Venue capacity & daily availability fact tables** — `fct_venue_capacity` derives
  total bookable court-hours from court_count × opening_hours; `fct_daily_availability`
  calculates occupancy rate (1 - available/capacity), booked hours, revenue estimate,
  and pricing stats (median/peak/offpeak) per venue per day
- **Venue pricing benchmarks** — `venue_pricing_benchmarks.sql` aggregates last-30-day
  venue metrics to city/country level: median hourly rate, peak/offpeak rates, P25/P75,
  occupancy rate, estimated daily revenue, court count
- **Real data planner defaults** — `planner_defaults.sql` rewritten with 3-tier cascade:
  city-level Playtomic data → country median → hardcoded fallback; replaces income-factor
  estimation with actual market pricing; includes `data_source` and `data_confidence`
  provenance columns
- **Eurostat income integration** (`stg_income.sql`) — staging model reads `ilc_di03`
  (median equivalised net income in PPS) from landing zone; grain `(country_code, ref_year)`
- **Income columns in dim_cities and city_market_profile** — `median_income_pps`
  and `income_year` passed through from staging to serving layer
- **Transactional email i18n** — all 8 email types now translated via locale
  files; `_t()` helper in `worker.py` looks up `email_*` keys from `en.json` /
  `de.json`; `_email_wrap()` accepts `lang` parameter for `<html lang>` tag and
  translated footer; ~70 new translation keys (EN + DE); all task payloads now
  carry `lang` from request context at enqueue time; payloads without `lang`
  gracefully default to English
- **Email design & copy upgrade** — redesigned `_email_wrap()`: replaced monogram
  header with lowercase wordmark matching website, added 3px blue accent border,
  preheader text support (hidden preview in email clients), HR separators between
  heading and body; `_email_button()` now full-width block for mobile tap targets;
  rewrote copy for all 9 emails with improved subject lines, urgency cues,
  quick-start links in welcome email, styled project recap cards in quote
  verification, heat badges on lead forward emails, "what happens next" section
  in lead matched notifications, and secondary CTAs; ~30 new/updated translation
  keys in both EN and DE

### Changed
- **Resend audiences restructured** — replaced dynamic `waitlist-{blueprint}`
  audience naming (up to 4 audiences) with 3 named audiences fitting free plan
  limit: `suppliers` (supplier signups), `leads` (planner/quote users),
  `newsletter` (auth/content/public catch-all); new `_audience_for_blueprint()`
  mapping function in `core.py`
- **dim_venues enhanced** — now includes court_count, indoor/outdoor split,
  timezone, VAT rate, and default currency from Playtomic venue metadata
- **city_market_profile enhanced** — includes median hourly rate, occupancy rate,
  daily revenue estimate, and price currency from venue pricing benchmarks
- **Planner API route** — col_map updated to match new planner_defaults columns
  (`rate_peak`, `rate_off_peak`, `avg_utilisation_pct`, `courts_typical`); adds
  `_dataSource` and `_currency` metadata keys

### Changed
- **pSEO CMS: SSG architecture** — templates now live in git as `.md.jinja` files with YAML
  frontmatter (slug, data_table, url_pattern, etc.) instead of SQLite `article_templates` table;
  data comes directly from DuckDB serving tables instead of intermediary `template_data` table;
  admin template views are read-only (edit in git, preview/generate in admin)
- **pSEO CMS: SEO pipeline** — article generation bakes canonical URLs, hreflang links (EN + DE),
  JSON-LD structured data (Article, FAQPage, BreadcrumbList), and Open Graph tags into each
  article's `seo_head` column at generation time; articles stored with `template_slug`, `language`,
  and `date_modified` columns for regeneration and freshness tracking

### Removed
- `article_templates` and `template_data` SQLite tables (migration 0018) — replaced by git
  template files + direct DuckDB reads; `template_data_id` FK removed from `articles` and
  `published_scenarios` tables
- Admin template CRUD routes (create/edit/delete) and CSV upload — replaced by read-only views
  with generate/regenerate/preview actions
- `template_form.html` and `template_data.html` admin templates

### 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
- **Sitemap: hreflang alternates + caching** — extracted sitemap generation to
  `sitemap.py`; each URL entry now includes `xhtml:link` hreflang alternates
  (en, de, x-default) for correct international SEO signaling; supplier detail
  pages now listed in both EN and DE (were EN-only); removed misleading "today"
  lastmod from static pages; added 1-hour in-memory TTL cache with
  `Cache-Control: public, max-age=3600` response header
- **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 `29ac25b` → `v0.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_1–9.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_1–4.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.png` → `planner-screenshot.png`)
- Homepage meta description trimmed to under 155 characters
- Duplicate `og:url` and `canonical` tags removed from landing, markets, and article detail pages

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

### Changed
- 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_id` → `provider_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 context** — `load_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 decorators** — `role_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 handler** — `BOOST_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 atomic** — `boost_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 0010** — `published_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 pipeline** — `POST /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 system** — `POST /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 prevention** — `RESERVED_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-setup** — `setup_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 broken** — `Verifier().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 crash** — `no 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 CRUD** — `GET/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 0008** — `paddle_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 export** — `businessplan.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 routes** — `GET /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 management** — `GET /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 script** — `scripts/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 0007** — `credit_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 management** — `GET /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 emails** — `send_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 submission** — `credit_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 flow** — `GET /suppliers/claim/<slug>` verifies unclaimed
  and redirects to signup with pre-fill
- **Webhook handlers** — `subscription.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_verification` → `new`, 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 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
- 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)
- **Basic tier is now free** — `supplier_basic` monthly/yearly price → €0. No Paddle
  subscription required for Basic. Signup wizard shows "Free forever" instead of €39.
- **Card color boost** — price corrected €19/mo → €59/mo (aligns with MARKETING.md).
- **Business plan PDF** — price raised €99 → €149 (KfW-ready document supporting
  €200K+ investment decision).
- **Hero and final CTA** — links changed from `#pricing` anchor to direct signup URL.
- **Comparison table** — €1,799/yr annotated with "(yearly plan)" for clarity.
- **EN+DE translations** — `sup_meta_desc` updated (removed "from €39/mo"); all
  Basic-tier strings updated to reflect free tier; FAQ updated with guarantee mention.
- **`setup_paddle.py`** — Basic subscription products commented out (no longer needed);
  `boost_card_color` 1900 → 5900 cents; `business_plan` 9900 → 14900 cents.

### 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
