From b99cd3c7d813cdde800618c2d86de44bef0e2201 Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 18 Feb 2026 09:37:13 +0100 Subject: [PATCH] fix quote form state loss, admin errors, UI polish; add seed data and playwright tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 32 ++ padelnomics/src/padelnomics/admin/routes.py | 100 +++- .../admin/templates/admin/lead_form.html | 117 +++++ .../admin/templates/admin/leads.html | 5 +- .../templates/admin/supplier_detail.html | 8 +- .../admin/templates/admin/supplier_form.html | 88 ++++ .../admin/templates/admin/suppliers.html | 5 +- .../src/padelnomics/directory/routes.py | 23 + .../directory/templates/partials/results.html | 4 +- .../templates/partials/quote_step_1.html | 4 +- .../templates/partials/quote_step_2.html | 2 +- .../templates/partials/quote_step_3.html | 2 +- .../templates/partials/quote_step_4.html | 2 +- .../templates/partials/quote_step_5.html | 2 +- .../templates/partials/quote_step_6.html | 2 +- .../templates/partials/quote_step_7.html | 2 +- .../templates/partials/quote_step_8.html | 2 +- .../src/padelnomics/migrations/schema.sql | 1 + .../versions/0009_add_boost_metadata.py | 7 + .../public/templates/suppliers.html | 9 +- .../src/padelnomics/scripts/seed_dev_data.py | 437 ++++++++++++++++++ .../src/padelnomics/scripts/setup_paddle.py | 6 +- .../src/padelnomics/static/css/input.css | 6 +- .../src/padelnomics/suppliers/routes.py | 2 +- .../src/padelnomics/templates/base.html | 1 - padelnomics/tests/test_quote_wizard.py | 245 ++++++++++ 26 files changed, 1083 insertions(+), 31 deletions(-) create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/lead_form.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html create mode 100644 padelnomics/src/padelnomics/migrations/versions/0009_add_boost_metadata.py create mode 100644 padelnomics/src/padelnomics/scripts/seed_dev_data.py create mode 100644 padelnomics/tests/test_quote_wizard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b34ec15..237933a 100644 --- a/CHANGELOG.md +++ b/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, diff --git a/padelnomics/src/padelnomics/admin/routes.py b/padelnomics/src/padelnomics/admin/routes.py index d64b471..7b2a681 100644 --- a/padelnomics/src/padelnomics/admin/routes.py +++ b/padelnomics/src/padelnomics/admin/routes.py @@ -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//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//credits", methods=["POST"]) @admin_required @csrf_protect diff --git a/padelnomics/src/padelnomics/admin/templates/admin/lead_form.html b/padelnomics/src/padelnomics/admin/templates/admin/lead_form.html new file mode 100644 index 0000000..1dd8a55 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/lead_form.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+ ← All Leads +

Create Lead

+ +
+ + +

Contact

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +

Project

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/leads.html b/padelnomics/src/padelnomics/admin/templates/admin/leads.html index 5e53649..ac861bc 100644 --- a/padelnomics/src/padelnomics/admin/templates/admin/leads.html +++ b/padelnomics/src/padelnomics/admin/templates/admin/leads.html @@ -14,7 +14,10 @@ {% endif %}

- Back to Dashboard + diff --git a/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html b/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html index 66ca7b7..bd5466a 100644 --- a/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html +++ b/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html @@ -119,12 +119,12 @@ {% for entry in ledger %} - {{ entry.entry_type }} + {{ entry.event_type }} - {% if entry.amount > 0 %} - +{{ entry.amount }} + {% if entry.delta > 0 %} + +{{ entry.delta }} {% else %} - {{ entry.amount }} + {{ entry.delta }} {% endif %} {{ entry.balance_after }} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html b/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html new file mode 100644 index 0000000..56ab8fd --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}New Supplier - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+ ← All Suppliers +

Create Supplier

+ +
+ + +
+ + +
+ +
+ + +

URL-safe identifier. Leave blank to auto-generate from name.

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html b/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html index 85eccad..2a66709 100644 --- a/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html +++ b/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html @@ -13,7 +13,10 @@ · {{ supplier_stats.pro }} Pro

