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:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [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
|
||||
|
||||
- **Paddle.js overlay checkout** — migrated all checkout flows (billing,
|
||||
|
||||
@@ -89,7 +89,7 @@ async def get_dashboard_stats() -> dict:
|
||||
"SELECT COUNT(*) as count FROM suppliers WHERE tier = 'pro'"
|
||||
)
|
||||
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(
|
||||
"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))
|
||||
|
||||
|
||||
@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"])
|
||||
@admin_required
|
||||
@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"])
|
||||
@admin_required
|
||||
@csrf_protect
|
||||
|
||||
117
padelnomics/src/padelnomics/admin/templates/admin/lead_form.html
Normal file
117
padelnomics/src/padelnomics/admin/templates/admin/lead_form.html
Normal 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">← 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 (€)</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 %}
|
||||
@@ -14,7 +14,10 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Filters -->
|
||||
|
||||
@@ -119,12 +119,12 @@
|
||||
<tbody>
|
||||
{% for entry in ledger %}
|
||||
<tr>
|
||||
<td>{{ entry.entry_type }}</td>
|
||||
<td>{{ entry.event_type }}</td>
|
||||
<td>
|
||||
{% if entry.amount > 0 %}
|
||||
<span style="color:#16A34A">+{{ entry.amount }}</span>
|
||||
{% if entry.delta > 0 %}
|
||||
<span style="color:#16A34A">+{{ entry.delta }}</span>
|
||||
{% else %}
|
||||
<span style="color:#DC2626">{{ entry.amount }}</span>
|
||||
<span style="color:#DC2626">{{ entry.delta }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.balance_after }}</td>
|
||||
|
||||
@@ -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">← 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 %}
|
||||
@@ -13,7 +13,10 @@
|
||||
· {{ supplier_stats.pro }} Pro
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Filters -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 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 {
|
||||
"suppliers": suppliers,
|
||||
"q": q,
|
||||
@@ -114,6 +136,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
|
||||
"now": now,
|
||||
"country_labels": COUNTRY_LABELS,
|
||||
"category_labels": CATEGORY_LABELS,
|
||||
"card_colors": card_colors,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{% for s in suppliers %}
|
||||
{# --- Pro tier card --- #}
|
||||
{% 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 %}
|
||||
<div class="dir-card__head">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
{# --- Growth tier card --- #}
|
||||
{% 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 %}
|
||||
<div class="dir-card__head">
|
||||
<h3 class="dir-card__name">{{ s.name }}</h3>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=1) }}"
|
||||
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="facility_type" value="{{ data.facility_type }}">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{# Direct visit — show full form #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=1) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Your Project</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 2: Location #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=2) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Location</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 3: Build Context #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=3) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Build Context</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 4: Project Phase #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=4) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Project Phase</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 5: Timeline #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=5) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Timeline</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 6: Financing #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=6) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Financing</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 7: About You #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=7) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">About You</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Step 8: Services Needed #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=8) }}"
|
||||
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() }}">
|
||||
|
||||
<h2 class="q-step-title">Services Needed</h2>
|
||||
|
||||
@@ -272,6 +272,7 @@ CREATE TABLE IF NOT EXISTS supplier_boosts (
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
starts_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -95,6 +95,7 @@
|
||||
.pricing-card {
|
||||
border: 1px solid #E2E8F0; border-radius: 16px; padding: 1.5rem;
|
||||
background: white; position: relative;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.pricing-card--highlight {
|
||||
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 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 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 {
|
||||
font-size: 0.8125rem; color: #475569; padding: 4px 0;
|
||||
display: flex; align-items: flex-start; gap: 6px;
|
||||
}
|
||||
.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 */
|
||||
.boost-grid {
|
||||
@@ -401,8 +402,8 @@
|
||||
<span class="boost-price">€79/wk or €199/mo</span>
|
||||
</div>
|
||||
<div class="boost-card">
|
||||
<strong>Newsletter Feature</strong>
|
||||
<span class="boost-price">€99/mo</span>
|
||||
<strong>Custom Card Color</strong>
|
||||
<span class="boost-price">€19/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
437
padelnomics/src/padelnomics/scripts/seed_dev_data.py
Normal file
437
padelnomics/src/padelnomics/scripts/seed_dev_data.py
Normal 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()
|
||||
@@ -75,9 +75,9 @@ PRODUCTS = [
|
||||
"billing_type": "subscription",
|
||||
},
|
||||
{
|
||||
"key": "boost_newsletter",
|
||||
"name": "Boost: Newsletter Feature",
|
||||
"price": 9900,
|
||||
"key": "boost_card_color",
|
||||
"name": "Boost: Custom Card Color",
|
||||
"price": 1900,
|
||||
"currency": CurrencyCode.EUR,
|
||||
"interval": "month",
|
||||
"billing_type": "subscription",
|
||||
|
||||
@@ -57,14 +57,12 @@
|
||||
|
||||
/* ── Component classes ── */
|
||||
@layer components {
|
||||
/* ── Navigation (Zillow-style: links | logo | links) ── */
|
||||
/* ── Navigation ── */
|
||||
.nav-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.nav-inner {
|
||||
|
||||
@@ -60,7 +60,7 @@ BOOST_OPTIONS = [
|
||||
{"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_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 = [
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation — Zillow-style: [demand links] [logo] [supply links + auth] -->
|
||||
<nav class="nav-bar">
|
||||
<div class="nav-inner">
|
||||
<!-- Left: demand / buy side -->
|
||||
|
||||
245
padelnomics/tests/test_quote_wizard.py
Normal file
245
padelnomics/tests/test_quote_wizard.py
Normal 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()
|
||||
Reference in New Issue
Block a user