fix quote form state loss, admin errors, UI polish; add seed data and playwright tests

Quote wizard _accumulated hidden inputs used double-quote attribute delimiters
which broke on tojson output containing literal " characters — all step data was
lost by step 9. Admin dashboard crashed on credit_ledger queries referencing
wrong column names (amount/entry_type vs delta/event_type). Also: opaque nav bar,
pricing card button alignment, newsletter boost replaced with card color boost,
admin CRUD for suppliers/leads, dev seed data script, and playwright quote wizard
tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 09:37:13 +01:00
parent 7d3aa3141d
commit b99cd3c7d8
26 changed files with 1083 additions and 31 deletions

View File

@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- **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 ### Added — Phase 2: Scale the Marketplace — Supplier Dashboard + Business Plan PDF
- **Paddle.js overlay checkout** — migrated all checkout flows (billing, - **Paddle.js overlay checkout** — migrated all checkout flows (billing,

View File

@@ -89,7 +89,7 @@ async def get_dashboard_stats() -> dict:
"SELECT COUNT(*) as count FROM suppliers WHERE tier = 'pro'" "SELECT COUNT(*) as count FROM suppliers WHERE tier = 'pro'"
) )
total_credits_spent = await fetch_one( total_credits_spent = await fetch_one(
"SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM credit_ledger WHERE amount < 0" "SELECT COALESCE(SUM(ABS(delta)), 0) as total FROM credit_ledger WHERE delta < 0"
) )
leads_unlocked_by_suppliers = await fetch_one( leads_unlocked_by_suppliers = await fetch_one(
"SELECT COUNT(*) as count FROM lead_forwards" "SELECT COUNT(*) as count FROM lead_forwards"
@@ -510,6 +510,57 @@ async def lead_status(lead_id: int):
return redirect(url_for("admin.lead_detail", lead_id=lead_id)) return redirect(url_for("admin.lead_detail", lead_id=lead_id))
@bp.route("/leads/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def lead_new():
"""Create a new lead from admin."""
if request.method == "POST":
form = await request.form
contact_name = form.get("contact_name", "").strip()
contact_email = form.get("contact_email", "").strip()
facility_type = form.get("facility_type", "indoor")
court_count = int(form.get("court_count", 6) or 6)
country = form.get("country", "")
city = form.get("city", "").strip()
timeline = form.get("timeline", "")
budget_estimate = int(form.get("budget_estimate", 0) or 0)
stakeholder_type = form.get("stakeholder_type", "")
heat_score = form.get("heat_score", "warm")
status = form.get("status", "new")
if not contact_name or not contact_email:
await flash("Name and email are required.", "error")
return await render_template(
"admin/lead_form.html", data=dict(form), statuses=LEAD_STATUSES,
)
from ..credits import HEAT_CREDIT_COSTS
credit_cost = HEAT_CREDIT_COSTS.get(heat_score, 8)
now = datetime.utcnow().isoformat()
verified_at = now if status != "pending_verification" else None
lead_id = await execute(
"""INSERT INTO lead_requests
(lead_type, facility_type, court_count, country, location, timeline,
budget_estimate, stakeholder_type, heat_score, status,
contact_name, contact_email, contact_phone, contact_company,
credit_cost, verified_at, created_at)
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
facility_type, court_count, country, city, timeline,
budget_estimate, stakeholder_type, heat_score, status,
contact_name, contact_email,
form.get("contact_phone", ""), form.get("contact_company", ""),
credit_cost, verified_at, now,
),
)
await flash(f"Lead #{lead_id} created.", "success")
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
return await render_template("admin/lead_form.html", data={}, statuses=LEAD_STATUSES)
@bp.route("/leads/<int:lead_id>/forward", methods=["POST"]) @bp.route("/leads/<int:lead_id>/forward", methods=["POST"])
@admin_required @admin_required
@csrf_protect @csrf_protect
@@ -690,6 +741,53 @@ async def supplier_detail(supplier_id: int):
) )
@bp.route("/suppliers/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def supplier_new():
"""Create a new supplier from admin."""
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
slug = form.get("slug", "").strip()
country_code = form.get("country_code", "").strip().upper()
city = form.get("city", "").strip()
region = form.get("region", "Europe")
category = form.get("category", "manufacturer")
tier = form.get("tier", "free")
website = form.get("website", "").strip()
description = form.get("description", "").strip()
contact_name = form.get("contact_name", "").strip()
contact_email = form.get("contact_email", "").strip()
if not name or not country_code:
await flash("Name and country code are required.", "error")
return await render_template("admin/supplier_form.html", data=dict(form))
if not slug:
slug = name.lower().replace(" ", "-").replace("&", "and").replace(",", "")
# Check slug uniqueness
existing = await fetch_one("SELECT 1 FROM suppliers WHERE slug = ?", (slug,))
if existing:
await flash(f"Slug '{slug}' already exists.", "error")
return await render_template("admin/supplier_form.html", data=dict(form))
now = datetime.utcnow().isoformat()
supplier_id = await execute(
"""INSERT INTO suppliers
(name, slug, country_code, city, region, website, description, category,
tier, contact_name, contact_email, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(name, slug, country_code, city, region, website, description,
category, tier, contact_name, contact_email, now),
)
await flash(f"Supplier '{name}' created.", "success")
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
return await render_template("admin/supplier_form.html", data={})
@bp.route("/suppliers/<int:supplier_id>/credits", methods=["POST"]) @bp.route("/suppliers/<int:supplier_id>/credits", methods=["POST"])
@admin_required @admin_required
@csrf_protect @csrf_protect

View File

@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12" style="max-width:640px">
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a>
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
<form method="post" class="card" style="padding:1.5rem">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="text-lg mb-3">Contact</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Full Name *</label>
<input type="text" name="contact_name" class="form-input" required value="{{ data.get('contact_name', '') }}">
</div>
<div>
<label class="form-label">Email *</label>
<input type="email" name="contact_email" class="form-input" required value="{{ data.get('contact_email', '') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Phone</label>
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
</div>
<div>
<label class="form-label">Company</label>
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
</div>
</div>
<hr style="margin:1.5rem 0">
<h2 class="text-lg mb-3">Project</h2>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Facility Type *</label>
<select name="facility_type" class="form-input" required>
<option value="indoor">Indoor</option>
<option value="outdoor">Outdoor</option>
<option value="both">Both</option>
</select>
</div>
<div>
<label class="form-label">Courts</label>
<input type="number" name="court_count" class="form-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
</div>
<div>
<label class="form-label">Budget (&euro;)</label>
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Country *</label>
<select name="country" class="form-input" required>
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('GB', 'United Kingdom'), ('PT', 'Portugal'), ('AE', 'UAE'), ('SA', 'Saudi Arabia')] %}
<option value="{{ code }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">City</label>
<input type="text" name="city" class="form-input" value="{{ data.get('city', '') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Timeline *</label>
<select name="timeline" class="form-input" required>
<option value="asap">ASAP</option>
<option value="3-6mo">3-6 Months</option>
<option value="6-12mo">6-12 Months</option>
<option value="12+mo">12+ Months</option>
</select>
</div>
<div>
<label class="form-label">Stakeholder Type *</label>
<select name="stakeholder_type" class="form-input" required>
<option value="entrepreneur">Entrepreneur</option>
<option value="tennis_club">Tennis Club</option>
<option value="municipality">Municipality</option>
<option value="developer">Developer</option>
<option value="operator">Operator</option>
<option value="architect">Architect</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Heat Score</label>
<select name="heat_score" class="form-input">
<option value="hot">Hot</option>
<option value="warm" selected>Warm</option>
<option value="cool">Cool</option>
</select>
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-input">
{% for s in statuses %}
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s }}</option>
{% endfor %}
</select>
</div>
</div>
<button type="submit" class="btn" style="width:100%">Create Lead</button>
</form>
</main>
{% endblock %}