- Back to Dashboard + diff --git a/padelnomics/src/padelnomics/directory/routes.py b/padelnomics/src/padelnomics/directory/routes.py index 5f1d3a7..cb74394 100644 --- a/padelnomics/src/padelnomics/directory/routes.py +++ b/padelnomics/src/padelnomics/directory/routes.py @@ -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, } diff --git a/padelnomics/src/padelnomics/directory/templates/partials/results.html b/padelnomics/src/padelnomics/directory/templates/partials/results.html index ddc0e15..d86f8ef 100644 --- a/padelnomics/src/padelnomics/directory/templates/partials/results.html +++ b/padelnomics/src/padelnomics/directory/templates/partials/results.html @@ -31,7 +31,7 @@ {% for s in suppliers %} {# --- Pro tier card --- #} {% if s.tier == 'pro' %} - + {% if s.sticky_until and s.sticky_until > now %}{% endif %}
@@ -55,7 +55,7 @@ {# --- Growth tier card --- #} {% elif s.tier == 'growth' %} - + {% if s.sticky_until and s.sticky_until > now %}{% endif %}

{{ s.name }}

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html index e199582..33c70e7 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html @@ -16,7 +16,7 @@
- + @@ -30,7 +30,7 @@ {# Direct visit — show full form #} - +

Your Project

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html index 154beab..4568aa4 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html @@ -1,7 +1,7 @@ {# Step 2: Location #} - +

Location

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html index 1c35f57..f60aa5b 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html @@ -1,7 +1,7 @@ {# Step 3: Build Context #} - +

Build Context

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html index 9362bd6..6bda3d6 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html @@ -1,7 +1,7 @@ {# Step 4: Project Phase #} - +

Project Phase

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html index a59c35a..256b02e 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html @@ -1,7 +1,7 @@ {# Step 5: Timeline #} - +

Timeline

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html index a0c3eef..c94a931 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html @@ -1,7 +1,7 @@ {# Step 6: Financing #} - +

Financing

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html index 06b9498..1498532 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html @@ -1,7 +1,7 @@ {# Step 7: About You #} - +

About You

diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html index 92dac5d..003462a 100644 --- a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html @@ -1,7 +1,7 @@ {# Step 8: Services Needed #} - +

Services Needed

diff --git a/padelnomics/src/padelnomics/migrations/schema.sql b/padelnomics/src/padelnomics/migrations/schema.sql index c5bb24f..852c9a8 100644 --- a/padelnomics/src/padelnomics/migrations/schema.sql +++ b/padelnomics/src/padelnomics/migrations/schema.sql @@ -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')) ); diff --git a/padelnomics/src/padelnomics/migrations/versions/0009_add_boost_metadata.py b/padelnomics/src/padelnomics/migrations/versions/0009_add_boost_metadata.py new file mode 100644 index 0000000..6642c24 --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0009_add_boost_metadata.py @@ -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") diff --git a/padelnomics/src/padelnomics/public/templates/suppliers.html b/padelnomics/src/padelnomics/public/templates/suppliers.html index 2a7e432..cb8bc36 100644 --- a/padelnomics/src/padelnomics/public/templates/suppliers.html +++ b/padelnomics/src/padelnomics/public/templates/suppliers.html @@ -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 @@ €79/wk or €199/mo
- Newsletter Feature - €99/mo + Custom Card Color + €19/mo
diff --git a/padelnomics/src/padelnomics/scripts/seed_dev_data.py b/padelnomics/src/padelnomics/scripts/seed_dev_data.py new file mode 100644 index 0000000..58f8e9d --- /dev/null +++ b/padelnomics/src/padelnomics/scripts/seed_dev_data.py @@ -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() diff --git a/padelnomics/src/padelnomics/scripts/setup_paddle.py b/padelnomics/src/padelnomics/scripts/setup_paddle.py index 6091f18..fd2d046 100644 --- a/padelnomics/src/padelnomics/scripts/setup_paddle.py +++ b/padelnomics/src/padelnomics/scripts/setup_paddle.py @@ -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", diff --git a/padelnomics/src/padelnomics/static/css/input.css b/padelnomics/src/padelnomics/static/css/input.css index ee6eb03..9e4390b 100644 --- a/padelnomics/src/padelnomics/static/css/input.css +++ b/padelnomics/src/padelnomics/static/css/input.css @@ -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 { diff --git a/padelnomics/src/padelnomics/suppliers/routes.py b/padelnomics/src/padelnomics/suppliers/routes.py index b4d17bf..60ffc5b 100644 --- a/padelnomics/src/padelnomics/suppliers/routes.py +++ b/padelnomics/src/padelnomics/suppliers/routes.py @@ -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 = [ diff --git a/padelnomics/src/padelnomics/templates/base.html b/padelnomics/src/padelnomics/templates/base.html index bb67988..2f0651a 100644 --- a/padelnomics/src/padelnomics/templates/base.html +++ b/padelnomics/src/padelnomics/templates/base.html @@ -42,7 +42,6 @@ {% block head %}{% endblock %} -