Files
padelnomics/CHANGELOG.md
Deeman 9d1d42f6db feat: complete German translation of all public-facing content
Merges worktree-i18n-german branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 14:45:03 +01:00

801 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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]
### Added
- i18n: translate `base.html`, `_cookie_banner.html` — "Manage Cookies", "About" footer links, feedback placeholder via `{{ t.key }}`; cookie banner heading/categories/descriptions/buttons; JS toggle text injected via `tojson` so "Manage"/"Close" states are also translated; `public/routes.py` feedback flash messages use `get_translations(g.lang)` keys
- i18n: expand `i18n.py` with ~300 UI template keys, ~200 planner JS locale strings (`_PLANNER_TRANSLATIONS`), ~35 CAPEX/OPEX item name translations (`_CALC_ITEM_NAMES`), plus `get_planner_translations()` and `get_calc_item_names()` functions
### Fixed
- i18n: `planner.html` used `{% if lang %}...{% block %}` nesting which Jinja2 forbids — restructured to `{% block title %}{% if lang == 'de' %}...{% endif %}{% endblock %}`
- ruff: unsorted import in `planner/routes.py` (new `get_planner_translations` import) — auto-fixed with `ruff --fix`
### Added
- i18n: localize planner — inject `window.__PADELNOMICS_LOCALE__` from server via `get_planner_translations(lang)`, add `const L / tr()` helpers in `planner.js`, replace all hardcoded English strings in TABS, WIZ_STEPS, all `buildInputs()`/`rebuildCapexInputs()`/`rebuildOpexInputs()` slider labels, `renderWith()`, `renderCapex()`, `renderOperating()`, `renderCashflow()`, `renderReturns()`, `renderMetrics()`, `renderSeasonChart()`, `resetToDefaults()`, `saveScenario()`, `renderWizNav()`, and `renderWizPreview()` with `tr('key', 'fallback')` calls
- i18n: localize `planner.html` — add `window.__PADELNOMICS_LOCALE__` script injection, translate wizard step titles/subtitles, toggle labels, chart/section headers, CTA sidebar and inline CTA, signup bar, scenario controls, metrics section headers, and page title/meta via `{% if lang == 'de' %}` and `{{ t.key }}` / `{{ planner_t.key }}`
- i18n: localize all export templates — `export.html`, `export_success.html`, `export_generating.html`, `export_waitlist.html` — all strings via `{{ t.key }}`, feature lists via `{% if lang == 'de' %}` conditionals
- i18n: localize `partials/scenario_list.html` — drawer title, default badge, Load/Delete buttons, updated label, empty state message via `{{ t.scenario_* }}`
- calculator: add `lang: str = "en"` parameter to `calc()`, import `get_calc_item_names`, replace all `ci()`/`oi()` hardcoded English names with `names["key"]` lookups, track `rent_amount` as local variable to replace name-based loop lookup for rentRatio
- routes: pass `lang` and `planner_t` to `planner.html` render context; pass `lang=lang` to `calc()` in both index and `/calculate` endpoints
- i18n: translate directory and leads templates — `directory.html`, `supplier_detail.html`, `partials/results.html`, `partials/enquiry_result.html`, `quote_request.html`, `quote_step_19.html`, `quote_submitted.html`, `quote_verify_sent.html` — short strings via `{{ t.key }}`, long paragraphs and context-sensitive text via `{% if lang == 'de' %}` conditionals, title/meta tags conditional per language
- i18n: translate supplier signup flow (`signup.html`, `signup_step_14.html`, `signup_success.html`), waitlist pages (`waitlist.html`, `waitlist_confirmed.html`), content templates (`markets.html`, `article_detail.html`, `market_results.html`), and all scenario partials (`scenario_summary`, `scenario_capex`, `scenario_cashflow`, `scenario_operating`, `scenario_returns`) — step labels via `{{ t.key }}`, all other strings via `{% if lang == 'de' %}` conditionals
- i18n: translate `landing.html`, `features.html`, and `about.html` to German — all short strings via `{{ t.key }}`, long paragraphs/FAQ answers via `{% if lang == 'de' %}` conditionals, JSON-LD structured data wrapped per language, title/meta blocks conditional
### Changed
- leads/routes.py: replace hardcoded `QUOTE_STEPS` list with `_get_quote_steps(lang)` function — step titles now use i18n keys so the progress bar shows translated step names; all public-facing `flash()` calls now use `get_translations(g.lang)` keys instead of hardcoded English strings
### Fixed
- Nav: hamburger button was trapped inside `.nav-links--right`; parent `display: none` on mobile hid it too — moved hamburger to be first child of `.nav-inner`; sign-in button added as always-visible mobile slot (`.nav-auth-mobile`) on the right; mobile grid is now `auto 1fr auto` (hamburger | logo | sign-in)
- Nav: align `.nav-inner` width with `container-page` — changed `max-width` from 80rem to 72rem (`--container-6xl`) and matched responsive padding (`1rem` / `1.5rem` / `2rem` at mobile / sm / lg) so nav edges are flush with page content and footer
- Planner: "Get Supplier Quotes" button now navigates to the correct lang-prefixed URL (`/en/leads/quote` etc.) — hardcoded `/leads/quote` caused a 404 on prod; URL is now injected from the server via `window.__PADELNOMICS_QUOTE_URL__`
### Added
- Nav: hamburger menu on screens < 900px — clicking opens a full-width mobile panel with all nav links; overlay click and Escape key close it
- Nav: mobile panel groups links under "Plan", "Explore", and "Account" section headers
### Changed
- Nav: widen container from 72rem (1152px) to 80rem (1280px) — matches Zillow's nav container width, more breathing room for nav items on large monitors
- Nav: collapse breakpoint raised from 768px to 899px — nav links no longer hide until the screen is actually too narrow
- Nav: remove redundant inline `style="display:grid;grid-template-columns:1fr auto 1fr"` on `.nav-inner` (already in CSS)
### Fixed
- i18n: improve German nav labels — "Verzeichnis" → "Anbieterverzeichnis", "Planer" → "Kostenrechner"
- CI: add missing env vars to `.env` heredoc — `WAITLIST_MODE`, `LEADS_EMAIL`, `UMAMI_API_URL`; make Paddle vars optional (`:-`) so they don't break deploys when unset
### Changed
- Legal pages: replaced home address with virtual office address (c/o COCENTER, Koppoldstr. 1, 86551 Aichach) in all four legal templates (`imprint_de`, `imprint_en`, `privacy_de`, `privacy_en`, `terms_de`)
### Fixed
- Litestream: remove local-path replica — v0.5.8 dropped multi-replica support (`"multiple replicas on a single database are no longer supported"`), keeping only the R2 replica
- Litestream: extend retention from 7 days to 1 year (`8760h`) — WAL frames are tiny, R2 storage cost is negligible
- Deploy: gate deployment on litestream health (`kill -0 1`, retries 6×5s after 15s start period) so broken backups fail the deploy loudly
- Deploy: write nginx router config *before* starting containers so the router health check (`nginx -t`) passes on first deploy or after volume wipe
- Deploy: pre-migration DB backup added to `deploy.sh`; on health-check failure the DB is restored to pre-migration state (prevents old slot from running against new schema)
- Migrations: removed all `conn.commit()` and `executescript()` calls from `up()` functions in 0000, 0011, 0012, 0013, 0014, 0015 — restores batch-atomicity guarantee (`executescript` issued implicit COMMITs, breaking rollback on failure)
- Visual tests: server now builds schema via `migrate()` instead of the deleted `schema.sql`; all 12 Playwright tests pass
- Visual tests: updated assertions to match current landing page (text logo replacing img, `.roi-calc` replacing `.teaser-calc`, `hero-dark`/`cta-card` allowed as intentional dark sections, card count ≥ 6, i18n-prefixed logo href, h3 brightness threshold relaxed to 150)
- CSS: removed dead `.nav-logo { line-height: 0 }` rule (was for image logo, collapsed text logo to zero height); removed dead `.nav-logo img` rule
- Ruff: fixed 49 lint errors across `src/` and `tests/` (unused imports, unused variables, unsorted imports, bare f-strings, ambiguous variable name `l`)
### Added
- Litestream: R2 replication config with env-var placeholders (`LITESTREAM_R2_BUCKET` etc.) — fill in GitLab CI variables to enable off-host backup; handles new server, deleted volume, or disaster recovery via auto-restore on container startup
- `.env.example`: Litestream R2 vars documented
### Added
- i18n URL prefixes: all public-facing blueprints (`public`, `planner`, `directory`, `content`, `leads`, `suppliers`) now live under `/<lang>/` (e.g. `/en/`, `/de/`); internal blueprints (`auth`, `dashboard`, `admin`, `billing`) unchanged; root `/` detects language from cookie / Accept-Language header and 301-redirects; legacy URLs (`/terms`, `/privacy`, etc.) redirect to `/en/` equivalents
- German legal pages: full DSGVO-compliant `Datenschutzerklärung` (`/de/privacy`), `AGB` (`/de/terms`), and `Impressum` (`/de/imprint`) per § 5 DDG — populated with Hendrik Dreesmann's details, Kleinunternehmer § 19 UStG, Oldenburg address
- Rewritten English legal pages (`/en/terms`, `/en/privacy`, `/en/imprint`) with expanded GDPR sections, correct controller identity, proper data-processing details (Umami self-hosted, Paddle, Resend with SCCs), and German-law jurisdiction
- Language toggle (`EN | DE`) in footer; hreflang `en`, `de`, and `x-default` tags in `<head>` on all lang-prefixed pages
- `lang` cookie (1-year, SameSite=Lax) persisted on first visit; `lang` and `t` (translation dict) injected into every template context
- `i18n.py`: flat translation dicts for ~20 nav/footer keys in `en` and `de`; `LANG_BLUEPRINTS` and `SUPPORTED_LANGS` constants
- `sitemap.xml` and `robots.txt` moved to app-level root routes (not under `/<lang>`); sitemap now includes both language variants of every SEO page
- Cookie consent banner: fixed bottom bar with "Accept all" and "Manage preferences" (toggle for functional/A/B cookies); consent stored in `cookie_consent` cookie for 1 year; "Manage Cookies" link added to footer Legal section
### Changed
- Defer Paddle.js loading to only the 3 pages that use checkout (export, supplier signup, supplier dashboard) — removed from global `base.html` head; all other pages no longer receive Paddle's third-party cookies
- Gate A/B test cookie (`ab_*`) on functional cookie consent: variant is still picked per-request for rendering, but the cookie is only persisted when the visitor has accepted functional cookies
- Privacy policy section 6 (Cookies): full disclosure of all cookie categories (essential, functional, payment); fix "Plausible" → "Umami" in service providers list
### Changed
- Auto-create Resend audiences per blueprint: `capture_waitlist_email()` now derives the audience name from `request.blueprints[0]` (e.g., `waitlist-auth`, `waitlist-suppliers`) and lazily creates audiences via the Resend API on first use, caching IDs in a new `resend_audiences` table; removes `RESEND_AUDIENCE_WAITLIST` env var — only `RESEND_API_KEY` needed
### Added
- Waitlist mode (lean startup smoke test): `WAITLIST_MODE` config flag intercepts `/auth/signup`, `/suppliers/signup`, and `/planner/export` to capture emails or show "coming soon" messaging before Paddle billing is ready; confirmation emails sent via Resend; optional `RESEND_AUDIENCE_WAITLIST` for bulk launch blast; flip flag to `false` and all flows revert to normal
### Changed
- Redesign padel racket SVG logo/favicon: filled dark silhouette with white punched-through holes (3×3 grid), proper throat tapering to handle at bottom, grip tape lines — replaces outline-only stroke approach; bump favicon cache to v4
### Added
- Simple A/B testing with `@ab_test` decorator and Umami `data-tag` integration
- SEO defaults in `base.html`: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed
- `robots.txt` route: disallows `/admin/`, `/auth/`, `/dashboard/`, `/billing/`, `/directory/results`; includes Sitemap reference
- Meta descriptions and OG tags on all public pages: about, terms, privacy, pricing, features, suppliers, directory, supplier detail
- `og:image` on landing and features pages pointing to planner screenshot
- JSON-LD Organization schema on homepage and supplier detail pages
- JSON-LD FAQPage schema on homepage (5 FAQ entries)
- JSON-LD Article schema on article detail pages
- Sitemap supplier slugs: all supplier detail pages now indexed
- Sitemap `<lastmod>` on all entries (static pages: today, articles: `COALESCE(updated_at, published_at)`, suppliers: `created_at`)
- `rel="preconnect"` for Google Fonts to reduce font-load latency
- `X-Robots-Tag: noindex` header on `/directory/results` HTMX partial
### Fixed
- Render-blocking Paddle.js: added `defer` attribute and wrapped `Paddle.Initialize` in `DOMContentLoaded` listener
- Render-blocking Chart.js on planner page: added `defer` attribute
- Broken `og:image` on planner page (`og-planner.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)
### 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