View File

@@ -14,7 +14,10 @@
{% endif %} {% endif %}
</p> </p>
</div> </div>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back to Dashboard</a> <div class="flex gap-2">
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
</div>
</header> </header>
<!-- Filters --> <!-- Filters -->

View File

@@ -119,12 +119,12 @@
<tbody> <tbody>
{% for entry in ledger %} {% for entry in ledger %}
<tr> <tr>
<td>{{ entry.entry_type }}</td> <td>{{ entry.event_type }}</td>
<td> <td>
{% if entry.amount > 0 %} {% if entry.delta > 0 %}
<span style="color:#16A34A">+{{ entry.amount }}</span> <span style="color:#16A34A">+{{ entry.delta }}</span>
{% else %} {% else %}
<span style="color:#DC2626">{{ entry.amount }}</span> <span style="color:#DC2626">{{ entry.delta }}</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ entry.balance_after }}</td> <td>{{ entry.balance_after }}</td>

View File

@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}New Supplier - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12" style="max-width:640px">
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">&larr; All Suppliers</a>
<h1 class="text-2xl mt-2 mb-6">Create Supplier</h1>
<form method="post" class="card" style="padding:1.5rem">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="margin-bottom:1rem">
<label class="form-label">Company Name *</label>
<input type="text" name="name" class="form-input" required value="{{ data.get('name', '') }}">
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Slug *</label>
<input type="text" name="slug" class="form-input" required value="{{ data.get('slug', '') }}"
placeholder="company-name (auto-generated if blank)">
<p class="form-hint">URL-safe identifier. Leave blank to auto-generate from name.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Country Code *</label>
<input type="text" name="country_code" class="form-input" required maxlength="3"
value="{{ data.get('country_code', '') }}" placeholder="DE">
</div>
<div>
<label class="form-label">City</label>
<input type="text" name="city" class="form-input" value="{{ data.get('city', '') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Region *</label>
<select name="region" class="form-input" required>
{% for r in ['Europe', 'North America', 'Latin America', 'Middle East', 'Asia Pacific', 'Africa'] %}
<option value="{{ r }}" {{ 'selected' if data.get('region') == r }}>{{ r }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Category *</label>
<select name="category" class="form-input" required>
{% for c in ['manufacturer', 'turnkey', 'consultant', 'hall_builder', 'turf', 'lighting', 'software', 'industry_body', 'franchise'] %}
<option value="{{ c }}" {{ 'selected' if data.get('category') == c }}>{{ c | replace('_', ' ') | title }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Tier</label>
<select name="tier" class="form-input">
{% for t in ['free', 'growth', 'pro'] %}
<option value="{{ t }}" {{ 'selected' if data.get('tier') == t }}>{{ t | capitalize }}</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Website</label>
<input type="url" name="website" class="form-input" value="{{ data.get('website', '') }}" placeholder="https://...">
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Description</label>
<textarea name="description" class="form-input" rows="3">{{ data.get('description', '') }}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Contact Name</label>
<input type="text" name="contact_name" class="form-input" value="{{ data.get('contact_name', '') }}">
</div>
<div>
<label class="form-label">Contact Email</label>
<input type="email" name="contact_email" class="form-input" value="{{ data.get('contact_email', '') }}">
</div>
</div>
<button type="submit" class="btn" style="width:100%">Create Supplier</button>
</form>
</main>
{% endblock %}

View File

@@ -13,7 +13,10 @@
&middot; {{ supplier_stats.pro }} Pro &middot; {{ supplier_stats.pro }} Pro
</p> </p>
</div> </div>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back to Dashboard</a> <div class="flex gap-2">
<a href="{{ url_for('admin.supplier_new') }}" class="btn btn-sm">+ New Supplier</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
</div>
</header> </header>
<!-- Filters --> <!-- Filters -->

View File

@@ -102,6 +102,28 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
total_pages = max(1, (total + per_page - 1) // per_page) total_pages = max(1, (total + per_page - 1) // per_page)
# Fetch card_color boosts for displayed suppliers
card_colors = {}
if suppliers:
supplier_ids = [s["id"] for s in suppliers]
placeholders = ",".join("?" * len(supplier_ids))
color_rows = await fetch_all(
f"""SELECT supplier_id, metadata FROM supplier_boosts
WHERE supplier_id IN ({placeholders})
AND boost_type = 'card_color' AND status = 'active'""",
tuple(supplier_ids),
)
import json
for row in color_rows:
meta = {}
if row["metadata"]:
try:
meta = json.loads(row["metadata"])
except (json.JSONDecodeError, TypeError):
pass
if meta.get("color"):
card_colors[row["supplier_id"]] = meta["color"]
return { return {
"suppliers": suppliers, "suppliers": suppliers,
"q": q, "q": q,
@@ -114,6 +136,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
"now": now, "now": now,
"country_labels": COUNTRY_LABELS, "country_labels": COUNTRY_LABELS,
"category_labels": CATEGORY_LABELS, "category_labels": CATEGORY_LABELS,
"card_colors": card_colors,
} }

View File

@@ -31,7 +31,7 @@
{% for s in suppliers %} {% for s in suppliers %}
{# --- Pro tier card --- #} {# --- Pro tier card --- #}
{% if s.tier == 'pro' %} {% if s.tier == 'pro' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--pro {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %} {% if s.highlight %}dir-card--highlight{% endif %}" style="text-decoration:none;color:inherit;display:block"> <a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--pro {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %} {% if s.highlight %}dir-card--highlight{% endif %}" style="text-decoration:none;color:inherit;display:block{% if card_colors.get(s.id) %};border-color:{{ card_colors[s.id] }};border-width:2px{% endif %}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %} {% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head"> <div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
@@ -55,7 +55,7 @@
{# --- Growth tier card --- #} {# --- Growth tier card --- #}
{% elif s.tier == 'growth' %} {% elif s.tier == 'growth' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--growth {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}" style="text-decoration:none;color:inherit;display:block"> <a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--growth {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}" style="text-decoration:none;color:inherit;display:block{% if card_colors.get(s.id) %};border-color:{{ card_colors[s.id] }};border-width:2px{% endif %}">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %} {% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head"> <div class="dir-card__head">
<h3 class="dir-card__name">{{ s.name }}</h3> <h3 class="dir-card__name">{{ s.name }}</h3>

View File

@@ -16,7 +16,7 @@
<form hx-post="{{ url_for('leads.quote_step', step=1) }}" <form hx-post="{{ url_for('leads.quote_step', step=1) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="facility_type" value="{{ data.facility_type }}"> <input type="hidden" name="facility_type" value="{{ data.facility_type }}">
@@ -30,7 +30,7 @@
{# Direct visit — show full form #} {# Direct visit — show full form #}
<form hx-post="{{ url_for('leads.quote_step', step=1) }}" <form hx-post="{{ url_for('leads.quote_step', step=1) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Your Project</h2> <h2 class="q-step-title">Your Project</h2>

View File

@@ -1,7 +1,7 @@
{# Step 2: Location #} {# Step 2: Location #}
<form hx-post="{{ url_for('leads.quote_step', step=2) }}" <form hx-post="{{ url_for('leads.quote_step', step=2) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Location</h2> <h2 class="q-step-title">Location</h2>

View File

@@ -1,7 +1,7 @@
{# Step 3: Build Context #} {# Step 3: Build Context #}
<form hx-post="{{ url_for('leads.quote_step', step=3) }}" <form hx-post="{{ url_for('leads.quote_step', step=3) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Build Context</h2> <h2 class="q-step-title">Build Context</h2>

View File

@@ -1,7 +1,7 @@
{# Step 4: Project Phase #} {# Step 4: Project Phase #}
<form hx-post="{{ url_for('leads.quote_step', step=4) }}" <form hx-post="{{ url_for('leads.quote_step', step=4) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Project Phase</h2> <h2 class="q-step-title">Project Phase</h2>

View File

@@ -1,7 +1,7 @@
{# Step 5: Timeline #} {# Step 5: Timeline #}
<form hx-post="{{ url_for('leads.quote_step', step=5) }}" <form hx-post="{{ url_for('leads.quote_step', step=5) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Timeline</h2> <h2 class="q-step-title">Timeline</h2>

View File

@@ -1,7 +1,7 @@
{# Step 6: Financing #} {# Step 6: Financing #}
<form hx-post="{{ url_for('leads.quote_step', step=6) }}" <form hx-post="{{ url_for('leads.quote_step', step=6) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Financing</h2> <h2 class="q-step-title">Financing</h2>

View File

@@ -1,7 +1,7 @@
{# Step 7: About You #} {# Step 7: About You #}
<form hx-post="{{ url_for('leads.quote_step', step=7) }}" <form hx-post="{{ url_for('leads.quote_step', step=7) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">About You</h2> <h2 class="q-step-title">About You</h2>

View File

@@ -1,7 +1,7 @@
{# Step 8: Services Needed #} {# Step 8: Services Needed #}
<form hx-post="{{ url_for('leads.quote_step', step=8) }}" <form hx-post="{{ url_for('leads.quote_step', step=8) }}"
hx-target="#quote-step" hx-swap="innerHTML"> hx-target="#quote-step" hx-swap="innerHTML">
<input type="hidden" name="_accumulated" value="{{ data | tojson }}"> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="q-step-title">Services Needed</h2> <h2 class="q-step-title">Services Needed</h2>

View File

@@ -272,6 +272,7 @@ CREATE TABLE IF NOT EXISTS supplier_boosts (
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
starts_at TEXT NOT NULL, starts_at TEXT NOT NULL,
expires_at TEXT, expires_at TEXT,
metadata TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );

View File

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

View File

@@ -95,6 +95,7 @@
.pricing-card { .pricing-card {
border: 1px solid #E2E8F0; border-radius: 16px; padding: 1.5rem; border: 1px solid #E2E8F0; border-radius: 16px; padding: 1.5rem;
background: white; position: relative; background: white; position: relative;
display: flex; flex-direction: column;
} }
.pricing-card--highlight { .pricing-card--highlight {
border-color: #1D4ED8; border-width: 2px; border-color: #1D4ED8; border-width: 2px;
@@ -110,13 +111,13 @@
.pricing-card .price { font-size: 1.75rem; font-weight: 800; color: #1E293B; margin-bottom: 0.25rem; } .pricing-card .price { font-size: 1.75rem; font-weight: 800; color: #1E293B; margin-bottom: 0.25rem; }
.pricing-card .price span { font-size: 0.875rem; font-weight: 400; color: #64748B; } .pricing-card .price span { font-size: 0.875rem; font-weight: 400; color: #64748B; }
.pricing-card .credits-inc { font-size: 0.8125rem; color: #1D4ED8; font-weight: 600; margin-bottom: 1rem; } .pricing-card .credits-inc { font-size: 0.8125rem; color: #1D4ED8; font-weight: 600; margin-bottom: 1rem; }
.pricing-card ul { list-style: none; padding: 0; margin: 0 0 1.5rem; } .pricing-card ul { list-style: none; padding: 0; margin: 0 0 1.5rem; flex-grow: 1; }
.pricing-card li { .pricing-card li {
font-size: 0.8125rem; color: #475569; padding: 4px 0; font-size: 0.8125rem; color: #475569; padding: 4px 0;
display: flex; align-items: flex-start; gap: 6px; display: flex; align-items: flex-start; gap: 6px;
} }
.pricing-card li::before { content: "\2713"; color: #16A34A; font-weight: 700; flex-shrink: 0; } .pricing-card li::before { content: "\2713"; color: #16A34A; font-weight: 700; flex-shrink: 0; }
.pricing-card .btn { width: 100%; text-align: center; } .pricing-card .btn, .pricing-card .btn-outline { width: 100%; text-align: center; margin-top: auto; }
/* Boosts */ /* Boosts */
.boost-grid { .boost-grid {
@@ -401,8 +402,8 @@
<span class="boost-price">&euro;79/wk or &euro;199/mo</span> <span class="boost-price">&euro;79/wk or &euro;199/mo</span>
</div> </div>
<div class="boost-card"> <div class="boost-card">
<strong>Newsletter Feature</strong> <strong>Custom Card Color</strong>
<span class="boost-price">&euro;99/mo</span> <span class="boost-price">&euro;19/mo</span>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,437 @@
"""
Seed realistic test data for local development.
Creates suppliers, leads, a dev user, credit ledger entries, and lead forwards.
Usage:
uv run python -m padelnomics.scripts.seed_dev_data
"""
import json
import os
import sqlite3
import sys
from datetime import datetime, timedelta
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
def slugify(name: str) -> str:
return name.lower().replace(" ", "-").replace("&", "and").replace(",", "")
SUPPLIERS = [
{
"name": "PadelTech GmbH",
"slug": "padeltech-gmbh",
"country_code": "DE",
"city": "Munich",
"region": "Europe",
"website": "https://padeltech.example.com",
"description": "Premium padel court manufacturer with 15+ years experience in Central Europe.",
"category": "manufacturer",
"tier": "pro",
"credit_balance": 80,
"monthly_credits": 100,
"contact_name": "Hans Weber",
"contact_email": "hans@padeltech.example.com",
"years_in_business": 15,
"project_count": 120,
"service_area": "DE,AT,CH",
},
{
"name": "CourtBuild Spain",
"slug": "courtbuild-spain",
"country_code": "ES",
"city": "Madrid",
"region": "Europe",
"website": "https://courtbuild.example.com",
"description": "Turnkey padel facility builder serving Spain and Portugal.",
"category": "turnkey",
"tier": "growth",
"credit_balance": 25,
"monthly_credits": 30,
"contact_name": "Maria Garcia",
"contact_email": "maria@courtbuild.example.com",
"years_in_business": 8,
"project_count": 45,
"service_area": "ES,PT",
},
{
"name": "Nordic Padel Solutions",
"slug": "nordic-padel-solutions",
"country_code": "SE",
"city": "Stockholm",
"region": "Europe",
"website": "https://nordicpadel.example.com",
"description": "Indoor padel specialists for the Nordic climate.",
"category": "manufacturer",
"tier": "free",
"credit_balance": 0,
"monthly_credits": 0,
"contact_name": "Erik Lindqvist",
"contact_email": "erik@nordicpadel.example.com",
"years_in_business": 5,
"project_count": 20,
"service_area": "SE,DK,FI,NO",
},
{
"name": "PadelLux Consulting",
"slug": "padellux-consulting",
"country_code": "NL",
"city": "Amsterdam",
"region": "Europe",
"website": "https://padellux.example.com",
"description": "Independent padel consultants helping investors plan profitable facilities.",
"category": "consultant",
"tier": "growth",
"credit_balance": 18,
"monthly_credits": 30,
"contact_name": "Jan de Vries",
"contact_email": "jan@padellux.example.com",
"years_in_business": 3,
"project_count": 12,
"service_area": "NL,BE,DE",
},
{
"name": "Desert Padel FZE",
"slug": "desert-padel-fze",
"country_code": "AE",
"city": "Dubai",
"region": "Middle East",
"website": "https://desertpadel.example.com",
"description": "Outdoor and covered padel courts designed for extreme heat environments.",
"category": "turnkey",
"tier": "pro",
"credit_balance": 90,
"monthly_credits": 100,
"contact_name": "Ahmed Al-Rashid",
"contact_email": "ahmed@desertpadel.example.com",
"years_in_business": 6,
"project_count": 30,
"service_area": "AE,SA",
},
]
LEADS = [
{
"facility_type": "indoor",
"court_count": 6,
"country": "DE",
"city": "Berlin",
"timeline": "3-6mo",
"budget_estimate": 450000,
"heat_score": "hot",
"status": "new",
"contact_name": "Thomas Mueller",
"contact_email": "thomas@example.com",
"stakeholder_type": "entrepreneur",
"financing_status": "loan_approved",
"location_status": "lease_signed",
"decision_process": "solo",
},
{
"facility_type": "outdoor",
"court_count": 4,
"country": "ES",
"city": "Barcelona",
"timeline": "asap",
"budget_estimate": 280000,
"heat_score": "hot",
"status": "new",
"contact_name": "Carlos Ruiz",
"contact_email": "carlos@example.com",
"stakeholder_type": "tennis_club",
"financing_status": "self_funded",
"location_status": "permit_granted",
"decision_process": "partners",
},
{
"facility_type": "both",
"court_count": 8,
"country": "SE",
"city": "Gothenburg",
"timeline": "6-12mo",
"budget_estimate": 720000,
"heat_score": "warm",
"status": "new",
"contact_name": "Anna Svensson",
"contact_email": "anna@example.com",
"stakeholder_type": "developer",
"financing_status": "seeking",
"location_status": "location_found",
"decision_process": "committee",
},
{
"facility_type": "indoor",
"court_count": 4,
"country": "NL",
"city": "Rotterdam",
"timeline": "3-6mo",
"budget_estimate": 350000,
"heat_score": "warm",
"status": "new",
"contact_name": "Pieter van Dijk",
"contact_email": "pieter@example.com",
"stakeholder_type": "entrepreneur",
"financing_status": "self_funded",
"location_status": "converting_existing",
"decision_process": "solo",
},
{
"facility_type": "indoor",
"court_count": 10,
"country": "DE",
"city": "Hamburg",
"timeline": "asap",
"budget_estimate": 900000,
"heat_score": "hot",
"status": "forwarded",
"contact_name": "Lisa Braun",
"contact_email": "lisa@example.com",
"stakeholder_type": "operator",
"financing_status": "loan_approved",
"location_status": "permit_pending",
"decision_process": "partners",
},
{
"facility_type": "outdoor",
"court_count": 3,
"country": "AE",
"city": "Abu Dhabi",
"timeline": "12+mo",
"budget_estimate": 200000,
"heat_score": "cool",
"status": "new",
"contact_name": "Fatima Hassan",
"contact_email": "fatima@example.com",
"stakeholder_type": "municipality",
"financing_status": "not_started",
"location_status": "still_searching",
"decision_process": "committee",
},
{
"facility_type": "indoor",
"court_count": 6,
"country": "FR",
"city": "Lyon",
"timeline": "6-12mo",
"budget_estimate": 500000,
"heat_score": "warm",
"status": "new",
"contact_name": "Jean Dupont",
"contact_email": "jean@example.com",
"stakeholder_type": "entrepreneur",
"financing_status": "seeking",
"location_status": "location_found",
"decision_process": "solo",
},
{
"facility_type": "both",
"court_count": 12,
"country": "IT",
"city": "Milan",
"timeline": "3-6mo",
"budget_estimate": 1200000,
"heat_score": "hot",
"status": "new",
"contact_name": "Marco Rossi",
"contact_email": "marco@example.com",
"stakeholder_type": "developer",
"financing_status": "loan_approved",
"location_status": "permit_granted",
"decision_process": "partners",
},
{
"facility_type": "indoor",
"court_count": 4,
"country": "GB",
"city": "Manchester",
"timeline": "6-12mo",
"budget_estimate": 400000,
"heat_score": "warm",
"status": "pending_verification",
"contact_name": "James Wilson",
"contact_email": "james@example.com",
"stakeholder_type": "tennis_club",
"financing_status": "seeking",
"location_status": "converting_existing",
"decision_process": "committee",
},
{
"facility_type": "outdoor",
"court_count": 2,
"country": "PT",
"city": "Lisbon",
"timeline": "12+mo",
"budget_estimate": 150000,
"heat_score": "cool",
"status": "new",
"contact_name": "Sofia Costa",
"contact_email": "sofia@example.com",
"stakeholder_type": "entrepreneur",
"financing_status": "not_started",
"location_status": "still_searching",
"decision_process": "solo",
},
]
def main():
db_path = DATABASE_PATH
if not Path(db_path).exists():
print(f"ERROR: Database not found at {db_path}. Run migrations first.")
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
now = datetime.utcnow()
# 1. Create dev user
print("Creating dev user (dev@localhost)...")
existing = conn.execute("SELECT id FROM users WHERE email = 'dev@localhost'").fetchone()
if existing:
dev_user_id = existing["id"]
print(f" Already exists (id={dev_user_id})")
else:
cursor = conn.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("dev@localhost", "Dev User", now.isoformat()),
)
dev_user_id = cursor.lastrowid
print(f" Created (id={dev_user_id})")
# 2. Seed suppliers
print(f"\nSeeding {len(SUPPLIERS)} suppliers...")
supplier_ids = {}
for s in SUPPLIERS:
existing = conn.execute("SELECT id FROM suppliers WHERE slug = ?", (s["slug"],)).fetchone()
if existing:
supplier_ids[s["slug"]] = existing["id"]
print(f" {s['name']} already exists (id={existing['id']})")
continue
cursor = conn.execute(
"""INSERT INTO suppliers
(name, slug, country_code, city, region, website, description, category,
tier, credit_balance, monthly_credits, contact_name, contact_email,
years_in_business, project_count, service_area, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
s["name"], s["slug"], s["country_code"], s["city"], s["region"],
s["website"], s["description"], s["category"], s["tier"],
s["credit_balance"], s["monthly_credits"], s["contact_name"],
s["contact_email"], s["years_in_business"], s["project_count"],
s["service_area"], now.isoformat(),
),
)
supplier_ids[s["slug"]] = cursor.lastrowid
print(f" {s['name']} -> id={cursor.lastrowid}")
# 3. Claim 2 suppliers to dev user (growth + pro)
print("\nClaiming PadelTech GmbH and CourtBuild Spain to dev@localhost...")
for slug in ("padeltech-gmbh", "courtbuild-spain"):
sid = supplier_ids.get(slug)
if sid:
conn.execute(
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
(dev_user_id, now.isoformat(), sid),
)
# 4. Seed leads
print(f"\nSeeding {len(LEADS)} leads...")
lead_ids = []
for i, lead in enumerate(LEADS):
from padelnomics.credits import HEAT_CREDIT_COSTS
credit_cost = HEAT_CREDIT_COSTS.get(lead["heat_score"], 8)
verified_at = (now - timedelta(hours=i * 2)).isoformat() if lead["status"] not in ("pending_verification",) else None
cursor = conn.execute(
"""INSERT INTO lead_requests
(user_id, lead_type, facility_type, court_count, country, location, timeline,
budget_estimate, heat_score, status, contact_name, contact_email,
stakeholder_type, financing_status, location_status, decision_process,
credit_cost, verified_at, created_at)
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
dev_user_id, lead["facility_type"], lead["court_count"], lead["country"],
lead["city"], lead["timeline"], lead["budget_estimate"],
lead["heat_score"], lead["status"], lead["contact_name"],
lead["contact_email"], lead["stakeholder_type"],
lead["financing_status"], lead["location_status"],
lead["decision_process"], credit_cost, verified_at,
(now - timedelta(hours=i * 3)).isoformat(),
),
)
lead_ids.append(cursor.lastrowid)
print(f" Lead #{cursor.lastrowid}: {lead['contact_name']} ({lead['heat_score']}, {lead['country']})")
# 5. Add credit ledger entries for claimed suppliers
print("\nAdding credit ledger entries...")
for slug in ("padeltech-gmbh", "courtbuild-spain"):
sid = supplier_ids.get(slug)
if not sid:
continue
# Monthly allocation
conn.execute(
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
VALUES (?, ?, ?, 'monthly_allocation', 'Monthly refill', ?)""",
(sid, 100 if slug == "padeltech-gmbh" else 30,
100 if slug == "padeltech-gmbh" else 30,
(now - timedelta(days=28)).isoformat()),
)
# Admin adjustment
conn.execute(
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
VALUES (?, ?, ?, 'admin_adjustment', 'Welcome bonus', ?)""",
(sid, 10, 110 if slug == "padeltech-gmbh" else 40,
(now - timedelta(days=25)).isoformat()),
)
print(f" {slug}: 2 ledger entries")
# 6. Add lead forwards for testing
print("\nAdding lead forwards...")
padeltech_id = supplier_ids.get("padeltech-gmbh")
if padeltech_id and len(lead_ids) >= 2:
for lead_id in lead_ids[:2]:
existing = conn.execute(
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
(lead_id, padeltech_id),
).fetchone()
if not existing:
conn.execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
VALUES (?, ?, ?, 'sent', ?)""",
(lead_id, padeltech_id, 35, (now - timedelta(hours=6)).isoformat()),
)
conn.execute(
"UPDATE lead_requests SET unlock_count = unlock_count + 1 WHERE id = ?",
(lead_id,),
)
# Ledger entry for spend
conn.execute(
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
VALUES (?, -35, ?, 'lead_unlock', ?, ?, ?)""",
(padeltech_id, 80, lead_id, f"Unlocked lead #{lead_id}",
(now - timedelta(hours=6)).isoformat()),
)
print(f" PadelTech unlocked lead #{lead_id}")
conn.commit()
conn.close()
print(f"\nDone! Seed data written to {db_path}")
print(" Login: dev@localhost (use magic link or admin impersonation)")
print(" Admin: /admin with password 'admin'")
if __name__ == "__main__":
main()

View File

@@ -75,9 +75,9 @@ PRODUCTS = [
"billing_type": "subscription", "billing_type": "subscription",
}, },
{ {
"key": "boost_newsletter", "key": "boost_card_color",
"name": "Boost: Newsletter Feature", "name": "Boost: Custom Card Color",
"price": 9900, "price": 1900,
"currency": CurrencyCode.EUR, "currency": CurrencyCode.EUR,
"interval": "month", "interval": "month",
"billing_type": "subscription", "billing_type": "subscription",

View File

@@ -57,14 +57,12 @@
/* ── Component classes ── */ /* ── Component classes ── */
@layer components { @layer components {
/* ── Navigation (Zillow-style: links | logo | links) ── */ /* ── Navigation ── */
.nav-bar { .nav-bar {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
background: rgba(255,255,255,0.85); background: #ffffff;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid #E2E8F0; border-bottom: 1px solid #E2E8F0;
} }
.nav-inner { .nav-inner {

View File

@@ -60,7 +60,7 @@ BOOST_OPTIONS = [
{"key": "boost_logo", "type": "logo", "name": "Logo", "price": 29, "desc": "Display your company logo"}, {"key": "boost_logo", "type": "logo", "name": "Logo", "price": 29, "desc": "Display your company logo"},
{"key": "boost_highlight", "type": "highlight", "name": "Highlight", "price": 39, "desc": "Blue highlighted card border"}, {"key": "boost_highlight", "type": "highlight", "name": "Highlight", "price": 39, "desc": "Blue highlighted card border"},
{"key": "boost_verified", "type": "verified", "name": "Verified Badge", "price": 49, "desc": "Verified checkmark badge"}, {"key": "boost_verified", "type": "verified", "name": "Verified Badge", "price": 49, "desc": "Verified checkmark badge"},
{"key": "boost_newsletter", "type": "newsletter", "name": "Newsletter Feature", "price": 99, "desc": "Featured in our monthly newsletter"}, {"key": "boost_card_color", "type": "card_color", "name": "Custom Card Color", "price": 19, "desc": "Stand out with a custom border color on your directory listing"},
] ]
CREDIT_PACK_OPTIONS = [ CREDIT_PACK_OPTIONS = [

View File

@@ -42,7 +42,6 @@
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<!-- Navigation — Zillow-style: [demand links] [logo] [supply links + auth] -->
<nav class="nav-bar"> <nav class="nav-bar">
<div class="nav-inner"> <div class="nav-inner">
<!-- Left: demand / buy side --> <!-- Left: demand / buy side -->

View File

@@ -0,0 +1,245 @@
"""
Playwright tests for the 9-step quote wizard flow.
Tests the full happy path, back navigation with data preservation,
and validation error handling.
Run explicitly with:
uv run pytest -m visual tests/test_quote_wizard.py -v
"""
import asyncio
import multiprocessing
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from playwright.sync_api import expect, sync_playwright
from padelnomics import core
from padelnomics.app import create_app
pytestmark = pytest.mark.visual
SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
SCREENSHOTS_DIR.mkdir(exist_ok=True)
def _run_server(ready_event):
"""Run the Quart dev server in a separate process."""
import aiosqlite
async def _serve():
schema_path = (
Path(__file__).parent.parent
/ "src"
/ "padelnomics"
/ "migrations"
/ "schema.sql"
)
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_path.read_text())
await conn.commit()
core._db = conn
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock):
app = create_app()
app.config["TESTING"] = True
ready_event.set()
await app.run_task(host="127.0.0.1", port=5112)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
"""Start a live Quart server on port 5112 for quote wizard tests."""
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield "http://127.0.0.1:5112"
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
"""Launch a headless Chromium browser."""
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture
def page(browser):
"""Create a page for the quote wizard tests."""
pg = browser.new_page(viewport={"width": 1280, "height": 900})
yield pg
pg.close()
def _fill_step_1(page):
"""Fill step 1: facility type = indoor."""
page.locator("input[name='facility_type'][value='indoor']").check()
def _fill_step_2(page):
"""Fill step 2: country = Germany."""
page.select_option("select[name='country']", "DE")
def _fill_step_5(page):
"""Fill step 5: timeline = 3-6mo."""
page.locator("input[name='timeline'][value='3-6mo']").check()
def _fill_step_7(page):
"""Fill step 7: stakeholder type = entrepreneur."""
page.locator("input[name='stakeholder_type'][value='entrepreneur']").check()
def _fill_step_9(page):
"""Fill step 9: contact details."""
page.fill("input[name='contact_name']", "Test User")
page.fill("input[name='contact_email']", "test@example.com")
page.locator("input[name='consent']").check()
def _click_next(page):
"""Click the Next button and wait for HTMX swap."""
page.locator("button.q-btn-next").click()
page.wait_for_timeout(500)
def _click_back(page):
"""Click the Back button and wait for HTMX swap."""
page.locator("button.q-btn-back").click()
page.wait_for_timeout(500)
def test_quote_wizard_full_flow(live_server, page):
"""Complete all 9 steps and submit — verify success page shown."""
page.goto(f"{live_server}/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: Your Project
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_fill_step_1(page)
_click_next(page)
# Step 2: Location
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
_fill_step_2(page)
_click_next(page)
# Step 3: Build Context (optional — just click next)
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
_click_next(page)
# Step 4: Project Phase (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_next(page)
# Step 5: Timeline
expect(page.locator("h2.q-step-title")).to_contain_text("Timeline")
_fill_step_5(page)
_click_next(page)
# Step 6: Financing (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
_click_next(page)
# Step 7: About You
expect(page.locator("h2.q-step-title")).to_contain_text("About You")
_fill_step_7(page)
_click_next(page)
# Step 8: Services Needed (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed")
_click_next(page)
# Step 9: Contact Details
expect(page.locator("h2.q-step-title")).to_contain_text("Contact Details")
_fill_step_9(page)
# Submit the form
page.locator("button.q-btn-submit").click()
page.wait_for_load_state("networkidle")
# Should see either success page or verification sent page (both acceptable)
body_text = page.locator("body").inner_text()
assert "matched" in body_text.lower() or "check your email" in body_text.lower() or "verify" in body_text.lower(), \
f"Expected success or verification page, got: {body_text[:200]}"
page.screenshot(path=str(SCREENSHOTS_DIR / "quote_wizard_submitted.png"), full_page=True)
def test_quote_wizard_back_navigation(live_server, page):
"""Go forward 3 steps, go back, verify data preserved in form fields."""
page.goto(f"{live_server}/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: select Indoor
_fill_step_1(page)
_click_next(page)
# Step 2: select Germany
_fill_step_2(page)
page.fill("input[name='city']", "Berlin")
_click_next(page)
# Step 3: select a build context
page.locator("input[name='build_context'][value='new_standalone']").check()
_click_next(page)
# Step 4: now go back to step 3
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_back(page)
# Verify we're on step 3 and build_context is preserved
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
checked = page.locator("input[name='build_context'][value='new_standalone']").is_checked()
assert checked, "Build context 'new_standalone' should still be checked after going back"
# Go back to step 2 and verify data preserved
_click_back(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
country_val = page.locator("select[name='country']").input_value()
assert country_val == "DE", f"Country should be DE, got {country_val}"
city_val = page.locator("input[name='city']").input_value()
assert city_val == "Berlin", f"City should be Berlin, got {city_val}"
def test_quote_wizard_validation_errors(live_server, page):
"""Skip a required field on step 1 — verify error shown on same step."""
page.goto(f"{live_server}/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: DON'T select facility_type, just click Next
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_click_next(page)
# Should still be on step 1 with an error hint
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()
# Now fill the field and proceed — should work
_fill_step_1(page)
_click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
# Skip country (required on step 2) — should stay on step 2
_click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()