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

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

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

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

View File

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

View File

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