add Phase 2: supplier dashboard, business plan PDF, Paddle.js checkout, admin tools
Migrate all checkouts to Paddle.js overlay (no redirect), move Paddle price IDs from env vars to DB table, add 4-tab supplier dashboard (overview, leads, listing, boosts), business plan PDF export with WeasyPrint, enhanced supplier landing page with live stats, admin supplier management + feedback widget. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
112
CHANGELOG.md
112
CHANGELOG.md
@@ -6,6 +6,118 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added — Phase 2: Scale the Marketplace — Supplier Dashboard + Business Plan PDF
|
||||||
|
|
||||||
|
- **Paddle.js overlay checkout** — migrated all checkout flows (billing,
|
||||||
|
supplier signup, business plan) from server-side Paddle transaction creation +
|
||||||
|
redirect to client-side `Paddle.Checkout.open()` overlay; `PADDLE_CLIENT_TOKEN`
|
||||||
|
config; Paddle.js script in `base.html` with sandbox/production toggle
|
||||||
|
- **Paddle products in DB** — new `paddle_products` table replaces 16
|
||||||
|
`PADDLE_PRICE_*` env vars; `get_paddle_price(key)` and
|
||||||
|
`get_all_paddle_prices()` async helpers in `core.py`; `setup_paddle.py`
|
||||||
|
rewritten to write product/price IDs directly to database
|
||||||
|
- **Migration 0008** — `paddle_products`, `business_plan_exports`, `feedback`
|
||||||
|
tables; `logo_file` and `tagline` columns on `suppliers`
|
||||||
|
- **Umami analytics** — tracking script in `base.html`; config vars
|
||||||
|
`UMAMI_API_URL`, `UMAMI_API_TOKEN`, `UMAMI_WEBSITE_ID`; directory click
|
||||||
|
tracking redirect routes (`/<slug>/website`, `/<slug>/quote`)
|
||||||
|
- **Supplier dashboard** (`/suppliers/dashboard`) — tab-based HTMX dashboard
|
||||||
|
with sidebar nav (Overview, Lead Feed, My Listing, Boost & Upsells); each
|
||||||
|
tab loads via `hx-get` with `hx-push-url` for deep-linking
|
||||||
|
- **Overview tab** — 4 KPI stat cards (profile views, leads unlocked, credit
|
||||||
|
balance, directory rank), new leads alert banner, recent activity feed
|
||||||
|
- **Lead feed tab** — refactored `_get_lead_feed_data()` shared function
|
||||||
|
with bidder count; heat/country/timeline filter pills; region matching
|
||||||
|
badges; "No other suppliers yet — be first!" messaging
|
||||||
|
- **My Listing tab** — preview card + inline edit form (company info,
|
||||||
|
categories, service area, logo upload); `POST /suppliers/dashboard/listing`
|
||||||
|
saves changes
|
||||||
|
- **Boost & Upsells tab** — current plan, active boosts, available boosts
|
||||||
|
with `Paddle.Checkout.open()` purchase buttons, credit packs grid, summary
|
||||||
|
sidebar with visibility multiplier
|
||||||
|
- **Business plan PDF export** — `businessplan.py` with WeasyPrint PDF engine;
|
||||||
|
`plan.html` + `plan.css` A4 templates (executive summary, CAPEX, OPEX,
|
||||||
|
revenue model, 5-year P&L, 12-month cash flow, sensitivity, key metrics);
|
||||||
|
bilingual EN/DE; `generate_business_plan` worker task
|
||||||
|
- **Business plan routes** — `GET /planner/export` (options page with scenario
|
||||||
|
picker + Paddle checkout), `POST /planner/export/checkout`, `GET
|
||||||
|
/planner/export/success`, `GET /planner/export/<id>` (download); export CTA
|
||||||
|
in planner sidebar
|
||||||
|
- **Supplier landing page enhanced** — live stats from DB (business plans
|
||||||
|
created, avg project value, suppliers listed, monthly leads); real anonymized
|
||||||
|
lead preview cards (fallback to example data); credit explainer (hot=35,
|
||||||
|
warm=20, cool=8); "Most Popular" badge on Growth plan; expanded FAQ (8
|
||||||
|
questions including credits, countries, cancellation); social proof section
|
||||||
|
- **Admin supplier management** — `GET /admin/suppliers` with tier/country/name
|
||||||
|
filters (HTMX search), `GET /admin/suppliers/<id>` detail with profile info,
|
||||||
|
credit balance + ledger, active boosts, lead forward history; `POST
|
||||||
|
/admin/suppliers/<id>/credits` manual credit adjustment; `POST
|
||||||
|
/admin/suppliers/<id>/tier` manual tier change; supplier stats on admin
|
||||||
|
dashboard (claimed, growth, pro, credits spent, leads forwarded)
|
||||||
|
- **Feedback widget** — compact "Feedback" button in navbar opens HTMX popover
|
||||||
|
with textarea; `POST /feedback` rate-limited (5/hr per IP), inserts into
|
||||||
|
`feedback` table; `GET /admin/feedback` paginated admin view with user email
|
||||||
|
+ page URL
|
||||||
|
|
||||||
|
### Added — Phase 1: Lead Operations + Builder Directory Monetization
|
||||||
|
|
||||||
|
- **SDK migration** — replaced raw httpx calls with official `paddle-python-sdk`
|
||||||
|
and `resend` SDKs for type safety, built-in webhook verification, and cleaner
|
||||||
|
code; `send_email()` now accepts `from_addr` parameter; all Paddle API calls
|
||||||
|
use SDK client
|
||||||
|
- **EMAIL_ADDRESSES dict** — hardcoded `transactional`, `leads`, and `nurture`
|
||||||
|
from-addresses sharing the `notification.padelnomics.io` Resend domain
|
||||||
|
- **Paddle product setup script** — `scripts/setup_paddle.py` creates all 14
|
||||||
|
products/prices programmatically via SDK; outputs `.env` snippet for CI
|
||||||
|
- **Claude Code skills** — `.claude/skills/paddle-integration/SKILL.md` and
|
||||||
|
`.claude/skills/resend-emails/SKILL.md` for consistent SDK usage patterns
|
||||||
|
- **Migration 0007** — `credit_ledger`, `lead_forwards`, `supplier_boosts`
|
||||||
|
tables; 12 new columns on `suppliers` (profile, credits); `credit_cost` and
|
||||||
|
`unlock_count` on `lead_requests`
|
||||||
|
- **Credit system** (`credits.py`) — `get_balance`, `add_credits`,
|
||||||
|
`spend_credits`, `unlock_lead`, `compute_credit_cost`, `monthly_credit_refill`,
|
||||||
|
`get_ledger`; `InsufficientCredits` exception; heat-based pricing (hot=35,
|
||||||
|
warm=20, cool=8 credits); `refill_monthly_credits` worker task + scheduler
|
||||||
|
- **Admin lead management** — `GET /admin/leads` with status/heat/country
|
||||||
|
filters (HTMX search), `GET /admin/leads/<id>` detail with project brief +
|
||||||
|
forward history, `POST /admin/leads/<id>/status` update, `POST
|
||||||
|
/admin/leads/<id>/forward` manual forward (no credit cost); lead funnel stats
|
||||||
|
on admin dashboard (planner users → leads → verified → unlocked)
|
||||||
|
- **Lead forwarding emails** — `send_lead_forward_email` worker task sends full
|
||||||
|
project brief + contact details to supplier; `send_lead_matched_notification`
|
||||||
|
notifies entrepreneur when a supplier unlocks their lead
|
||||||
|
- **Credit cost computed on submission** — `credit_cost` set from heat score
|
||||||
|
both on verified-user submission and on email verification
|
||||||
|
- **Supplier signup wizard** (`/suppliers/signup`) — 4-step HTMX wizard: plan
|
||||||
|
selection (Growth €149/mo, Pro €399/mo), boost add-ons (logo, highlight,
|
||||||
|
verified, newsletter), credit packs (25-250), account details + order summary;
|
||||||
|
builds multi-item Paddle transaction; `_accumulated` hidden JSON pattern
|
||||||
|
- **Supplier claim flow** — `GET /suppliers/claim/<slug>` verifies unclaimed
|
||||||
|
and redirects to signup with pre-fill
|
||||||
|
- **Webhook handlers** — `subscription.activated` with `supplier_*` plan creates
|
||||||
|
supplier record with tier, credits, and boosts; `transaction.completed`
|
||||||
|
handles credit pack purchases and sticky boost purchases with expiry
|
||||||
|
- **Supplier profile page** (`/directory/<slug>`) — public profile with logo,
|
||||||
|
verified badge, description, service categories as pills, service area, years
|
||||||
|
in business, project count, website; "Request Quote" and "Claim This Listing"
|
||||||
|
CTAs
|
||||||
|
- **Directory card links** — all directory cards now link to supplier profile
|
||||||
|
pages; paid-tier cards show "Request Quote" mini-CTA
|
||||||
|
- **Supplier lead feed** (`/suppliers/leads`) — requires login + paid supplier
|
||||||
|
tier; shows anonymized lead cards with heat badge, facility type, courts,
|
||||||
|
country, timeline, budget range, credit cost, unlock count; `POST
|
||||||
|
/suppliers/leads/<id>/unlock` spends credits, creates lead_forward, sends
|
||||||
|
emails, returns full-details card via HTMX swap
|
||||||
|
- **Email nurture via Resend Audiences** — on first scenario save, user is added
|
||||||
|
to "Planner Users" audience (triggers 3-email automation); on quote submission,
|
||||||
|
user is removed from audience (stops nurture)
|
||||||
|
- **PADDLE_PRICES expanded** — 13 new price keys for supplier plans, boosts,
|
||||||
|
credit packs; `PADDLE_ENVIRONMENT` config for sandbox/production switching
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Supplier marketing page CTAs link to `/suppliers/signup` instead of mailto
|
||||||
|
- `httpx` removed from direct dependencies (transitive via paddle SDK)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Double opt-in email verification for quote requests** — guest quote
|
- **Double opt-in email verification for quote requests** — guest quote
|
||||||
submissions now require email verification before the lead goes live;
|
submissions now require email verification before the lead goes live;
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"quart>=0.19.0",
|
"quart>=0.19.0",
|
||||||
"aiosqlite>=0.19.0",
|
"aiosqlite>=0.19.0",
|
||||||
"httpx>=0.27.0",
|
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"itsdangerous>=2.1.0",
|
"itsdangerous>=2.1.0",
|
||||||
"jinja2>=3.1.0",
|
"jinja2>=3.1.0",
|
||||||
"hypercorn>=0.17.0",
|
"hypercorn>=0.17.0",
|
||||||
|
"paddle-python-sdk>=1.13.0",
|
||||||
|
"resend>=2.22.0",
|
||||||
|
"weasyprint>=68.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Blueprint, flash, redirect, render_template, request, session, url_for
|
from quart import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..core import config, csrf_protect, execute, fetch_all, fetch_one
|
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, transaction
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -59,6 +59,42 @@ async def get_dashboard_stats() -> dict:
|
|||||||
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
|
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
|
||||||
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
|
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
|
||||||
|
|
||||||
|
# Lead funnel stats
|
||||||
|
leads_total = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM lead_requests WHERE lead_type = 'quote'"
|
||||||
|
)
|
||||||
|
leads_new = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM lead_requests WHERE status = 'new' AND lead_type = 'quote'"
|
||||||
|
)
|
||||||
|
leads_verified = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM lead_requests WHERE verified_at IS NOT NULL AND lead_type = 'quote'"
|
||||||
|
)
|
||||||
|
leads_unlocked = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM lead_requests WHERE unlock_count > 0 AND lead_type = 'quote'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Planner users
|
||||||
|
planner_users = await fetch_one(
|
||||||
|
"SELECT COUNT(DISTINCT user_id) as count FROM scenarios WHERE deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Supplier stats
|
||||||
|
suppliers_claimed = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM suppliers WHERE claimed_by IS NOT NULL"
|
||||||
|
)
|
||||||
|
suppliers_growth = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM suppliers WHERE tier = 'growth'"
|
||||||
|
)
|
||||||
|
suppliers_pro = await fetch_one(
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
leads_unlocked_by_suppliers = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as count FROM lead_forwards"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"users_total": users_total["count"] if users_total else 0,
|
"users_total": users_total["count"] if users_total else 0,
|
||||||
"users_today": users_today["count"] if users_today else 0,
|
"users_today": users_today["count"] if users_today else 0,
|
||||||
@@ -66,6 +102,16 @@ async def get_dashboard_stats() -> dict:
|
|||||||
"active_subscriptions": subs["count"] if subs else 0,
|
"active_subscriptions": subs["count"] if subs else 0,
|
||||||
"tasks_pending": tasks_pending["count"] if tasks_pending else 0,
|
"tasks_pending": tasks_pending["count"] if tasks_pending else 0,
|
||||||
"tasks_failed": tasks_failed["count"] if tasks_failed else 0,
|
"tasks_failed": tasks_failed["count"] if tasks_failed else 0,
|
||||||
|
"leads_total": leads_total["count"] if leads_total else 0,
|
||||||
|
"leads_new": leads_new["count"] if leads_new else 0,
|
||||||
|
"leads_verified": leads_verified["count"] if leads_verified else 0,
|
||||||
|
"leads_unlocked": leads_unlocked["count"] if leads_unlocked else 0,
|
||||||
|
"planner_users": planner_users["count"] if planner_users else 0,
|
||||||
|
"suppliers_claimed": suppliers_claimed["count"] if suppliers_claimed else 0,
|
||||||
|
"suppliers_growth": suppliers_growth["count"] if suppliers_growth else 0,
|
||||||
|
"suppliers_pro": suppliers_pro["count"] if suppliers_pro else 0,
|
||||||
|
"total_credits_spent": total_credits_spent["total"] if total_credits_spent else 0,
|
||||||
|
"leads_unlocked_by_suppliers": leads_unlocked_by_suppliers["count"] if leads_unlocked_by_suppliers else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -314,3 +360,396 @@ async def task_delete(task_id: int):
|
|||||||
else:
|
else:
|
||||||
await flash("Could not delete task.", "error")
|
await flash("Could not delete task.", "error")
|
||||||
return redirect(url_for("admin.tasks"))
|
return redirect(url_for("admin.tasks"))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Lead Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
LEAD_STATUSES = ["new", "pending_verification", "contacted", "forwarded", "closed_won", "closed_lost"]
|
||||||
|
HEAT_OPTIONS = ["hot", "warm", "cool"]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_leads(
|
||||||
|
status: str = None, heat: str = None, country: str = None,
|
||||||
|
page: int = 1, per_page: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get leads with optional filters."""
|
||||||
|
wheres = ["lead_type = 'quote'"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if status:
|
||||||
|
wheres.append("status = ?")
|
||||||
|
params.append(status)
|
||||||
|
if heat:
|
||||||
|
wheres.append("heat_score = ?")
|
||||||
|
params.append(heat)
|
||||||
|
if country:
|
||||||
|
wheres.append("country = ?")
|
||||||
|
params.append(country)
|
||||||
|
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
|
||||||
|
return await fetch_all(
|
||||||
|
f"""SELECT * FROM lead_requests WHERE {where}
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lead_detail(lead_id: int) -> dict | None:
|
||||||
|
"""Get lead with forward history."""
|
||||||
|
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||||
|
if not lead:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lead = dict(lead)
|
||||||
|
lead["forwards"] = await fetch_all(
|
||||||
|
"""SELECT lf.*, s.name as supplier_name, s.slug as supplier_slug
|
||||||
|
FROM lead_forwards lf
|
||||||
|
JOIN suppliers s ON s.id = lf.supplier_id
|
||||||
|
WHERE lf.lead_id = ?
|
||||||
|
ORDER BY lf.created_at DESC""",
|
||||||
|
(lead_id,),
|
||||||
|
)
|
||||||
|
return lead
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lead_stats() -> dict:
|
||||||
|
"""Get lead conversion funnel counts."""
|
||||||
|
rows = await fetch_all(
|
||||||
|
"SELECT status, COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' GROUP BY status"
|
||||||
|
)
|
||||||
|
return {r["status"]: r["cnt"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads")
|
||||||
|
@admin_required
|
||||||
|
async def leads():
|
||||||
|
"""Lead management list."""
|
||||||
|
status = request.args.get("status", "")
|
||||||
|
heat = request.args.get("heat", "")
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
|
lead_list = await get_leads(
|
||||||
|
status=status or None, heat=heat or None, country=country or None, page=page,
|
||||||
|
)
|
||||||
|
lead_stats = await get_lead_stats()
|
||||||
|
|
||||||
|
countries = await fetch_all(
|
||||||
|
"SELECT DISTINCT country FROM lead_requests WHERE country IS NOT NULL AND country != '' ORDER BY country"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/leads.html",
|
||||||
|
leads=lead_list,
|
||||||
|
lead_stats=lead_stats,
|
||||||
|
statuses=LEAD_STATUSES,
|
||||||
|
heat_options=HEAT_OPTIONS,
|
||||||
|
countries=[c["country"] for c in countries],
|
||||||
|
current_status=status,
|
||||||
|
current_heat=heat,
|
||||||
|
current_country=country,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/results")
|
||||||
|
@admin_required
|
||||||
|
async def lead_results():
|
||||||
|
"""HTMX partial for filtered lead results."""
|
||||||
|
status = request.args.get("status", "")
|
||||||
|
heat = request.args.get("heat", "")
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
|
lead_list = await get_leads(
|
||||||
|
status=status or None, heat=heat or None, country=country or None, page=page,
|
||||||
|
)
|
||||||
|
return await render_template("admin/partials/lead_results.html", leads=lead_list)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/<int:lead_id>")
|
||||||
|
@admin_required
|
||||||
|
async def lead_detail(lead_id: int):
|
||||||
|
"""Lead detail page."""
|
||||||
|
lead = await get_lead_detail(lead_id)
|
||||||
|
if not lead:
|
||||||
|
await flash("Lead not found.", "error")
|
||||||
|
return redirect(url_for("admin.leads"))
|
||||||
|
|
||||||
|
suppliers = await fetch_all(
|
||||||
|
"SELECT id, name, slug, country_code, category FROM suppliers ORDER BY name"
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"admin/lead_detail.html",
|
||||||
|
lead=lead,
|
||||||
|
statuses=LEAD_STATUSES,
|
||||||
|
suppliers=suppliers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/<int:lead_id>/status", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def lead_status(lead_id: int):
|
||||||
|
"""Update lead status."""
|
||||||
|
form = await request.form
|
||||||
|
new_status = form.get("status", "")
|
||||||
|
if new_status not in LEAD_STATUSES:
|
||||||
|
await flash("Invalid status.", "error")
|
||||||
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"UPDATE lead_requests SET status = ? WHERE id = ?", (new_status, lead_id)
|
||||||
|
)
|
||||||
|
await flash(f"Lead status updated to {new_status}.", "success")
|
||||||
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/<int:lead_id>/forward", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def lead_forward(lead_id: int):
|
||||||
|
"""Manually forward a lead to a supplier (no credit cost)."""
|
||||||
|
form = await request.form
|
||||||
|
supplier_id = int(form.get("supplier_id", 0))
|
||||||
|
|
||||||
|
if not supplier_id:
|
||||||
|
await flash("Select a supplier.", "error")
|
||||||
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
|
# Check if already forwarded
|
||||||
|
existing = await fetch_one(
|
||||||
|
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
|
||||||
|
(lead_id, supplier_id),
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
await flash("Already forwarded to this supplier.", "warning")
|
||||||
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
|
||||||
|
VALUES (?, ?, 0, 'sent', ?)""",
|
||||||
|
(lead_id, supplier_id, now),
|
||||||
|
)
|
||||||
|
await execute(
|
||||||
|
"UPDATE lead_requests SET unlock_count = unlock_count + 1, status = 'forwarded' WHERE id = ?",
|
||||||
|
(lead_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue forward email
|
||||||
|
from ..worker import enqueue
|
||||||
|
await enqueue("send_lead_forward_email", {
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"supplier_id": supplier_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await flash("Lead forwarded to supplier.", "success")
|
||||||
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Supplier Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SUPPLIER_TIERS = ["free", "growth", "pro"]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_suppliers_list(
|
||||||
|
tier: str = None, country: str = None, search: str = None,
|
||||||
|
page: int = 1, per_page: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get suppliers with optional filters."""
|
||||||
|
wheres = ["1=1"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if tier:
|
||||||
|
wheres.append("tier = ?")
|
||||||
|
params.append(tier)
|
||||||
|
if country:
|
||||||
|
wheres.append("country_code = ?")
|
||||||
|
params.append(country)
|
||||||
|
if search:
|
||||||
|
wheres.append("name LIKE ?")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
|
||||||
|
return await fetch_all(
|
||||||
|
f"""SELECT * FROM suppliers WHERE {where}
|
||||||
|
ORDER BY tier DESC, name ASC LIMIT ? OFFSET ?""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_supplier_stats() -> dict:
|
||||||
|
"""Get aggregate supplier stats for the admin list header."""
|
||||||
|
claimed = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers WHERE claimed_by IS NOT NULL")
|
||||||
|
growth = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers WHERE tier = 'growth'")
|
||||||
|
pro = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers WHERE tier = 'pro'")
|
||||||
|
return {
|
||||||
|
"claimed": claimed["cnt"] if claimed else 0,
|
||||||
|
"growth": growth["cnt"] if growth else 0,
|
||||||
|
"pro": pro["cnt"] if pro else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/suppliers")
|
||||||
|
@admin_required
|
||||||
|
async def suppliers():
|
||||||
|
"""Supplier management list."""
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
tier = request.args.get("tier", "")
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
|
supplier_list = await get_suppliers_list(
|
||||||
|
tier=tier or None, country=country or None,
|
||||||
|
search=search or None, page=page,
|
||||||
|
)
|
||||||
|
supplier_stats = await get_supplier_stats()
|
||||||
|
|
||||||
|
countries = await fetch_all(
|
||||||
|
"SELECT DISTINCT country_code FROM suppliers WHERE country_code IS NOT NULL AND country_code != '' ORDER BY country_code"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/suppliers.html",
|
||||||
|
suppliers=supplier_list,
|
||||||
|
supplier_stats=supplier_stats,
|
||||||
|
tiers=SUPPLIER_TIERS,
|
||||||
|
countries=[c["country_code"] for c in countries],
|
||||||
|
current_search=search,
|
||||||
|
current_tier=tier,
|
||||||
|
current_country=country,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/suppliers/results")
|
||||||
|
@admin_required
|
||||||
|
async def supplier_results():
|
||||||
|
"""HTMX partial for filtered supplier results."""
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
tier = request.args.get("tier", "")
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
|
supplier_list = await get_suppliers_list(
|
||||||
|
tier=tier or None, country=country or None,
|
||||||
|
search=search or None, page=page,
|
||||||
|
)
|
||||||
|
return await render_template("admin/partials/supplier_results.html", suppliers=supplier_list)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/suppliers/<int:supplier_id>")
|
||||||
|
@admin_required
|
||||||
|
async def supplier_detail(supplier_id: int):
|
||||||
|
"""Supplier detail page."""
|
||||||
|
supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,))
|
||||||
|
if not supplier:
|
||||||
|
await flash("Supplier not found.", "error")
|
||||||
|
return redirect(url_for("admin.suppliers"))
|
||||||
|
|
||||||
|
# Credit balance
|
||||||
|
from ..credits import get_balance, get_ledger
|
||||||
|
credit_balance = await get_balance(supplier_id)
|
||||||
|
ledger = await get_ledger(supplier_id, limit=50)
|
||||||
|
|
||||||
|
# Active boosts
|
||||||
|
boosts = await fetch_all(
|
||||||
|
"SELECT * FROM supplier_boosts WHERE supplier_id = ? ORDER BY created_at DESC",
|
||||||
|
(supplier_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lead forward history
|
||||||
|
forwards = await fetch_all(
|
||||||
|
"""SELECT lf.*, lr.contact_name, lr.country, lr.heat_score
|
||||||
|
FROM lead_forwards lf
|
||||||
|
LEFT JOIN lead_requests lr ON lr.id = lf.lead_id
|
||||||
|
WHERE lf.supplier_id = ?
|
||||||
|
ORDER BY lf.created_at DESC LIMIT 50""",
|
||||||
|
(supplier_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/supplier_detail.html",
|
||||||
|
supplier=supplier,
|
||||||
|
tiers=SUPPLIER_TIERS,
|
||||||
|
credit_balance=credit_balance,
|
||||||
|
ledger=ledger,
|
||||||
|
boosts=boosts,
|
||||||
|
forwards=forwards,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/suppliers/<int:supplier_id>/credits", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def supplier_credits(supplier_id: int):
|
||||||
|
"""Manually adjust supplier credits."""
|
||||||
|
form = await request.form
|
||||||
|
amount = int(form.get("amount", 0))
|
||||||
|
action = form.get("action", "add")
|
||||||
|
note = form.get("note", "").strip() or "Admin adjustment"
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
await flash("Amount must be positive.", "error")
|
||||||
|
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||||
|
|
||||||
|
from ..credits import add_credits, spend_credits, InsufficientCredits
|
||||||
|
|
||||||
|
if action == "subtract":
|
||||||
|
try:
|
||||||
|
await spend_credits(supplier_id, amount, "admin_adjustment", note=note)
|
||||||
|
except InsufficientCredits:
|
||||||
|
await flash("Insufficient credits for subtraction.", "error")
|
||||||
|
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||||
|
else:
|
||||||
|
await add_credits(supplier_id, amount, "admin_adjustment", note=note)
|
||||||
|
|
||||||
|
await flash(f"Credits {'added' if action == 'add' else 'subtracted'}: {amount}.", "success")
|
||||||
|
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/suppliers/<int:supplier_id>/tier", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def supplier_tier(supplier_id: int):
|
||||||
|
"""Manually change supplier tier."""
|
||||||
|
form = await request.form
|
||||||
|
new_tier = form.get("tier", "")
|
||||||
|
if new_tier not in SUPPLIER_TIERS:
|
||||||
|
await flash("Invalid tier.", "error")
|
||||||
|
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"UPDATE suppliers SET tier = ? WHERE id = ?", (new_tier, supplier_id)
|
||||||
|
)
|
||||||
|
await flash(f"Supplier tier updated to {new_tier}.", "success")
|
||||||
|
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Feedback Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/feedback")
|
||||||
|
@admin_required
|
||||||
|
async def feedback():
|
||||||
|
"""View user feedback submissions."""
|
||||||
|
feedback_list = await fetch_all(
|
||||||
|
"""SELECT f.*, u.email
|
||||||
|
FROM feedback f
|
||||||
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT 200"""
|
||||||
|
)
|
||||||
|
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Feedback - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Feedback</h1>
|
||||||
|
<p class="text-sm text-slate mt-1">{{ feedback_list | length }} submissions shown</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back to Dashboard</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if feedback_list %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Page</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in feedback_list %}
|
||||||
|
<tr>
|
||||||
|
<td style="max-width:400px">
|
||||||
|
<p class="text-sm" style="white-space:pre-wrap;word-break:break-word">{{ f.message }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-slate mono">{{ f.page_url or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if f.email %}
|
||||||
|
<a href="{{ url_for('admin.user_detail', user_id=f.user_id) }}" class="text-sm">{{ f.email }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-slate">Anonymous</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card text-center" style="padding:2rem">
|
||||||
|
<p class="text-slate">No feedback yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -45,10 +45,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Lead Funnel -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Planner Users</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Total Leads</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.leads_total }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">New</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.leads_new }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Verified</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.leads_verified }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Unlocked</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.leads_unlocked }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Supplier Stats -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Claimed Suppliers</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Growth Tier</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.suppliers_growth }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Pro Tier</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.suppliers_pro }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Credits Spent</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.total_credits_spent }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center" style="padding:0.75rem">
|
||||||
|
<p class="text-xs text-slate">Leads Forwarded</p>
|
||||||
|
<p class="text-xl font-bold text-navy">{{ stats.leads_unlocked_by_suppliers }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick Links -->
|
||||||
<div class="grid-3 mb-10">
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-10">
|
||||||
|
<a href="{{ url_for('admin.leads') }}" class="btn text-center">Leads</a>
|
||||||
|
<a href="{{ url_for('admin.suppliers') }}" class="btn text-center">Suppliers</a>
|
||||||
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
|
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
|
||||||
<a href="{{ url_for('admin.tasks') }}" class="btn-outline text-center">Task Queue</a>
|
<a href="{{ url_for('admin.tasks') }}" class="btn-outline text-center">Task Queue</a>
|
||||||
|
<a href="{{ url_for('admin.feedback') }}" class="btn-outline text-center">Feedback</a>
|
||||||
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
|
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||||
|
<h1 class="text-2xl mt-1">Lead #{{ lead.id }}
|
||||||
|
{% if lead.heat_score == 'hot' %}<span class="badge-danger">HOT</span>
|
||||||
|
{% elif lead.heat_score == 'warm' %}<span class="badge-warning">WARM</span>
|
||||||
|
{% else %}<span class="badge">COOL</span>{% endif %}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Status update -->
|
||||||
|
<form method="post" action="{{ url_for('admin.lead_status', lead_id=lead.id) }}" class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<select name="status" class="form-input" style="min-width:140px">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-outline btn-sm">Update</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid-2" style="gap:1.5rem">
|
||||||
|
<!-- Project brief -->
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-4">Project Brief</h2>
|
||||||
|
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
|
<dt class="text-slate">Facility</dt>
|
||||||
|
<dd>{{ lead.facility_type or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Courts</dt>
|
||||||
|
<dd>{{ lead.court_count or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Glass</dt>
|
||||||
|
<dd>{{ lead.glass_type or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Lighting</dt>
|
||||||
|
<dd>{{ lead.lighting_type or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Build Context</dt>
|
||||||
|
<dd>{{ lead.build_context or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Location</dt>
|
||||||
|
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Timeline</dt>
|
||||||
|
<dd>{{ lead.timeline or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Phase</dt>
|
||||||
|
<dd>{{ lead.location_status or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Budget</dt>
|
||||||
|
<dd>{{ lead.budget_estimate or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Financing</dt>
|
||||||
|
<dd>{{ lead.financing_status or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Services</dt>
|
||||||
|
<dd>{{ lead.services_needed or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Additional Info</dt>
|
||||||
|
<dd>{{ lead.additional_info or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Credit Cost</dt>
|
||||||
|
<dd>{{ lead.credit_cost or '-' }} credits</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact info -->
|
||||||
|
<div>
|
||||||
|
<div class="card mb-4" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-4">Contact</h2>
|
||||||
|
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
|
<dt class="text-slate">Name</dt>
|
||||||
|
<dd>{{ lead.contact_name or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Email</dt>
|
||||||
|
<dd>{{ lead.contact_email or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Phone</dt>
|
||||||
|
<dd>{{ lead.contact_phone or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Company</dt>
|
||||||
|
<dd>{{ lead.contact_company or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Role</dt>
|
||||||
|
<dd>{{ lead.stakeholder_type or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Created</dt>
|
||||||
|
<dd class="mono">{{ lead.created_at or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Verified</dt>
|
||||||
|
<dd class="mono">{{ lead.verified_at or 'Not verified' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forward to supplier -->
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-4">Forward to Supplier</h2>
|
||||||
|
<form method="post" action="{{ url_for('admin.lead_forward', lead_id=lead.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<select name="supplier_id" class="form-input mb-3" style="width:100%">
|
||||||
|
<option value="">Select supplier...</option>
|
||||||
|
{% for s in suppliers %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }} ({{ s.country_code }}, {{ s.category }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn" style="width:100%">Forward Lead</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forward history -->
|
||||||
|
{% if lead.forwards %}
|
||||||
|
<section class="mt-6">
|
||||||
|
<h2 class="text-lg mb-3">Forward History</h2>
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Sent</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in lead.forwards %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('directory.index') }}">{{ f.supplier_name }}</a></td>
|
||||||
|
<td>{{ f.credit_cost }}</td>
|
||||||
|
<td><span class="badge">{{ f.status }}</span></td>
|
||||||
|
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
65
padelnomics/src/padelnomics/admin/templates/admin/leads.html
Normal file
65
padelnomics/src/padelnomics/admin/templates/admin/leads.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Lead Management</h1>
|
||||||
|
<p class="text-sm text-slate mt-1">
|
||||||
|
{{ leads | length }} leads shown
|
||||||
|
{% if lead_stats %}
|
||||||
|
· {{ lead_stats.get('new', 0) }} new
|
||||||
|
· {{ lead_stats.get('forwarded', 0) }} forwarded
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back to Dashboard</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
||||||
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
|
hx-get="{{ url_for('admin.lead_results') }}"
|
||||||
|
hx-target="#lead-results"
|
||||||
|
hx-trigger="change, input delay:300ms from:find input">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||||
|
<select name="status" class="form-input" style="min-width:140px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if s == current_status %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Heat</label>
|
||||||
|
<select name="heat" class="form-input" style="min-width:100px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for h in heat_options %}
|
||||||
|
<option value="{{ h }}" {% if h == current_heat %}selected{% endif %}>{{ h | upper }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Country</label>
|
||||||
|
<select name="country" class="form-input" style="min-width:120px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="lead-results">
|
||||||
|
{% include "admin/partials/lead_results.html" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% if leads %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Heat</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Courts</th>
|
||||||
|
<th>Budget</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Unlocks</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for lead in leads %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% if lead.heat_score == 'hot' %}
|
||||||
|
<span class="badge-danger">HOT</span>
|
||||||
|
{% elif lead.heat_score == 'warm' %}
|
||||||
|
<span class="badge-warning">WARM</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">COOL</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
|
||||||
|
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ lead.country or '-' }}</td>
|
||||||
|
<td>{{ lead.court_count or '-' }}</td>
|
||||||
|
<td>{{ lead.budget_estimate or '-' }}</td>
|
||||||
|
<td><span class="badge">{{ lead.status }}</span></td>
|
||||||
|
<td>{{ lead.unlock_count or 0 }}</td>
|
||||||
|
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card text-center" style="padding:2rem">
|
||||||
|
<p class="text-slate">No leads match the current filters.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% if suppliers %}
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Tier</th>
|
||||||
|
<th>Credits</th>
|
||||||
|
<th>Claimed</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in suppliers %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=s.id) }}">#{{ s.id }}</a></td>
|
||||||
|
<td>
|
||||||
|
<span class="text-sm font-semibold">{{ s.name }}</span>
|
||||||
|
{% if s.slug %}<br><span class="text-xs text-slate">{{ s.slug }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.country_code or '-' }}</td>
|
||||||
|
<td>{{ s.category or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if s.tier == 'pro' %}
|
||||||
|
<span class="badge-danger">PRO</span>
|
||||||
|
{% elif s.tier == 'growth' %}
|
||||||
|
<span class="badge-warning">GROWTH</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">FREE</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.credit_balance or 0 }}</td>
|
||||||
|
<td>
|
||||||
|
{% if s.claimed_by %}
|
||||||
|
<span class="text-xs text-accent">Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-slate">No</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-sm">{{ s.created_at[:10] if s.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card text-center" style="padding:2rem">
|
||||||
|
<p class="text-slate">No suppliers match the current filters.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ supplier.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">← All Suppliers</a>
|
||||||
|
<h1 class="text-2xl mt-1">{{ supplier.name }}
|
||||||
|
{% if supplier.tier == 'pro' %}<span class="badge-danger">PRO</span>
|
||||||
|
{% elif supplier.tier == 'growth' %}<span class="badge-warning">GROWTH</span>
|
||||||
|
{% else %}<span class="badge">FREE</span>{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-slate mt-1">{{ supplier.slug }} · {{ supplier.country_code or '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('directory.supplier_detail', slug=supplier.slug) }}" class="btn-outline" target="_blank">View Profile</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid-2" style="gap:1.5rem">
|
||||||
|
<!-- Profile info -->
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-4">Company Info</h2>
|
||||||
|
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
|
<dt class="text-slate">Name</dt>
|
||||||
|
<dd>{{ supplier.name }}</dd>
|
||||||
|
<dt class="text-slate">Slug</dt>
|
||||||
|
<dd class="mono">{{ supplier.slug }}</dd>
|
||||||
|
<dt class="text-slate">Category</dt>
|
||||||
|
<dd>{{ supplier.category or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Country</dt>
|
||||||
|
<dd>{{ supplier.country_code or '-' }}</dd>
|
||||||
|
<dt class="text-slate">City</dt>
|
||||||
|
<dd>{{ supplier.city or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Website</dt>
|
||||||
|
<dd>{% if supplier.website %}<a href="{{ supplier.website }}" target="_blank" class="text-sm">{{ supplier.website }}</a>{% else %}-{% endif %}</dd>
|
||||||
|
<dt class="text-slate">Contact</dt>
|
||||||
|
<dd>{{ supplier.contact_name or '-' }}<br>
|
||||||
|
<span class="text-xs text-slate">{{ supplier.contact_email or '-' }}</span></dd>
|
||||||
|
<dt class="text-slate">Tagline</dt>
|
||||||
|
<dd>{{ supplier.tagline or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Description</dt>
|
||||||
|
<dd>{{ supplier.short_description or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Years</dt>
|
||||||
|
<dd>{{ supplier.years_in_business or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Projects</dt>
|
||||||
|
<dd>{{ supplier.project_count or '-' }}</dd>
|
||||||
|
<dt class="text-slate">Claimed By</dt>
|
||||||
|
<dd>{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}</dd>
|
||||||
|
<dt class="text-slate">Created</dt>
|
||||||
|
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<!-- Tier management -->
|
||||||
|
<div class="card mb-4" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-4">Tier</h2>
|
||||||
|
<form method="post" action="{{ url_for('admin.supplier_tier', supplier_id=supplier.id) }}" class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<select name="tier" class="form-input" style="min-width:140px">
|
||||||
|
{% for t in tiers %}
|
||||||
|
<option value="{{ t }}" {% if t == supplier.tier %}selected{% endif %}>{{ t | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-outline btn-sm">Update Tier</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credit management -->
|
||||||
|
<div class="card mb-4" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-3">Credits</h2>
|
||||||
|
<p class="text-2xl font-bold text-navy mb-3">{{ credit_balance }} <span class="text-sm font-normal text-slate">credits</span></p>
|
||||||
|
<form method="post" action="{{ url_for('admin.supplier_credits', supplier_id=supplier.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<input type="number" name="amount" placeholder="Amount" class="form-input" style="width:100px" required>
|
||||||
|
<select name="action" class="form-input" style="min-width:100px">
|
||||||
|
<option value="add">Add</option>
|
||||||
|
<option value="subtract">Subtract</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="note" placeholder="Admin note (optional)" class="form-input mb-2" style="width:100%">
|
||||||
|
<button type="submit" class="btn-outline btn-sm">Adjust Credits</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active boosts -->
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-lg mb-3">Active Boosts</h2>
|
||||||
|
{% if boosts %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Boost</th><th>Status</th><th>Activated</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in boosts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.boost_type }}</td>
|
||||||
|
<td><span class="badge">{{ b.status }}</span></td>
|
||||||
|
<td class="mono text-sm">{{ b.created_at[:10] if b.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-slate">No active boosts.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credit ledger -->
|
||||||
|
<section class="mt-6">
|
||||||
|
<h2 class="text-lg mb-3">Credit Ledger (last 50)</h2>
|
||||||
|
<div class="card">
|
||||||
|
{% if ledger %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Type</th><th>Amount</th><th>Balance After</th><th>Note</th><th>Date</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in ledger %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.entry_type }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.amount > 0 %}
|
||||||
|
<span style="color:#16A34A">+{{ entry.amount }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#DC2626">{{ entry.amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ entry.balance_after }}</td>
|
||||||
|
<td class="text-xs text-slate">{{ entry.note or '-' }}</td>
|
||||||
|
<td class="mono text-sm">{{ entry.created_at[:16] if entry.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-slate" style="padding:1rem">No credit history.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Lead forwards -->
|
||||||
|
<section class="mt-6">
|
||||||
|
<h2 class="text-lg mb-3">Lead Forward History</h2>
|
||||||
|
<div class="card">
|
||||||
|
{% if forwards %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Lead</th><th>Credits</th><th>Status</th><th>Date</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in forwards %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.lead_detail', lead_id=f.lead_id) }}">#{{ f.lead_id }}</a></td>
|
||||||
|
<td>{{ f.credit_cost }}</td>
|
||||||
|
<td><span class="badge">{{ f.status }}</span></td>
|
||||||
|
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-slate" style="padding:1rem">No leads forwarded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Supplier Management - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Supplier Management</h1>
|
||||||
|
<p class="text-sm text-slate mt-1">
|
||||||
|
{{ suppliers | length }} suppliers shown
|
||||||
|
· {{ supplier_stats.claimed }} claimed
|
||||||
|
· {{ supplier_stats.growth }} Growth
|
||||||
|
· {{ supplier_stats.pro }} Pro
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back to Dashboard</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
||||||
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
|
hx-get="{{ url_for('admin.supplier_results') }}"
|
||||||
|
hx-target="#supplier-results"
|
||||||
|
hx-trigger="change, input delay:300ms from:find input">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||||
|
<input type="text" name="search" value="{{ current_search }}" placeholder="Company name..."
|
||||||
|
class="form-input" style="min-width:180px">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Tier</label>
|
||||||
|
<select name="tier" class="form-input" style="min-width:120px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for t in tiers %}
|
||||||
|
<option value="{{ t }}" {% if t == current_tier %}selected{% endif %}>{{ t | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Country</label>
|
||||||
|
<select name="country" class="form-input" style="min-width:120px">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="supplier-results">
|
||||||
|
{% include "admin/partials/supplier_results.html" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -85,6 +85,7 @@ def create_app() -> Quart:
|
|||||||
from .leads.routes import bp as leads_bp
|
from .leads.routes import bp as leads_bp
|
||||||
from .planner.routes import bp as planner_bp
|
from .planner.routes import bp as planner_bp
|
||||||
from .public.routes import bp as public_bp
|
from .public.routes import bp as public_bp
|
||||||
|
from .suppliers.routes import bp as suppliers_bp
|
||||||
|
|
||||||
app.register_blueprint(public_bp)
|
app.register_blueprint(public_bp)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
@@ -93,6 +94,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(planner_bp)
|
app.register_blueprint(planner_bp)
|
||||||
app.register_blueprint(leads_bp)
|
app.register_blueprint(leads_bp)
|
||||||
app.register_blueprint(directory_bp)
|
app.register_blueprint(directory_bp)
|
||||||
|
app.register_blueprint(suppliers_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
# Request ID tracking
|
# Request ID tracking
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ from datetime import datetime
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
from paddle_billing import Client as PaddleClient
|
||||||
|
from paddle_billing import Environment, Options
|
||||||
|
from paddle_billing.Notifications import Secret, Verifier
|
||||||
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
from ..auth.routes import login_required
|
||||||
from ..core import config, execute, fetch_one, verify_hmac_signature
|
from ..core import config, execute, fetch_all, fetch_one, get_paddle_price
|
||||||
|
|
||||||
|
|
||||||
|
def _paddle_client() -> PaddleClient:
|
||||||
|
"""Create a Paddle SDK client. Used only for subscription management + webhook verification."""
|
||||||
|
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
|
||||||
|
return PaddleClient(config.PADDLE_API_KEY, options=Options(env))
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -152,37 +160,24 @@ async def success():
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Paddle Implementation
|
# Paddle Implementation — Paddle.js Overlay Checkout
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@bp.route("/checkout/<plan>", methods=["POST"])
|
@bp.route("/checkout/<plan>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def checkout(plan: str):
|
async def checkout(plan: str):
|
||||||
"""Create Paddle checkout via API."""
|
"""Return JSON for Paddle.js overlay checkout."""
|
||||||
price_id = config.PADDLE_PRICES.get(plan)
|
price_id = await get_paddle_price(plan)
|
||||||
if not price_id:
|
if not price_id:
|
||||||
await flash("Invalid plan selected.", "error")
|
return jsonify({"error": "Invalid plan selected."}), 400
|
||||||
return redirect(url_for("billing.pricing"))
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
return jsonify({
|
||||||
response = await client.post(
|
"items": [{"priceId": price_id, "quantity": 1}],
|
||||||
"https://api.paddle.com/transactions",
|
"customData": {"user_id": str(g.user["id"]), "plan": plan},
|
||||||
headers={
|
"settings": {
|
||||||
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
|
"successUrl": f"{config.BASE_URL}/billing/success",
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
json={
|
})
|
||||||
"items": [{"price_id": price_id, "quantity": 1}],
|
|
||||||
"custom_data": {"user_id": str(g.user["id"]), "plan": plan},
|
|
||||||
"checkout": {
|
|
||||||
"url": f"{config.BASE_URL}/billing/success",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
checkout_url = response.json()["data"]["checkout"]["url"]
|
|
||||||
return redirect(checkout_url)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/manage", methods=["POST"])
|
@bp.route("/manage", methods=["POST"])
|
||||||
@@ -194,14 +189,9 @@ async def manage():
|
|||||||
await flash("No active subscription found.", "error")
|
await flash("No active subscription found.", "error")
|
||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
paddle = _paddle_client()
|
||||||
response = await client.get(
|
paddle_sub = paddle.subscriptions.get(sub["paddle_subscription_id"])
|
||||||
f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}",
|
portal_url = paddle_sub.management_urls.update_payment_method
|
||||||
headers={"Authorization": f"Bearer {config.PADDLE_API_KEY}"},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
portal_url = response.json()["data"]["management_urls"]["update_payment_method"]
|
|
||||||
return redirect(portal_url)
|
return redirect(portal_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,14 +201,11 @@ async def cancel():
|
|||||||
"""Cancel subscription via Paddle API."""
|
"""Cancel subscription via Paddle API."""
|
||||||
sub = await get_subscription(g.user["id"])
|
sub = await get_subscription(g.user["id"])
|
||||||
if sub and sub.get("paddle_subscription_id"):
|
if sub and sub.get("paddle_subscription_id"):
|
||||||
async with httpx.AsyncClient() as client:
|
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
|
||||||
await client.post(
|
paddle = _paddle_client()
|
||||||
f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}/cancel",
|
paddle.subscriptions.cancel(
|
||||||
headers={
|
sub["paddle_subscription_id"],
|
||||||
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
|
CancelSubscription(effective_from="next_billing_period"),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={"effective_from": "next_billing_period"},
|
|
||||||
)
|
)
|
||||||
return redirect(url_for("dashboard.settings"))
|
return redirect(url_for("dashboard.settings"))
|
||||||
|
|
||||||
@@ -229,7 +216,10 @@ async def webhook():
|
|||||||
payload = await request.get_data()
|
payload = await request.get_data()
|
||||||
sig = request.headers.get("Paddle-Signature", "")
|
sig = request.headers.get("Paddle-Signature", "")
|
||||||
|
|
||||||
if not verify_hmac_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET):
|
if config.PADDLE_WEBHOOK_SECRET:
|
||||||
|
try:
|
||||||
|
Verifier().verify(payload, Secret(config.PADDLE_WEBHOOK_SECRET), sig)
|
||||||
|
except Exception:
|
||||||
return jsonify({"error": "Invalid signature"}), 400
|
return jsonify({"error": "Invalid signature"}), 400
|
||||||
|
|
||||||
event = json.loads(payload)
|
event = json.loads(payload)
|
||||||
@@ -237,12 +227,15 @@ async def webhook():
|
|||||||
data = event.get("data", {})
|
data = event.get("data", {})
|
||||||
custom_data = data.get("custom_data", {})
|
custom_data = data.get("custom_data", {})
|
||||||
user_id = custom_data.get("user_id")
|
user_id = custom_data.get("user_id")
|
||||||
|
plan = custom_data.get("plan", "")
|
||||||
|
|
||||||
if event_type == "subscription.activated":
|
if event_type == "subscription.activated":
|
||||||
plan = custom_data.get("plan", "starter")
|
if plan.startswith("supplier_"):
|
||||||
|
await _handle_supplier_subscription_activated(data, custom_data)
|
||||||
|
else:
|
||||||
await upsert_subscription(
|
await upsert_subscription(
|
||||||
user_id=int(user_id) if user_id else 0,
|
user_id=int(user_id) if user_id else 0,
|
||||||
plan=plan,
|
plan=plan or "starter",
|
||||||
status="active",
|
status="active",
|
||||||
provider_customer_id=str(data.get("customer_id", "")),
|
provider_customer_id=str(data.get("customer_id", "")),
|
||||||
provider_subscription_id=data.get("id", ""),
|
provider_subscription_id=data.get("id", ""),
|
||||||
@@ -262,7 +255,170 @@ async def webhook():
|
|||||||
elif event_type == "subscription.past_due":
|
elif event_type == "subscription.past_due":
|
||||||
await update_subscription_status(data.get("id", ""), status="past_due")
|
await update_subscription_status(data.get("id", ""), status="past_due")
|
||||||
|
|
||||||
|
elif event_type == "transaction.completed":
|
||||||
|
await _handle_transaction_completed(data, custom_data)
|
||||||
|
|
||||||
return jsonify({"received": True})
|
return jsonify({"received": True})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Supplier Webhook Handlers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Map product keys to credit pack amounts
|
||||||
|
CREDIT_PACK_AMOUNTS = {
|
||||||
|
"credits_25": 25,
|
||||||
|
"credits_50": 50,
|
||||||
|
"credits_100": 100,
|
||||||
|
"credits_250": 250,
|
||||||
|
}
|
||||||
|
|
||||||
|
PLAN_MONTHLY_CREDITS = {"supplier_growth": 30, "supplier_pro": 100}
|
||||||
|
|
||||||
|
BOOST_PRICE_KEYS = {
|
||||||
|
"boost_logo": "logo",
|
||||||
|
"boost_highlight": "highlight",
|
||||||
|
"boost_verified": "verified",
|
||||||
|
"boost_newsletter": "newsletter",
|
||||||
|
"boost_sticky_week": "sticky_week",
|
||||||
|
"boost_sticky_month": "sticky_month",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _price_id_to_key(price_id: str) -> str | None:
|
||||||
|
"""Reverse-lookup a paddle_products key from a Paddle price ID."""
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,)
|
||||||
|
)
|
||||||
|
return row["key"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None:
|
||||||
|
"""Handle supplier plan subscription activation."""
|
||||||
|
from ..core import transaction as db_transaction
|
||||||
|
|
||||||
|
supplier_id = custom_data.get("supplier_id")
|
||||||
|
plan = custom_data.get("plan", "supplier_growth")
|
||||||
|
user_id = custom_data.get("user_id")
|
||||||
|
|
||||||
|
if not supplier_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
monthly_credits = PLAN_MONTHLY_CREDITS.get(plan, 0)
|
||||||
|
tier = "pro" if plan == "supplier_pro" else "growth"
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
async with db_transaction() as db:
|
||||||
|
# Update supplier record
|
||||||
|
await db.execute(
|
||||||
|
"""UPDATE suppliers SET tier = ?, claimed_at = ?, claimed_by = ?,
|
||||||
|
monthly_credits = ?, credit_balance = ?, last_credit_refill = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(tier, now, int(user_id) if user_id else None,
|
||||||
|
monthly_credits, monthly_credits, now, int(supplier_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initial credit allocation
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
||||||
|
VALUES (?, ?, ?, 'monthly_allocation', 'Initial credit allocation', ?)""",
|
||||||
|
(int(supplier_id), monthly_credits, monthly_credits, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create boost records for items included in the subscription
|
||||||
|
items = data.get("items", [])
|
||||||
|
for item in items:
|
||||||
|
price_id = item.get("price", {}).get("id", "")
|
||||||
|
key = await _price_id_to_key(price_id)
|
||||||
|
if key in BOOST_PRICE_KEYS:
|
||||||
|
boost_type = BOOST_PRICE_KEYS[key]
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO supplier_boosts
|
||||||
|
(supplier_id, boost_type, paddle_subscription_id, status, starts_at, created_at)
|
||||||
|
VALUES (?, ?, ?, 'active', ?, ?)""",
|
||||||
|
(int(supplier_id), boost_type, data.get("id", ""), now, now),
|
||||||
|
)
|
||||||
|
# Update denormalized columns
|
||||||
|
if boost_type == "highlight":
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE suppliers SET highlight = 1 WHERE id = ?", (int(supplier_id),)
|
||||||
|
)
|
||||||
|
elif boost_type == "verified":
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE suppliers SET is_verified = 1 WHERE id = ?", (int(supplier_id),)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
|
||||||
|
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
|
||||||
|
supplier_id = custom_data.get("supplier_id")
|
||||||
|
user_id = custom_data.get("user_id")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
items = data.get("items", [])
|
||||||
|
for item in items:
|
||||||
|
price_id = item.get("price", {}).get("id", "")
|
||||||
|
key = await _price_id_to_key(price_id)
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Credit pack purchases
|
||||||
|
if key in CREDIT_PACK_AMOUNTS and supplier_id:
|
||||||
|
from ..credits import add_credits
|
||||||
|
await add_credits(
|
||||||
|
int(supplier_id), CREDIT_PACK_AMOUNTS[key],
|
||||||
|
"pack_purchase", note=f"Credit pack: {key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sticky boost purchases
|
||||||
|
elif key == "boost_sticky_week" and supplier_id:
|
||||||
|
from datetime import timedelta
|
||||||
|
expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat()
|
||||||
|
country = custom_data.get("sticky_country", "")
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO supplier_boosts
|
||||||
|
(supplier_id, boost_type, status, starts_at, expires_at, created_at)
|
||||||
|
VALUES (?, 'sticky_week', 'active', ?, ?, ?)""",
|
||||||
|
(int(supplier_id), now, expires, now),
|
||||||
|
)
|
||||||
|
await execute(
|
||||||
|
"UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?",
|
||||||
|
(expires, country, int(supplier_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif key == "boost_sticky_month" and supplier_id:
|
||||||
|
from datetime import timedelta
|
||||||
|
expires = (datetime.utcnow() + timedelta(days=30)).isoformat()
|
||||||
|
country = custom_data.get("sticky_country", "")
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO supplier_boosts
|
||||||
|
(supplier_id, boost_type, status, starts_at, expires_at, created_at)
|
||||||
|
VALUES (?, 'sticky_month', 'active', ?, ?, ?)""",
|
||||||
|
(int(supplier_id), now, expires, now),
|
||||||
|
)
|
||||||
|
await execute(
|
||||||
|
"UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?",
|
||||||
|
(expires, country, int(supplier_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Business plan PDF purchase
|
||||||
|
elif key == "business_plan" and user_id:
|
||||||
|
scenario_id = custom_data.get("scenario_id")
|
||||||
|
language = custom_data.get("language", "en")
|
||||||
|
transaction_id = data.get("id", "")
|
||||||
|
export_id = await execute(
|
||||||
|
"""INSERT INTO business_plan_exports
|
||||||
|
(user_id, scenario_id, paddle_transaction_id, language, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'pending', ?)""",
|
||||||
|
(int(user_id), int(scenario_id) if scenario_id else 0,
|
||||||
|
transaction_id, language, now),
|
||||||
|
)
|
||||||
|
# Enqueue PDF generation
|
||||||
|
from ..worker import enqueue
|
||||||
|
await enqueue("generate_business_plan", {
|
||||||
|
"export_id": export_id,
|
||||||
|
"user_id": int(user_id),
|
||||||
|
"scenario_id": int(scenario_id) if scenario_id else 0,
|
||||||
|
"language": language,
|
||||||
|
})
|
||||||
|
|||||||
187
padelnomics/src/padelnomics/businessplan.py
Normal file
187
padelnomics/src/padelnomics/businessplan.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Business Plan PDF generation engine.
|
||||||
|
|
||||||
|
Renders an HTML template with planner data, converts to PDF via WeasyPrint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .core import fetch_one
|
||||||
|
from .planner.calculator import calc, validate_state
|
||||||
|
|
||||||
|
TEMPLATE_DIR = Path(__file__).parent / "templates" / "businessplan"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_eur(n) -> str:
|
||||||
|
"""Format number as EUR with thousands separator."""
|
||||||
|
if n is None:
|
||||||
|
return "-"
|
||||||
|
return f"\u20ac{n:,.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct(n) -> str:
|
||||||
|
"""Format decimal as percentage."""
|
||||||
|
if n is None:
|
||||||
|
return "-"
|
||||||
|
return f"{n * 100:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_months(idx: int) -> str:
|
||||||
|
"""Format payback month index as readable string."""
|
||||||
|
if idx < 0:
|
||||||
|
return "Not reached in 60 months"
|
||||||
|
months = idx + 1
|
||||||
|
if months <= 12:
|
||||||
|
return f"{months} months"
|
||||||
|
years = months / 12
|
||||||
|
return f"{years:.1f} years"
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
|
||||||
|
"""Extract and format all business plan sections from planner data."""
|
||||||
|
s = state
|
||||||
|
is_en = language == "en"
|
||||||
|
|
||||||
|
venue_type = "Indoor" if s["venue"] == "indoor" else "Outdoor"
|
||||||
|
own_type = "Own" if s["own"] == "buy" else "Rent"
|
||||||
|
|
||||||
|
sections = {
|
||||||
|
"title": "Padel Business Plan" if is_en else "Padel Geschäftsplan",
|
||||||
|
"subtitle": f"{venue_type} ({own_type}) \u2014 {s.get('country', 'DE')}",
|
||||||
|
"courts": f"{s['dblCourts']} double + {s['sglCourts']} single ({d['totalCourts']} total)",
|
||||||
|
|
||||||
|
# Executive Summary
|
||||||
|
"executive_summary": {
|
||||||
|
"heading": "Executive Summary" if is_en else "Zusammenfassung",
|
||||||
|
"facility_type": f"{venue_type} ({own_type})",
|
||||||
|
"courts": d["totalCourts"],
|
||||||
|
"sqm": d["sqm"],
|
||||||
|
"total_capex": _fmt_eur(d["capex"]),
|
||||||
|
"equity": _fmt_eur(d["equity"]),
|
||||||
|
"loan": _fmt_eur(d["loanAmount"]),
|
||||||
|
"y1_revenue": _fmt_eur(d["annuals"][0]["revenue"]) if d["annuals"] else "-",
|
||||||
|
"y3_ebitda": _fmt_eur(d["stabEbitda"]),
|
||||||
|
"irr": _fmt_pct(d["irr"]),
|
||||||
|
"payback": _fmt_months(d["paybackIdx"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
# Investment Plan (CAPEX)
|
||||||
|
"investment": {
|
||||||
|
"heading": "Investment Plan" if is_en else "Investitionsplan",
|
||||||
|
"items": d["capexItems"],
|
||||||
|
"total": _fmt_eur(d["capex"]),
|
||||||
|
"per_court": _fmt_eur(d["capexPerCourt"]),
|
||||||
|
"per_sqm": _fmt_eur(d["capexPerSqm"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
# Operating Costs
|
||||||
|
"operations": {
|
||||||
|
"heading": "Operating Costs" if is_en else "Betriebskosten",
|
||||||
|
"items": d["opexItems"],
|
||||||
|
"monthly_total": _fmt_eur(d["opex"]),
|
||||||
|
"annual_total": _fmt_eur(d["annualOpex"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
# Revenue Model
|
||||||
|
"revenue": {
|
||||||
|
"heading": "Revenue & Profitability" if is_en else "Umsatz & Rentabilit\u00e4t",
|
||||||
|
"weighted_rate": _fmt_eur(d["weightedRate"]),
|
||||||
|
"utilization": _fmt_pct(s["utilTarget"] / 100),
|
||||||
|
"gross_monthly": _fmt_eur(d["grossRevMonth"]),
|
||||||
|
"net_monthly": _fmt_eur(d["netRevMonth"]),
|
||||||
|
"ebitda_monthly": _fmt_eur(d["ebitdaMonth"]),
|
||||||
|
"net_cf_monthly": _fmt_eur(d["netCFMonth"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
# 5-Year P&L
|
||||||
|
"annuals": {
|
||||||
|
"heading": "5-Year Projection" if is_en else "5-Jahres-Projektion",
|
||||||
|
"years": [
|
||||||
|
{
|
||||||
|
"year": a["year"],
|
||||||
|
"revenue": _fmt_eur(a["revenue"]),
|
||||||
|
"ebitda": _fmt_eur(a["ebitda"]),
|
||||||
|
"debt_service": _fmt_eur(a["ds"]),
|
||||||
|
"net_cf": _fmt_eur(a["ncf"]),
|
||||||
|
}
|
||||||
|
for a in d["annuals"]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
# Financing
|
||||||
|
"financing": {
|
||||||
|
"heading": "Financing Structure" if is_en else "Finanzierungsstruktur",
|
||||||
|
"loan_pct": _fmt_pct(s["loanPct"] / 100),
|
||||||
|
"equity": _fmt_eur(d["equity"]),
|
||||||
|
"loan": _fmt_eur(d["loanAmount"]),
|
||||||
|
"interest_rate": f"{s['interestRate']}%",
|
||||||
|
"term": f"{s['loanTerm']} years",
|
||||||
|
"monthly_payment": _fmt_eur(d["monthlyPayment"]),
|
||||||
|
"annual_debt_service": _fmt_eur(d["annualDebtService"]),
|
||||||
|
"ltv": _fmt_pct(d["ltv"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
# Key Metrics
|
||||||
|
"metrics": {
|
||||||
|
"heading": "Key Metrics" if is_en else "Kennzahlen",
|
||||||
|
"irr": _fmt_pct(d["irr"]),
|
||||||
|
"moic": f"{d['moic']:.2f}x",
|
||||||
|
"cash_on_cash": _fmt_pct(d["cashOnCash"]),
|
||||||
|
"payback": _fmt_months(d["paybackIdx"]),
|
||||||
|
"break_even_util": _fmt_pct(d["breakEvenUtil"]),
|
||||||
|
"ebitda_margin": _fmt_pct(d["ebitdaMargin"]),
|
||||||
|
"dscr_y3": f"{d['dscr'][2]['dscr']:.2f}x" if len(d["dscr"]) >= 3 else "-",
|
||||||
|
"yield_on_cost": _fmt_pct(d["yieldOnCost"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
# 12-Month Cash Flow
|
||||||
|
"cashflow_12m": {
|
||||||
|
"heading": "12-Month Cash Flow" if is_en else "12-Monats-Liquidit\u00e4tsplan",
|
||||||
|
"months": [
|
||||||
|
{
|
||||||
|
"month": m["m"],
|
||||||
|
"revenue": _fmt_eur(m["totalRev"]),
|
||||||
|
"opex": _fmt_eur(abs(m["opex"])),
|
||||||
|
"ebitda": _fmt_eur(m["ebitda"]),
|
||||||
|
"debt": _fmt_eur(abs(m["loan"])),
|
||||||
|
"ncf": _fmt_eur(m["ncf"]),
|
||||||
|
"cumulative": _fmt_eur(m["cum"]),
|
||||||
|
}
|
||||||
|
for m in d["months"][:12]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_business_plan(scenario_id: int, user_id: int, language: str = "en") -> bytes:
|
||||||
|
"""Generate a business plan PDF from a saved scenario. Returns PDF bytes."""
|
||||||
|
scenario = await fetch_one(
|
||||||
|
"SELECT * FROM scenarios WHERE id = ? AND user_id = ?",
|
||||||
|
(scenario_id, user_id),
|
||||||
|
)
|
||||||
|
if not scenario:
|
||||||
|
raise ValueError(f"Scenario {scenario_id} not found for user {user_id}")
|
||||||
|
|
||||||
|
state = validate_state(json.loads(scenario["state_json"]))
|
||||||
|
d = calc(state)
|
||||||
|
sections = get_plan_sections(state, d, language)
|
||||||
|
sections["scenario_name"] = scenario["name"]
|
||||||
|
sections["location"] = scenario.get("location", "")
|
||||||
|
|
||||||
|
# Read HTML + CSS template
|
||||||
|
html_template = (TEMPLATE_DIR / "plan.html").read_text()
|
||||||
|
css = (TEMPLATE_DIR / "plan.css").read_text()
|
||||||
|
|
||||||
|
# Render with Jinja
|
||||||
|
from jinja2 import Template
|
||||||
|
template = Template(html_template)
|
||||||
|
rendered_html = template.render(s=sections, css=css)
|
||||||
|
|
||||||
|
# Convert to PDF via WeasyPrint
|
||||||
|
from weasyprint import HTML
|
||||||
|
pdf_bytes = HTML(string=rendered_html).write_pdf()
|
||||||
|
|
||||||
|
return pdf_bytes
|
||||||
@@ -11,7 +11,7 @@ from functools import wraps
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import httpx
|
import resend
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from quart import g, request, session
|
from quart import g, request, session
|
||||||
|
|
||||||
@@ -41,16 +41,18 @@ class Config:
|
|||||||
PAYMENT_PROVIDER: str = "paddle"
|
PAYMENT_PROVIDER: str = "paddle"
|
||||||
|
|
||||||
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
|
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
|
||||||
|
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
|
||||||
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
|
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
|
||||||
PADDLE_PRICES: dict = {
|
PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox")
|
||||||
"starter": os.getenv("PADDLE_PRICE_STARTER", ""),
|
|
||||||
"pro": os.getenv("PADDLE_PRICE_PRO", ""),
|
UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
|
||||||
"business_plan": os.getenv("PADDLE_PRICE_BUSINESS_PLAN", ""),
|
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
|
||||||
}
|
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
|
||||||
|
|
||||||
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
||||||
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
||||||
ADMIN_EMAIL: str = _env("ADMIN_EMAIL", "leads@padelnomics.io")
|
ADMIN_EMAIL: str = _env("ADMIN_EMAIL", "leads@padelnomics.io")
|
||||||
|
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
||||||
|
|
||||||
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||||
@@ -161,25 +163,34 @@ class transaction:
|
|||||||
# Email
|
# Email
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
async def send_email(to: str, subject: str, html: str, text: str = None) -> bool:
|
EMAIL_ADDRESSES = {
|
||||||
"""Send email via Resend API."""
|
"transactional": "Padelnomics <hello@notification.padelnomics.io>",
|
||||||
|
"leads": "Padelnomics Leads <leads@notification.padelnomics.io>",
|
||||||
|
"nurture": "Padelnomics <coach@notification.padelnomics.io>",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
to: str, subject: str, html: str, text: str = None, from_addr: str = None
|
||||||
|
) -> bool:
|
||||||
|
"""Send email via Resend SDK."""
|
||||||
if not config.RESEND_API_KEY:
|
if not config.RESEND_API_KEY:
|
||||||
print(f"[EMAIL] Would send to {to}: {subject}")
|
print(f"[EMAIL] Would send to {to}: {subject}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
resend.api_key = config.RESEND_API_KEY
|
||||||
response = await client.post(
|
try:
|
||||||
"https://api.resend.com/emails",
|
resend.Emails.send({
|
||||||
headers={"Authorization": f"Bearer {config.RESEND_API_KEY}"},
|
"from": from_addr or config.EMAIL_FROM,
|
||||||
json={
|
|
||||||
"from": config.EMAIL_FROM,
|
|
||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"html": html,
|
"html": html,
|
||||||
"text": text or html,
|
"text": text or html,
|
||||||
},
|
})
|
||||||
)
|
return True
|
||||||
return response.status_code == 200
|
except Exception as e:
|
||||||
|
print(f"[EMAIL] Error sending to {to}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CSRF Protection
|
# CSRF Protection
|
||||||
@@ -344,3 +355,21 @@ async def purge_deleted(table: str, days: int = 30) -> int:
|
|||||||
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
|
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
|
||||||
(cutoff,)
|
(cutoff,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Paddle Product Lookup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_paddle_price(key: str) -> str | None:
|
||||||
|
"""Look up a Paddle price ID by product key from the paddle_products table."""
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)
|
||||||
|
)
|
||||||
|
return row["paddle_price_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_paddle_prices() -> dict[str, str]:
|
||||||
|
"""Load all Paddle price IDs as a {key: price_id} dict."""
|
||||||
|
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
|
||||||
|
return {r["key"]: r["paddle_price_id"] for r in rows}
|
||||||
|
|||||||
206
padelnomics/src/padelnomics/credits.py
Normal file
206
padelnomics/src/padelnomics/credits.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
Credit system: balance tracking, lead unlocking, and ledger management.
|
||||||
|
|
||||||
|
All balance mutations go through this module to keep credit_ledger (source of truth)
|
||||||
|
and suppliers.credit_balance (denormalized cache) in sync within a single transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .core import execute, fetch_all, fetch_one, transaction
|
||||||
|
|
||||||
|
# Credit cost per heat tier
|
||||||
|
HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8}
|
||||||
|
|
||||||
|
# Monthly credits by supplier plan
|
||||||
|
PLAN_MONTHLY_CREDITS = {"growth": 30, "pro": 100}
|
||||||
|
|
||||||
|
# Credit pack prices (amount -> EUR cents, for reference)
|
||||||
|
CREDIT_PACKS = {25: 99, 50: 179, 100: 329, 250: 749}
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientCredits(Exception):
|
||||||
|
"""Raised when a supplier doesn't have enough credits."""
|
||||||
|
|
||||||
|
def __init__(self, balance: int, required: int):
|
||||||
|
self.balance = balance
|
||||||
|
self.required = required
|
||||||
|
super().__init__(f"Need {required} credits, have {balance}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_balance(supplier_id: int) -> int:
|
||||||
|
"""Get current credit balance for a supplier."""
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||||
|
)
|
||||||
|
return row["credit_balance"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def add_credits(
|
||||||
|
supplier_id: int,
|
||||||
|
amount: int,
|
||||||
|
event_type: str,
|
||||||
|
reference_id: int = None,
|
||||||
|
note: str = None,
|
||||||
|
) -> int:
|
||||||
|
"""Add credits to a supplier. Returns new balance."""
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
async with transaction() as db:
|
||||||
|
row = await db.execute_fetchall(
|
||||||
|
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||||
|
)
|
||||||
|
current = row[0][0] if row else 0
|
||||||
|
new_balance = current + amount
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO credit_ledger
|
||||||
|
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(supplier_id, amount, new_balance, event_type, reference_id, note, now),
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
|
||||||
|
(new_balance, supplier_id),
|
||||||
|
)
|
||||||
|
return new_balance
|
||||||
|
|
||||||
|
|
||||||
|
async def spend_credits(
|
||||||
|
supplier_id: int,
|
||||||
|
amount: int,
|
||||||
|
event_type: str,
|
||||||
|
reference_id: int = None,
|
||||||
|
note: str = None,
|
||||||
|
) -> int:
|
||||||
|
"""Spend credits from a supplier. Returns new balance. Raises InsufficientCredits."""
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
async with transaction() as db:
|
||||||
|
row = await db.execute_fetchall(
|
||||||
|
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||||
|
)
|
||||||
|
current = row[0][0] if row else 0
|
||||||
|
|
||||||
|
if current < amount:
|
||||||
|
raise InsufficientCredits(current, amount)
|
||||||
|
|
||||||
|
new_balance = current - amount
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO credit_ledger
|
||||||
|
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(supplier_id, -amount, new_balance, event_type, reference_id, note, now),
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
|
||||||
|
(new_balance, supplier_id),
|
||||||
|
)
|
||||||
|
return new_balance
|
||||||
|
|
||||||
|
|
||||||
|
async def already_unlocked(supplier_id: int, lead_id: int) -> bool:
|
||||||
|
"""Check if a supplier has already unlocked a lead."""
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT 1 FROM lead_forwards WHERE supplier_id = ? AND lead_id = ?",
|
||||||
|
(supplier_id, lead_id),
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def unlock_lead(supplier_id: int, lead_id: int) -> dict:
|
||||||
|
"""Unlock a lead for a supplier. Atomic: check, spend, insert forward, increment unlock_count."""
|
||||||
|
if await already_unlocked(supplier_id, lead_id):
|
||||||
|
raise ValueError("Lead already unlocked by this supplier")
|
||||||
|
|
||||||
|
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||||
|
if not lead:
|
||||||
|
raise ValueError("Lead not found")
|
||||||
|
|
||||||
|
cost = lead["credit_cost"] or compute_credit_cost(lead)
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
async with transaction() as db:
|
||||||
|
# Check balance
|
||||||
|
row = await db.execute_fetchall(
|
||||||
|
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||||
|
)
|
||||||
|
current = row[0][0] if row else 0
|
||||||
|
if current < cost:
|
||||||
|
raise InsufficientCredits(current, cost)
|
||||||
|
|
||||||
|
new_balance = current - cost
|
||||||
|
|
||||||
|
# Insert lead forward
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)""",
|
||||||
|
(lead_id, supplier_id, cost, now),
|
||||||
|
)
|
||||||
|
forward_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Record in ledger
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO credit_ledger
|
||||||
|
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||||||
|
VALUES (?, ?, ?, 'lead_unlock', ?, ?, ?)""",
|
||||||
|
(supplier_id, -cost, new_balance, forward_id,
|
||||||
|
f"Unlocked lead #{lead_id}", now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update supplier balance
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
|
||||||
|
(new_balance, supplier_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Increment unlock count on lead
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE lead_requests SET unlock_count = unlock_count + 1 WHERE id = ?",
|
||||||
|
(lead_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"forward_id": forward_id,
|
||||||
|
"credit_cost": cost,
|
||||||
|
"new_balance": new_balance,
|
||||||
|
"lead": dict(lead),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_credit_cost(lead: dict) -> int:
|
||||||
|
"""Compute credit cost from lead heat score."""
|
||||||
|
heat = (lead.get("heat_score") or "cool").lower()
|
||||||
|
return HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
|
||||||
|
|
||||||
|
|
||||||
|
async def monthly_credit_refill(supplier_id: int) -> int:
|
||||||
|
"""Refill monthly credits for a supplier. Returns new balance."""
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT monthly_credits, tier FROM suppliers WHERE id = ?", (supplier_id,)
|
||||||
|
)
|
||||||
|
if not row or not row["monthly_credits"]:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
new_balance = await add_credits(
|
||||||
|
supplier_id,
|
||||||
|
row["monthly_credits"],
|
||||||
|
"monthly_allocation",
|
||||||
|
note=f"Monthly refill ({row['tier']} plan)",
|
||||||
|
)
|
||||||
|
await execute(
|
||||||
|
"UPDATE suppliers SET last_credit_refill = ? WHERE id = ?",
|
||||||
|
(now, supplier_id),
|
||||||
|
)
|
||||||
|
return new_balance
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]:
|
||||||
|
"""Get credit ledger entries for a supplier."""
|
||||||
|
return await fetch_all(
|
||||||
|
"""SELECT cl.*, lf.lead_id
|
||||||
|
FROM credit_ledger cl
|
||||||
|
LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock'
|
||||||
|
WHERE cl.supplier_id = ?
|
||||||
|
ORDER BY cl.created_at DESC LIMIT ?""",
|
||||||
|
(supplier_id, limit),
|
||||||
|
)
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Supplier directory: public, searchable listing of padel court suppliers.
|
Supplier directory: public, searchable listing of padel court suppliers.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, render_template, request
|
from quart import Blueprint, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..core import fetch_all, fetch_one
|
from ..core import fetch_all, fetch_one
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ REGION_LABELS = {
|
|||||||
|
|
||||||
async def _build_directory_query(q, country, category, region, page, per_page=24):
|
async def _build_directory_query(q, country, category, region, page, per_page=24):
|
||||||
"""Shared query builder for directory index and HTMX results."""
|
"""Shared query builder for directory index and HTMX results."""
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
params: list = []
|
params: list = []
|
||||||
wheres: list[str] = []
|
wheres: list[str] = []
|
||||||
@@ -152,6 +152,49 @@ async def index():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<slug>")
|
||||||
|
async def supplier_detail(slug: str):
|
||||||
|
"""Public supplier profile page."""
|
||||||
|
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,))
|
||||||
|
if not supplier:
|
||||||
|
from quart import abort
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Get active boosts
|
||||||
|
boosts = await fetch_all(
|
||||||
|
"SELECT boost_type FROM supplier_boosts WHERE supplier_id = ? AND status = 'active'",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
active_boosts = [b["boost_type"] for b in boosts]
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"supplier_detail.html",
|
||||||
|
supplier=supplier,
|
||||||
|
active_boosts=active_boosts,
|
||||||
|
country_labels=COUNTRY_LABELS,
|
||||||
|
category_labels=CATEGORY_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<slug>/website")
|
||||||
|
async def supplier_website(slug: str):
|
||||||
|
"""Redirect to supplier website — tracked as Umami event."""
|
||||||
|
supplier = await fetch_one("SELECT website FROM suppliers WHERE slug = ?", (slug,))
|
||||||
|
if not supplier or not supplier["website"]:
|
||||||
|
from quart import abort
|
||||||
|
abort(404)
|
||||||
|
url = supplier["website"]
|
||||||
|
if not url.startswith("http"):
|
||||||
|
url = "https://" + url
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/<slug>/quote")
|
||||||
|
async def supplier_quote(slug: str):
|
||||||
|
"""Redirect to quote request form — tracked as Umami event."""
|
||||||
|
return redirect(url_for("leads.quote_request", supplier=slug))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/results")
|
@bp.route("/results")
|
||||||
async def results():
|
async def results():
|
||||||
"""HTMX endpoint — returns only the results partial."""
|
"""HTMX endpoint — returns only the results partial."""
|
||||||
|
|||||||
@@ -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' %}
|
||||||
<div 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 %}">
|
<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 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">
|
||||||
@@ -47,14 +47,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="dir-card__foot">
|
<div class="dir-card__foot">
|
||||||
<span class="dir-card__web">
|
<span class="dir-card__web">
|
||||||
{% if s.website %}<a href="https://{{ s.website }}" target="_blank" rel="noopener">{{ s.website }}</a>{% endif %}
|
{% if s.website %}{{ s.website }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
<span style="font-size:0.6875rem;color:#1D4ED8;font-weight:600">Request Quote →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
{# --- Growth tier card --- #}
|
{# --- Growth tier card --- #}
|
||||||
{% elif s.tier == 'growth' %}
|
{% elif s.tier == 'growth' %}
|
||||||
<div class="dir-card dir-card--growth {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}">
|
<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 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>
|
||||||
@@ -67,12 +68,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="dir-card__foot">
|
<div class="dir-card__foot">
|
||||||
<span></span>
|
<span></span>
|
||||||
|
<span style="font-size:0.6875rem;color:#1D4ED8;font-weight:600">Request Quote →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
{# --- Free / unclaimed tier card --- #}
|
{# --- Free / unclaimed tier card --- #}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="dir-card dir-card--free">
|
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--free" style="text-decoration:none;color:inherit;display:block">
|
||||||
<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>
|
||||||
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
|
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
|
||||||
@@ -81,9 +83,9 @@
|
|||||||
<div class="dir-card__tier-badge dir-card__tier-badge--unverified">Unverified</div>
|
<div class="dir-card__tier-badge dir-card__tier-badge--unverified">Unverified</div>
|
||||||
<div class="dir-card__foot">
|
<div class="dir-card__foot">
|
||||||
<span></span>
|
<span></span>
|
||||||
<span class="dir-card__claim"><a href="{{ url_for('public.suppliers') }}">Is this your company? →</a></span>
|
<span class="dir-card__claim">Is this your company? →</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ supplier.name }} - Supplier Directory - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.sp-hero { max-width: 800px; margin: 0 auto; }
|
||||||
|
.sp-card { background: white; border-radius: 16px; padding: 2rem; box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
|
||||||
|
.sp-header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.sp-logo { width: 64px; height: 64px; border-radius: 12px; object-fit: cover; background: #F1F5F9; flex-shrink: 0; }
|
||||||
|
.sp-logo-placeholder {
|
||||||
|
width: 64px; height: 64px; border-radius: 12px; background: #F1F5F9;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 24px; font-weight: 800; color: #94A3B8; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sp-name { font-size: 1.5rem; font-weight: 800; color: #0F172A; margin: 0; }
|
||||||
|
.sp-location { font-size: 0.875rem; color: #64748B; margin: 2px 0 0; }
|
||||||
|
.sp-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.sp-badge { font-size: 0.6875rem; font-weight: 600; padding: 3px 10px; border-radius: 999px; }
|
||||||
|
.sp-badge--category { background: #EFF6FF; color: #1D4ED8; }
|
||||||
|
.sp-badge--verified { background: #DCFCE7; color: #16A34A; }
|
||||||
|
.sp-badge--tier { background: #F1F5F9; color: #64748B; }
|
||||||
|
.sp-desc { font-size: 0.9375rem; color: #475569; line-height: 1.7; margin-bottom: 1.5rem; }
|
||||||
|
.sp-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||||
|
.sp-meta-item { font-size: 0.8125rem; }
|
||||||
|
.sp-meta-item dt { color: #94A3B8; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.sp-meta-item dd { color: #1E293B; font-weight: 600; margin: 2px 0 0; }
|
||||||
|
.sp-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1.5rem; }
|
||||||
|
.sp-pill {
|
||||||
|
font-size: 0.6875rem; font-weight: 600; padding: 4px 12px;
|
||||||
|
border-radius: 999px; background: #F1F5F9; color: #475569;
|
||||||
|
}
|
||||||
|
.sp-cta { display: flex; gap: 0.75rem; }
|
||||||
|
.sp-cta .btn { flex: 1; text-align: center; }
|
||||||
|
.sp-cta .btn-outline { flex: 1; text-align: center; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sp-meta { grid-template-columns: 1fr; }
|
||||||
|
.sp-cta { flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 60vh;">
|
||||||
|
<div class="container-page py-12">
|
||||||
|
<div class="sp-hero">
|
||||||
|
<a href="{{ url_for('directory.index') }}" style="font-size:0.8125rem;color:#64748B;text-decoration:none;display:block;margin-bottom:1rem">← Back to Directory</a>
|
||||||
|
|
||||||
|
<div class="sp-card">
|
||||||
|
<div class="sp-header">
|
||||||
|
{% if supplier.logo_url %}
|
||||||
|
<img src="{{ supplier.logo_url }}" alt="" class="sp-logo">
|
||||||
|
{% else %}
|
||||||
|
<div class="sp-logo-placeholder">{{ supplier.name[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h1 class="sp-name">{{ supplier.name }}</h1>
|
||||||
|
<p class="sp-location">{{ country_labels.get(supplier.country_code, supplier.country_code) }}{% if supplier.city %}, {{ supplier.city }}{% endif %}</p>
|
||||||
|
<div class="sp-badges">
|
||||||
|
<span class="sp-badge sp-badge--category">{{ category_labels.get(supplier.category, supplier.category) }}</span>
|
||||||
|
{% if supplier.is_verified %}
|
||||||
|
<span class="sp-badge sp-badge--verified">Verified ✓</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if supplier.tier != 'free' %}
|
||||||
|
<span class="sp-badge sp-badge--tier">{{ supplier.tier | title }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set desc = supplier.long_description or supplier.description %}
|
||||||
|
{% if desc %}
|
||||||
|
<p class="sp-desc">{{ desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if supplier.service_categories %}
|
||||||
|
<div class="sp-pills">
|
||||||
|
{% for cat in (supplier.service_categories or '').split(',') %}
|
||||||
|
{% if cat.strip() %}
|
||||||
|
<span class="sp-pill">{{ cat.strip() }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sp-meta">
|
||||||
|
{% if supplier.service_area %}
|
||||||
|
<div class="sp-meta-item">
|
||||||
|
<dt>Service Area</dt>
|
||||||
|
<dd>{{ supplier.service_area }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if supplier.years_in_business %}
|
||||||
|
<div class="sp-meta-item">
|
||||||
|
<dt>Years in Business</dt>
|
||||||
|
<dd>{{ supplier.years_in_business }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if supplier.project_count %}
|
||||||
|
<div class="sp-meta-item">
|
||||||
|
<dt>Projects Completed</dt>
|
||||||
|
<dd>{{ supplier.project_count }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if supplier.website %}
|
||||||
|
<div class="sp-meta-item">
|
||||||
|
<dt>Website</dt>
|
||||||
|
<dd><a href="https://{{ supplier.website }}" target="_blank" rel="noopener" style="color:#1D4ED8">{{ supplier.website }}</a></dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-cta">
|
||||||
|
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}" class="btn">Request Quote</a>
|
||||||
|
{% if not supplier.claimed_by %}
|
||||||
|
<a href="{{ url_for('suppliers.claim', slug=supplier.slug) }}" class="btn-outline">Claim This Listing</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -277,6 +277,10 @@ async def quote_request():
|
|||||||
|
|
||||||
heat = calculate_heat_score(form)
|
heat = calculate_heat_score(form)
|
||||||
|
|
||||||
|
# Compute credit cost from heat tier
|
||||||
|
from ..credits import HEAT_CREDIT_COSTS
|
||||||
|
credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
|
||||||
|
|
||||||
services_json = json.dumps(services) if services else None
|
services_json = json.dumps(services) if services else None
|
||||||
|
|
||||||
user_id = g.user["id"] if g.user else None
|
user_id = g.user["id"] if g.user else None
|
||||||
@@ -298,8 +302,8 @@ async def quote_request():
|
|||||||
previous_supplier_contact, services_needed, additional_info,
|
previous_supplier_contact, services_needed, additional_info,
|
||||||
contact_name, contact_email, contact_phone, contact_company,
|
contact_name, contact_email, contact_phone, contact_company,
|
||||||
stakeholder_type,
|
stakeholder_type,
|
||||||
heat_score, status, created_at)
|
heat_score, status, credit_cost, created_at)
|
||||||
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
user_id,
|
user_id,
|
||||||
form.get("court_count", 0),
|
form.get("court_count", 0),
|
||||||
@@ -325,10 +329,23 @@ async def quote_request():
|
|||||||
form.get("stakeholder_type", ""),
|
form.get("stakeholder_type", ""),
|
||||||
heat,
|
heat,
|
||||||
status,
|
status,
|
||||||
|
credit_cost,
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove from nurture audience (stop drip emails)
|
||||||
|
if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY:
|
||||||
|
try:
|
||||||
|
import resend
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
resend.Contacts.remove(
|
||||||
|
audience_id=config.RESEND_AUDIENCE_PLANNER,
|
||||||
|
email=contact_email,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Best-effort removal
|
||||||
|
|
||||||
if is_verified_user:
|
if is_verified_user:
|
||||||
# Existing flow: notify admin immediately
|
# Existing flow: notify admin immediately
|
||||||
await send_email(
|
await send_email(
|
||||||
@@ -441,11 +458,13 @@ async def verify_quote():
|
|||||||
# Mark token used
|
# Mark token used
|
||||||
await mark_token_used(token_data["id"])
|
await mark_token_used(token_data["id"])
|
||||||
|
|
||||||
# Activate lead
|
# Compute credit cost and activate lead
|
||||||
|
from ..credits import compute_credit_cost
|
||||||
|
credit_cost = compute_credit_cost(dict(lead))
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
await execute(
|
await execute(
|
||||||
"UPDATE lead_requests SET status = 'new', verified_at = ? WHERE id = ?",
|
"UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?",
|
||||||
(now, lead_id),
|
(now, credit_cost, lead_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set user name from contact_name if not already set
|
# Set user name from contact_name if not already set
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{# Step 1: Your Project #}
|
||||||
|
{% if data.get('facility_type') %}
|
||||||
|
{# Pre-filled from planner — show read-only summary #}
|
||||||
|
<h2 class="q-step-title">Your Project</h2>
|
||||||
|
<p class="q-step-sub">Pre-filled from the planner. You can edit these in the planner.</p>
|
||||||
|
|
||||||
|
<div class="q-prefill-card">
|
||||||
|
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:2px 1rem;margin:0">
|
||||||
|
<dt>Facility</dt><dd>{{ data.facility_type | replace('_',' ') | title }}</dd>
|
||||||
|
{% if data.get('court_count') %}<dt>Courts</dt><dd>{{ data.court_count }}</dd>{% endif %}
|
||||||
|
{% if data.get('glass_type') %}<dt>Glass</dt><dd>{{ data.glass_type | replace('_',' ') | title }}</dd>{% endif %}
|
||||||
|
{% if data.get('lighting_type') %}<dt>Lighting</dt><dd>{{ data.lighting_type | replace('_',' ') | title }}</dd>{% endif %}
|
||||||
|
{% if data.get('budget_estimate') %}<dt>Budget</dt><dd>€{{ data.budget_estimate }}</dd>{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="facility_type" value="{{ data.facility_type }}">
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<div></div>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Your Project</h2>
|
||||||
|
<p class="q-step-sub">What type of padel facility are you planning?</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Facility Type <span class="required">*</span></span>
|
||||||
|
{% if 'facility_type' in errors %}<p class="q-error-hint">Please select a facility type</p>{% endif %}
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('both', 'Indoor + Outdoor')] %}
|
||||||
|
<label><input type="radio" name="facility_type" value="{{ val }}" {{ 'checked' if data.get('facility_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="court_count">Number of Courts</label>
|
||||||
|
<input type="number" id="court_count" name="court_count" class="q-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Glass Type</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('standard', 'Standard Glass'), ('panoramic', 'Panoramic Glass'), ('no_preference', 'No Preference')] %}
|
||||||
|
<label><input type="radio" name="glass_type" value="{{ val }}" {{ 'checked' if data.get('glass_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Lighting</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('led_standard', 'LED Standard'), ('led_competition', 'LED Competition'), ('natural', 'Natural Light'), ('not_sure', 'Not Sure')] %}
|
||||||
|
<label><input type="radio" name="lighting_type" value="{{ val }}" {{ 'checked' if data.get('lighting_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<div></div>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Location</h2>
|
||||||
|
<p class="q-step-sub">Where are you planning to build?</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="city">City / Region</label>
|
||||||
|
<input type="text" id="city" name="city" class="q-input" placeholder="e.g. Munich, Bavaria" value="{{ data.get('city', '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="country">Country <span class="required">*</span></label>
|
||||||
|
{% if 'country' in errors %}<p class="q-error-hint">Please select a country</p>{% endif %}
|
||||||
|
<select id="country" name="country" class="q-input {% if 'country' in errors %}q-input--error{% endif %}">
|
||||||
|
<option value="">Select country...</option>
|
||||||
|
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('UK', 'United Kingdom'), ('PT', 'Portugal'), ('BE', 'Belgium'), ('AT', 'Austria'), ('CH', 'Switzerland'), ('DK', 'Denmark'), ('FI', 'Finland'), ('NO', 'Norway'), ('PL', 'Poland'), ('CZ', 'Czech Republic'), ('AE', 'UAE'), ('SA', 'Saudi Arabia'), ('US', 'United States'), ('OTHER', 'Other')] %}
|
||||||
|
<option value="{{ code }}" {{ 'selected' if data.get('country') == code }}>{{ name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=1, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Build Context</h2>
|
||||||
|
<p class="q-step-sub">What best describes your project?</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Build Context</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('new_standalone', 'New Standalone Venue'), ('adding_to_club', 'Adding to Existing Club'), ('converting_building', 'Converting a Building'), ('venue_search', 'Need Help Finding a Venue')] %}
|
||||||
|
<label><input type="radio" name="build_context" value="{{ val }}" {{ 'checked' if data.get('build_context') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=2, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Project Phase</h2>
|
||||||
|
<p class="q-step-sub">Where are you in the process?</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Project Phase</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('still_searching', 'Still searching for a location'), ('location_found', 'Location identified'), ('converting_existing', 'Converting existing facility'), ('lease_signed', 'Lease / purchase signed'), ('permit_not_filed', 'Permit not yet filed'), ('permit_pending', 'Permit in progress'), ('permit_granted', 'Permit approved')] %}
|
||||||
|
<label><input type="radio" name="location_status" value="{{ val }}" {{ 'checked' if data.get('location_status') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=3, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Timeline</h2>
|
||||||
|
<p class="q-step-sub">When do you want to get started?</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Timeline <span class="required">*</span></span>
|
||||||
|
{% if 'timeline' in errors %}<p class="q-error-hint">Please select a timeline</p>{% endif %}
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('asap', 'ASAP'), ('3-6mo', '3-6 Months'), ('6-12mo', '6-12 Months'), ('12+mo', '12+ Months')] %}
|
||||||
|
<label><input type="radio" name="timeline" value="{{ val }}" {{ 'checked' if data.get('timeline') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="budget_estimate">Budget Estimate (€)</label>
|
||||||
|
<input type="number" id="budget_estimate" name="budget_estimate" class="q-input" placeholder="e.g. 500000" value="{{ data.get('budget_estimate', '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=4, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Financing</h2>
|
||||||
|
<p class="q-step-sub">How are you funding the project?</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Financing Status</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('self_funded', 'Self-Funded'), ('loan_approved', 'Loan Approved'), ('seeking', 'Seeking Financing'), ('not_started', 'Not Started')] %}
|
||||||
|
<label><input type="radio" name="financing_status" value="{{ val }}" {{ 'checked' if data.get('financing_status') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-checkbox-label">
|
||||||
|
<input type="checkbox" name="wants_financing_help" value="1" {{ 'checked' if data.get('wants_financing_help') == '1' }}>
|
||||||
|
<span>I'd like help finding financing options</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Decision Process</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('solo', 'Solo Decision'), ('partners', 'With Partners'), ('committee', 'Committee / Board')] %}
|
||||||
|
<label><input type="radio" name="decision_process" value="{{ val }}" {{ 'checked' if data.get('decision_process') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=5, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">About You</h2>
|
||||||
|
<p class="q-step-sub">This helps us match you with the right suppliers.</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">You are... <span class="required">*</span></span>
|
||||||
|
{% if 'stakeholder_type' in errors %}<p class="q-error-hint">Please select your role</p>{% endif %}
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('entrepreneur', 'Entrepreneur / Investor'), ('tennis_club', 'Tennis / Sports Club'), ('municipality', 'Municipality / Public Body'), ('developer', 'Real Estate Developer'), ('operator', 'Existing Padel Operator'), ('architect', 'Architect / Engineer')] %}
|
||||||
|
<label><input type="radio" name="stakeholder_type" value="{{ val }}" {{ 'checked' if data.get('stakeholder_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Have you contacted suppliers before?</span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% for val, label in [('first_time', 'First time'), ('researching', 'Researching options'), ('received_quotes', 'Already received quotes')] %}
|
||||||
|
<label><input type="radio" name="previous_supplier_contact" value="{{ val }}" {{ 'checked' if data.get('previous_supplier_contact') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=6, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{# 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="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Services Needed</h2>
|
||||||
|
<p class="q-step-sub">Select all that apply. This helps suppliers prepare relevant proposals.</p>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<span class="q-label">Services <span style="color:#94A3B8;font-weight:400">(select all that apply)</span></span>
|
||||||
|
<div class="q-pills">
|
||||||
|
{% set selected_services = data.get('services_needed', []) %}
|
||||||
|
{% for val, label in [('court_supply', 'Court Supply'), ('installation', 'Installation'), ('construction', 'Hall Construction'), ('design', 'Facility Design'), ('lighting', 'Lighting'), ('flooring', 'Flooring'), ('turnkey', 'Full Turnkey')] %}
|
||||||
|
<label><input type="checkbox" name="services_needed" value="{{ val }}" {{ 'checked' if val in selected_services }}><span class="q-pill">{{ label }}</span></label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="additional_info">Anything else?</label>
|
||||||
|
<textarea id="additional_info" name="additional_info" class="q-input" rows="3" placeholder="Any specific requirements, questions, or context...">{{ data.get('additional_info', '') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=7, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-next">Next →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{# Step 9: Contact Details — final submit #}
|
||||||
|
<form method="post" action="{{ url_for('leads.quote_request') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{# Expand accumulated data into individual hidden fields for the POST handler #}
|
||||||
|
{% for key, val in data.items() %}
|
||||||
|
{% if key != 'services_needed' %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ val }}">
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for svc in data.get('services_needed', []) %}
|
||||||
|
<input type="hidden" name="services_needed" value="{{ svc }}">
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<h2 class="q-step-title">Contact Details</h2>
|
||||||
|
<p class="q-step-sub">How should matched suppliers reach you?</p>
|
||||||
|
|
||||||
|
<div class="q-privacy-box">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5 7V5a3.5 3.5 0 10-7 0v2M4 7h8a1 1 0 011 1v5a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1z" stroke="#3B82F6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span>Your contact details are shared only with pre-vetted suppliers that match your project specs.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="contact_name">Full Name <span class="required">*</span></label>
|
||||||
|
{% if 'contact_name' in errors %}<p class="q-error-hint">Full name is required</p>{% endif %}
|
||||||
|
<input type="text" id="contact_name" name="contact_name" class="q-input {% if 'contact_name' in errors %}q-input--error{% endif %}" value="{{ data.get('contact_name', '') }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="contact_email">Email <span class="required">*</span></label>
|
||||||
|
{% if 'contact_email' in errors %}<p class="q-error-hint">Email is required</p>{% endif %}
|
||||||
|
<input type="email" id="contact_email" name="contact_email" class="q-input {% if 'contact_email' in errors %}q-input--error{% endif %}" value="{{ data.get('contact_email', '') }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="contact_phone">Phone <span style="color:#94A3B8;font-weight:400">(optional)</span></label>
|
||||||
|
<input type="tel" id="contact_phone" name="contact_phone" class="q-input" value="{{ data.get('contact_phone', '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-field-group">
|
||||||
|
<label class="q-label" for="contact_company">Company <span style="color:#94A3B8;font-weight:400">(optional)</span></label>
|
||||||
|
<input type="text" id="contact_company" name="contact_company" class="q-input" value="{{ data.get('contact_company', '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-consent">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="consent" value="1" required>
|
||||||
|
<span>I agree that my project details and contact information may be shared with verified padel court suppliers matched to my project. <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> · <a href="{{ url_for('public.terms') }}">Terms</a></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-nav">
|
||||||
|
<button type="button" class="q-btn-back"
|
||||||
|
hx-get="{{ url_for('leads.quote_step', step=8, _accumulated=data | tojson) }}"
|
||||||
|
hx-target="#quote-step" hx-swap="innerHTML">← Back</button>
|
||||||
|
<button type="submit" class="q-btn-submit">Submit & Get Quotes →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; font-size: 11px; color: #94A3B8; margin-top: 1rem;">No obligation.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
|
<div class="q-progress__meta">
|
||||||
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="q-progress__track">
|
||||||
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,337 +3,120 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
.quote-flow { max-width: 680px; margin: 0 auto; }
|
.q-wizard { max-width: 560px; margin: 2rem auto; }
|
||||||
.quote-progress { display: flex; gap: 4px; margin-bottom: 0.5rem; }
|
.q-progress { margin-bottom: 1.5rem; }
|
||||||
.quote-progress__step {
|
.q-progress__meta {
|
||||||
flex: 1; height: 4px; border-radius: 2px;
|
display: flex; justify-content: space-between; align-items: baseline;
|
||||||
background: #E2E8F0; transition: background 0.3s;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.quote-progress__step.active { background: #3B82F6; }
|
.q-progress__label { font-size: 12px; font-weight: 600; color: #334155; }
|
||||||
.quote-progress__labels {
|
.q-progress__count { font-size: 11px; color: #94A3B8; }
|
||||||
display: flex; justify-content: space-between;
|
.q-progress__track {
|
||||||
margin-bottom: 2rem; font-size: 0.75rem; font-weight: 600;
|
height: 4px; border-radius: 2px; background: #E2E8F0; overflow: hidden;
|
||||||
color: #94A3B8;
|
|
||||||
}
|
}
|
||||||
.quote-progress__labels span.active { color: #3B82F6; }
|
.q-progress__fill {
|
||||||
.quote-step { display: none; }
|
height: 100%; border-radius: 2px; background: #1D4ED8;
|
||||||
.quote-step.active { display: block; }
|
transition: width 0.3s ease;
|
||||||
.quote-step h2 { font-size: 1.25rem; margin-bottom: 0.25rem; }
|
|
||||||
.quote-step .step-sub { color: #64748B; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
|
||||||
.pill-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 1.25rem; }
|
|
||||||
.pill-grid label { cursor: pointer; }
|
|
||||||
.pill-grid input[type="radio"],
|
|
||||||
.pill-grid input[type="checkbox"] { display: none; }
|
|
||||||
.pill-grid .pill {
|
|
||||||
display: inline-block; padding: 8px 16px; border-radius: 6px;
|
|
||||||
border: 1px solid #CBD5E1; font-size: 0.8125rem; font-weight: 500;
|
|
||||||
color: #64748B; transition: all 0.15s; background: transparent;
|
|
||||||
}
|
}
|
||||||
.pill-grid input:checked + .pill {
|
.q-step-title { font-size: 1.25rem; font-weight: 800; color: #0F172A; margin: 0 0 4px; }
|
||||||
background: #3B82F6; border-color: #3B82F6; color: #fff;
|
.q-step-sub { font-size: 13px; color: #64748B; margin: 0 0 1.5rem; }
|
||||||
}
|
.q-field-group { margin-bottom: 1.25rem; }
|
||||||
.field-label {
|
.q-label {
|
||||||
display: block; font-size: 0.8125rem; font-weight: 600;
|
display: block; font-size: 12px; font-weight: 600;
|
||||||
color: #334155; margin-bottom: 4px;
|
color: #334155; margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.field-label .required { color: #EF4444; }
|
.q-label .required { color: #EF4444; }
|
||||||
.quote-nav { display: flex; justify-content: space-between; margin-top: 2rem; }
|
.q-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1rem; }
|
||||||
.quote-nav .btn-back {
|
.q-pills label { cursor: pointer; }
|
||||||
background: transparent; border: 1px solid #CBD5E1; color: #64748B;
|
.q-pills input[type="radio"],
|
||||||
padding: 8px 20px; border-radius: 6px; cursor: pointer; font-weight: 600;
|
.q-pills input[type="checkbox"] { display: none; }
|
||||||
|
.q-pill {
|
||||||
|
display: inline-block; padding: 7px 14px; border-radius: 999px;
|
||||||
|
border: 1px solid #E2E8F0; font-size: 12px; font-weight: 600;
|
||||||
|
color: #94A3B8; transition: all 0.15s; background: transparent;
|
||||||
}
|
}
|
||||||
.btn-gradient {
|
.q-pill:hover { background: #F1F5F9; color: #64748B; }
|
||||||
background: linear-gradient(135deg, #3B82F6, #2563EB);
|
input:checked + .q-pill { background: #1D4ED8; border-color: #1D4ED8; color: white; }
|
||||||
color: white; border: none; border-radius: 12px;
|
.q-input {
|
||||||
padding: 14px 28px; font-size: 15px; font-weight: 700;
|
width: 100%; background: #F1F5F9; border: 1px solid #CBD5E1;
|
||||||
box-shadow: 0 4px 16px rgba(59,130,246,0.25);
|
border-radius: 10px; padding: 8px 12px; font-size: 13px;
|
||||||
cursor: pointer; transition: transform 0.1s, box-shadow 0.1s;
|
font-family: 'Inter', sans-serif; color: #0F172A; outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.btn-gradient:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(59,130,246,0.35); }
|
.q-input:focus { border-color: rgba(29,78,216,0.5); }
|
||||||
.summary-card {
|
.q-input--error { border-color: #EF4444; }
|
||||||
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px;
|
.q-error-hint { font-size: 11px; color: #EF4444; margin-top: 4px; }
|
||||||
padding: 1rem; margin-bottom: 1.5rem; font-size: 0.8125rem;
|
textarea.q-input { resize: vertical; }
|
||||||
|
select.q-input { appearance: auto; }
|
||||||
|
.q-nav { display: flex; justify-content: space-between; margin-top: 1.5rem; }
|
||||||
|
.q-btn-back {
|
||||||
|
padding: 8px 20px; font-size: 12px; font-weight: 600;
|
||||||
|
border: 1px solid #CBD5E1; background: transparent; color: #64748B;
|
||||||
|
border-radius: 10px; cursor: pointer; font-family: 'Inter', sans-serif;
|
||||||
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.summary-card h4 { font-size: 0.8125rem; font-weight: 700; color: #334155; margin-bottom: 0.5rem; }
|
.q-btn-back:hover { background: #F1F5F9; color: #475569; }
|
||||||
.summary-card dt { color: #94A3B8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
.q-btn-next, .q-btn-submit {
|
||||||
.summary-card dd { color: #334155; font-weight: 500; margin: 0 0 0.5rem; }
|
padding: 10px 24px; font-size: 13px; font-weight: 700;
|
||||||
.privacy-box {
|
border: none; background: #1D4ED8; color: white; border-radius: 10px;
|
||||||
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 10px;
|
cursor: pointer; font-family: 'Inter', sans-serif;
|
||||||
padding: 14px 16px; margin-bottom: 1.5rem; font-size: 0.8125rem;
|
box-shadow: 0 2px 10px rgba(29,78,216,0.25); transition: all 0.15s;
|
||||||
color: #1E40AF; display: flex; gap: 10px; align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
.privacy-box svg { flex-shrink: 0; margin-top: 2px; }
|
.q-btn-next:hover, .q-btn-submit:hover { background: #1E40AF; }
|
||||||
.consent-group { margin-bottom: 1.5rem; }
|
.q-prefill-card {
|
||||||
.consent-group label {
|
background: #EFF6FF; border: 1.5px solid #BFDBFE;
|
||||||
|
border-radius: 14px; padding: 1rem 1.25rem; margin-bottom: 1.5rem;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.q-prefill-card dt { color: #94A3B8; font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.q-prefill-card dd { color: #0F172A; font-weight: 600; margin: 0 0 6px; }
|
||||||
|
.q-privacy-box {
|
||||||
|
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 14px;
|
||||||
|
padding: 12px 14px; margin-bottom: 1rem; font-size: 12px;
|
||||||
|
color: #1E40AF; display: flex; gap: 8px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.q-privacy-box svg { flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.q-consent { margin-bottom: 1.25rem; }
|
||||||
|
.q-consent label {
|
||||||
display: flex; gap: 8px; align-items: flex-start;
|
display: flex; gap: 8px; align-items: flex-start;
|
||||||
font-size: 0.8125rem; color: #475569; cursor: pointer;
|
font-size: 12px; color: #475569; cursor: pointer;
|
||||||
|
}
|
||||||
|
.q-consent input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
|
||||||
|
.q-consent a { color: #1D4ED8; }
|
||||||
|
.q-checkbox-label {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: #475569; cursor: pointer;
|
||||||
|
}
|
||||||
|
.q-checkbox-label input[type="checkbox"] { margin: 0; flex-shrink: 0; }
|
||||||
|
.q-card {
|
||||||
|
background: white; border-radius: 16px; padding: 36px 32px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.q-card { padding: 24px 16px; }
|
||||||
}
|
}
|
||||||
.consent-group input[type="checkbox"] { margin-top: 3px; flex-shrink: 0; }
|
|
||||||
.field-group { margin-bottom: 1.25rem; }
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
||||||
<div class="container-page py-12">
|
<div class="container-page py-12">
|
||||||
<div class="quote-flow">
|
<div class="q-wizard">
|
||||||
<!-- Progress bar with labels -->
|
<div class="q-progress" id="q-progress">
|
||||||
<div class="quote-progress">
|
<div class="q-progress__meta">
|
||||||
<div class="quote-progress__step active" data-step="1"></div>
|
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||||
<div class="quote-progress__step" data-step="2"></div>
|
<span class="q-progress__count">{{ step }} of {{ steps | length }}</span>
|
||||||
<div class="quote-progress__step" data-step="3"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="quote-progress__labels">
|
<div class="q-progress__track">
|
||||||
<span class="active" data-label="1">Project</span>
|
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||||
<span data-label="2">Details</span>
|
|
||||||
<span data-label="3">Contact</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Elevated card -->
|
|
||||||
<div style="background: white; border-radius: 16px; padding: 36px 32px; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
|
||||||
<form method="post" id="quoteForm">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
|
|
||||||
<!-- STEP 1: Tell us about your project -->
|
|
||||||
<div class="quote-step active" data-step="1">
|
|
||||||
<h2>Tell us about your project</h2>
|
|
||||||
<p class="step-sub">This helps us match you with the right suppliers. Share what you know.</p>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Facility Type <span class="required">*</span></span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('both', 'Indoor + Outdoor')] %}
|
|
||||||
<label><input type="radio" name="facility_type" value="{{ val }}" {{ 'checked' if prefill.facility_type == val }} required><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="q-card">
|
||||||
<div class="field-group">
|
<div id="quote-step">
|
||||||
<label class="field-label" for="court_count">Number of Courts <span class="required">*</span></label>
|
{% include "partials/quote_step_" ~ step ~ ".html" %}
|
||||||
<input type="number" id="court_count" name="court_count" class="form-input" min="1" max="50" value="{{ prefill.court_count or 6 }}" required>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Glass Type</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('standard', 'Standard Glass'), ('panoramic', 'Panoramic Glass'), ('no_preference', 'No Preference')] %}
|
|
||||||
<label><input type="radio" name="glass_type" value="{{ val }}" {{ 'checked' if prefill.glass_type == val }}><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Lighting</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('led_standard', 'LED Standard'), ('led_competition', 'LED Competition'), ('natural', 'Natural Light'), ('not_sure', 'Not Sure')] %}
|
|
||||||
<label><input type="radio" name="lighting_type" value="{{ val }}" {{ 'checked' if prefill.lighting_type == val }}><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Build Context</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('new_standalone', 'New Standalone Venue'), ('adding_to_club', 'Adding to Existing Club'), ('converting_building', 'Converting a Building'), ('venue_search', 'Need Help Finding a Venue / Land')] %}
|
|
||||||
<label><input type="radio" name="build_context" value="{{ val }}"><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="quote-nav">
|
|
||||||
<div></div>
|
|
||||||
<button type="button" class="btn-gradient" onclick="goStep(2)">Continue →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- STEP 2: Project Details -->
|
|
||||||
<div class="quote-step" data-step="2">
|
|
||||||
<h2>Project details</h2>
|
|
||||||
<p class="step-sub">Help suppliers understand your timeline and scope.</p>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="city">City / Region</label>
|
|
||||||
<input type="text" id="city" name="city" class="form-input" placeholder="e.g. Munich, Bavaria">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="country">Country <span class="required">*</span></label>
|
|
||||||
<select id="country" name="country" class="form-input" required>
|
|
||||||
<option value="">Select country...</option>
|
|
||||||
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('UK', 'United Kingdom'), ('PT', 'Portugal'), ('BE', 'Belgium'), ('AT', 'Austria'), ('CH', 'Switzerland'), ('DK', 'Denmark'), ('FI', 'Finland'), ('NO', 'Norway'), ('PL', 'Poland'), ('CZ', 'Czech Republic'), ('AE', 'UAE'), ('SA', 'Saudi Arabia'), ('US', 'United States'), ('OTHER', 'Other')] %}
|
|
||||||
<option value="{{ code }}" {{ 'selected' if prefill.country == code }}>{{ name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Project Phase</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('still_searching', 'Still searching for a location'), ('location_found', 'Location identified / shortlisted'), ('converting_existing', 'Converting existing facility'), ('lease_signed', 'Lease or purchase signed'), ('permit_not_filed', 'Building permit not yet filed'), ('permit_pending', 'Building permit in progress'), ('permit_granted', 'Building permit approved')] %}
|
|
||||||
<label><input type="radio" name="location_status" value="{{ val }}"><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Timeline <span class="required">*</span></span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('asap', 'ASAP'), ('3-6mo', '3-6 Months'), ('6-12mo', '6-12 Months'), ('12+mo', '12+ Months')] %}
|
|
||||||
<label><input type="radio" name="timeline" value="{{ val }}" {{ 'required' if loop.first }}><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="budget_estimate">Budget Estimate (€)</label>
|
|
||||||
<input type="number" id="budget_estimate" name="budget_estimate" class="form-input" placeholder="e.g. 500000" value="{{ prefill.budget or '' }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Financing Status</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('self_funded', 'Self-Funded'), ('loan_approved', 'Loan Approved'), ('seeking', 'Seeking Financing'), ('not_started', 'Not Started')] %}
|
|
||||||
<label><input type="radio" name="financing_status" value="{{ val }}"><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label">
|
|
||||||
<input type="checkbox" name="wants_financing_help" value="1" style="margin-right: 6px;">
|
|
||||||
I'd like help finding financing options
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Decision Process</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('solo', 'Solo Decision'), ('partners', 'With Partners'), ('committee', 'Committee / Board')] %}
|
|
||||||
<label><input type="radio" name="decision_process" value="{{ val }}"><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">You are... <span class="required">*</span></span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('entrepreneur', 'Entrepreneur / Investor'), ('tennis_club', 'Tennis / Sports Club'), ('municipality', 'Municipality / Public Body'), ('developer', 'Real Estate Developer'), ('operator', 'Existing Padel Operator'), ('architect', 'Architect / Engineer')] %}
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="{{ val }}" {{ 'required' if loop.first }}><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Services Needed (select all that apply)</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
{% for val, label in [('court_supply', 'Court Supply'), ('installation', 'Installation'), ('construction', 'Hall Construction'), ('design', 'Facility Design'), ('lighting', 'Lighting'), ('flooring', 'Flooring'), ('turnkey', 'Full Turnkey')] %}
|
|
||||||
<label><input type="checkbox" name="services_needed" value="{{ val }}"><span class="pill">{{ label }}</span></label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="additional_info">Anything else?</label>
|
|
||||||
<textarea id="additional_info" name="additional_info" class="form-input" rows="3" placeholder="Any specific requirements, questions, or context..."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="quote-nav">
|
|
||||||
<button type="button" class="btn-back" onclick="goStep(1)">← Back</button>
|
|
||||||
<button type="button" class="btn-gradient" onclick="goStep(3)">Continue →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- STEP 3: Contact -->
|
|
||||||
<div class="quote-step" data-step="3">
|
|
||||||
<h2>How should suppliers reach you?</h2>
|
|
||||||
<p class="step-sub">Matched suppliers will contact you directly with tailored proposals.</p>
|
|
||||||
|
|
||||||
<div class="privacy-box">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5 7V5a3.5 3.5 0 10-7 0v2M4 7h8a1 1 0 011 1v5a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1z" stroke="#3B82F6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
<span>Your contact details are shared only with 2-5 pre-vetted suppliers that match your project specs.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="contact_name">Full Name <span class="required">*</span></label>
|
|
||||||
<input type="text" id="contact_name" name="contact_name" class="form-input" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="contact_email">Email <span class="required">*</span></label>
|
|
||||||
<input type="email" id="contact_email" name="contact_email" class="form-input" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="contact_phone">Phone (optional)</label>
|
|
||||||
<input type="tel" id="contact_phone" name="contact_phone" class="form-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="contact_company">Company (optional)</label>
|
|
||||||
<input type="text" id="contact_company" name="contact_company" class="form-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="quoteSummary" class="summary-card"></div>
|
|
||||||
|
|
||||||
<div class="consent-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="consent" value="1" required>
|
|
||||||
<span>I agree that my project details and contact information may be shared with verified padel court suppliers matched to my project. <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> · <a href="{{ url_for('public.terms') }}">Terms</a></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="quote-nav">
|
|
||||||
<button type="button" class="btn-back" onclick="goStep(2)">← Back</button>
|
|
||||||
<button type="submit" class="btn-gradient">Submit & Get Quotes →</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="text-align: center; font-size: 0.75rem; color: #94A3B8; margin-top: 1rem;">No obligation.</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
function goStep(n) {
|
|
||||||
document.querySelectorAll('.quote-step').forEach(s => s.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.quote-progress__step').forEach(s => s.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.quote-progress__labels span').forEach(s => s.classList.remove('active'));
|
|
||||||
document.querySelector(`.quote-step[data-step="${n}"]`).classList.add('active');
|
|
||||||
for (let i = 1; i <= n; i++) {
|
|
||||||
document.querySelector(`.quote-progress__step[data-step="${i}"]`).classList.add('active');
|
|
||||||
document.querySelector(`.quote-progress__labels span[data-label="${i}"]`).classList.add('active');
|
|
||||||
}
|
|
||||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
|
||||||
|
|
||||||
if (n === 3) buildSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSummary() {
|
|
||||||
const f = document.getElementById('quoteForm');
|
|
||||||
const val = name => {
|
|
||||||
const checked = f.querySelector(`[name="${name}"]:checked`);
|
|
||||||
if (checked) return checked.value;
|
|
||||||
const el = f.querySelector(`[name="${name}"]`);
|
|
||||||
return el ? el.value : '';
|
|
||||||
};
|
|
||||||
const labels = {
|
|
||||||
facility_type: 'Facility', court_count: 'Courts', glass_type: 'Glass',
|
|
||||||
lighting_type: 'Lighting', build_context: 'Build Context', city: 'Location',
|
|
||||||
country: 'Country', timeline: 'Timeline', budget_estimate: 'Budget',
|
|
||||||
location_status: 'Project Phase', financing_status: 'Financing',
|
|
||||||
decision_process: 'Decision Process', stakeholder_type: 'You are',
|
|
||||||
};
|
|
||||||
let html = '<h4>Your project brief</h4><dl style="display:grid;grid-template-columns:1fr 1fr;gap:4px 1rem">';
|
|
||||||
for (const [k, label] of Object.entries(labels)) {
|
|
||||||
const v = val(k);
|
|
||||||
if (v) html += `<dt>${label}</dt><dd>${v.replace(/_/g, ' ')}</dd>`;
|
|
||||||
}
|
|
||||||
html += '</dl>';
|
|
||||||
document.getElementById('quoteSummary').innerHTML = html;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -155,7 +155,11 @@ CREATE TABLE IF NOT EXISTS lead_requests (
|
|||||||
contact_company TEXT,
|
contact_company TEXT,
|
||||||
stakeholder_type TEXT,
|
stakeholder_type TEXT,
|
||||||
heat_score TEXT DEFAULT 'cool',
|
heat_score TEXT DEFAULT 'cool',
|
||||||
verified_at TEXT
|
verified_at TEXT,
|
||||||
|
|
||||||
|
-- Phase 1: credit cost and unlock tracking
|
||||||
|
credit_cost INTEGER,
|
||||||
|
unlock_count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_leads_status ON lead_requests(status);
|
CREATE INDEX IF NOT EXISTS idx_leads_status ON lead_requests(status);
|
||||||
@@ -181,7 +185,25 @@ CREATE TABLE IF NOT EXISTS suppliers (
|
|||||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
highlight INTEGER NOT NULL DEFAULT 0,
|
highlight INTEGER NOT NULL DEFAULT 0,
|
||||||
sticky_until TEXT,
|
sticky_until TEXT,
|
||||||
sticky_country TEXT
|
sticky_country TEXT,
|
||||||
|
|
||||||
|
-- Phase 1: expanded supplier profile and credits
|
||||||
|
service_categories TEXT,
|
||||||
|
service_area TEXT,
|
||||||
|
years_in_business INTEGER,
|
||||||
|
project_count INTEGER,
|
||||||
|
short_description TEXT,
|
||||||
|
long_description TEXT,
|
||||||
|
contact_name TEXT,
|
||||||
|
contact_email TEXT,
|
||||||
|
contact_phone TEXT,
|
||||||
|
credit_balance INTEGER NOT NULL DEFAULT 0,
|
||||||
|
monthly_credits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_credit_refill TEXT,
|
||||||
|
|
||||||
|
-- Phase 2: editable profile fields
|
||||||
|
logo_file TEXT,
|
||||||
|
tagline TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_suppliers_country ON suppliers(country_code);
|
CREATE INDEX IF NOT EXISTS idx_suppliers_country ON suppliers(country_code);
|
||||||
@@ -211,3 +233,85 @@ CREATE TRIGGER IF NOT EXISTS suppliers_au AFTER UPDATE ON suppliers BEGIN
|
|||||||
INSERT INTO suppliers_fts(rowid, name, description, city, country_code, category)
|
INSERT INTO suppliers_fts(rowid, name, description, city, country_code, category)
|
||||||
VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category);
|
VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category);
|
||||||
END;
|
END;
|
||||||
|
|
||||||
|
-- Credit ledger (source of truth for all credit movements)
|
||||||
|
CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||||
|
delta INTEGER NOT NULL,
|
||||||
|
balance_after INTEGER NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
reference_id INTEGER,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credit_ledger_supplier ON credit_ledger(supplier_id);
|
||||||
|
|
||||||
|
-- Lead forwards (which supplier unlocked which lead)
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_forwards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES lead_requests(id),
|
||||||
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||||
|
credit_cost INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'sent',
|
||||||
|
email_sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(lead_id, supplier_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_forwards_lead ON lead_forwards(lead_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_forwards_supplier ON lead_forwards(supplier_id);
|
||||||
|
|
||||||
|
-- Supplier boost subscriptions/purchases
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_boosts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||||
|
boost_type TEXT NOT NULL,
|
||||||
|
paddle_subscription_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
starts_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_boosts_supplier ON supplier_boosts(supplier_id);
|
||||||
|
|
||||||
|
-- Paddle products (price IDs stored in DB, not env vars)
|
||||||
|
CREATE TABLE IF NOT EXISTS paddle_products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
paddle_product_id TEXT NOT NULL,
|
||||||
|
paddle_price_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price_cents INTEGER NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
billing_type TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Business plan PDF exports
|
||||||
|
CREATE TABLE IF NOT EXISTS business_plan_exports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
scenario_id INTEGER NOT NULL REFERENCES scenarios(id),
|
||||||
|
paddle_transaction_id TEXT,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
file_path TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bpe_user ON business_plan_exports(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bpe_scenario ON business_plan_exports(scenario_id);
|
||||||
|
|
||||||
|
-- In-app feedback
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
page_url TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Phase 1: credit ledger, lead forwarding, supplier boosts, expanded supplier/lead columns."""
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn):
|
||||||
|
# -- New tables --
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||||
|
delta INTEGER NOT NULL,
|
||||||
|
balance_after INTEGER NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
reference_id INTEGER,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_credit_ledger_supplier ON credit_ledger(supplier_id)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_forwards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES lead_requests(id),
|
||||||
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||||
|
credit_cost INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'sent',
|
||||||
|
email_sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(lead_id, supplier_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_lead_forwards_lead ON lead_forwards(lead_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_lead_forwards_supplier ON lead_forwards(supplier_id)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_boosts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||||
|
boost_type TEXT NOT NULL,
|
||||||
|
paddle_subscription_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
starts_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_supplier_boosts_supplier ON supplier_boosts(supplier_id)")
|
||||||
|
|
||||||
|
# -- Column additions to suppliers --
|
||||||
|
sup_cols = {r[1] for r in conn.execute("PRAGMA table_info(suppliers)").fetchall()}
|
||||||
|
new_sup_cols = {
|
||||||
|
"service_categories": "TEXT",
|
||||||
|
"service_area": "TEXT",
|
||||||
|
"years_in_business": "INTEGER",
|
||||||
|
"project_count": "INTEGER",
|
||||||
|
"short_description": "TEXT",
|
||||||
|
"long_description": "TEXT",
|
||||||
|
"contact_name": "TEXT",
|
||||||
|
"contact_email": "TEXT",
|
||||||
|
"contact_phone": "TEXT",
|
||||||
|
"credit_balance": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"monthly_credits": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"last_credit_refill": "TEXT",
|
||||||
|
}
|
||||||
|
for col, typedef in new_sup_cols.items():
|
||||||
|
if col not in sup_cols:
|
||||||
|
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {col} {typedef}")
|
||||||
|
|
||||||
|
# -- Column additions to lead_requests --
|
||||||
|
lead_cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)").fetchall()}
|
||||||
|
if "credit_cost" not in lead_cols:
|
||||||
|
conn.execute("ALTER TABLE lead_requests ADD COLUMN credit_cost INTEGER")
|
||||||
|
if "unlock_count" not in lead_cols:
|
||||||
|
conn.execute("ALTER TABLE lead_requests ADD COLUMN unlock_count INTEGER NOT NULL DEFAULT 0")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Phase 2: paddle_products, business_plan_exports, feedback tables; supplier profile columns."""
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn):
|
||||||
|
# -- paddle_products: store Paddle product/price IDs in DB --
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS paddle_products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
paddle_product_id TEXT NOT NULL,
|
||||||
|
paddle_price_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price_cents INTEGER NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
billing_type TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# -- business_plan_exports: track PDF purchases and generation --
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS business_plan_exports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
scenario_id INTEGER NOT NULL REFERENCES scenarios(id),
|
||||||
|
paddle_transaction_id TEXT,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
file_path TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
completed_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_bpe_user ON business_plan_exports(user_id)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_bpe_scenario ON business_plan_exports(scenario_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- feedback: in-app feedback submissions --
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
page_url TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# -- Column additions to suppliers --
|
||||||
|
sup_cols = {r[1] for r in conn.execute("PRAGMA table_info(suppliers)").fetchall()}
|
||||||
|
new_sup_cols = {
|
||||||
|
"logo_file": "TEXT",
|
||||||
|
"tagline": "TEXT",
|
||||||
|
}
|
||||||
|
for col, typedef in new_sup_cols.items():
|
||||||
|
if col not in sup_cols:
|
||||||
|
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {col} {typedef}")
|
||||||
@@ -5,10 +5,10 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify, render_template, request
|
from quart import Blueprint, Response, g, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..auth.routes import login_required
|
from ..auth.routes import login_required
|
||||||
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, get_paddle_price
|
||||||
from .calculator import calc, validate_state
|
from .calculator import calc, validate_state
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -94,6 +94,8 @@ async def save_scenario():
|
|||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0
|
||||||
|
|
||||||
if scenario_id:
|
if scenario_id:
|
||||||
# Update existing
|
# Update existing
|
||||||
await execute(
|
await execute(
|
||||||
@@ -107,6 +109,20 @@ async def save_scenario():
|
|||||||
(g.user["id"], name, state_json, location, now, now),
|
(g.user["id"], name, state_json, location, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add to Resend nurture audience on first scenario save
|
||||||
|
if is_first_save:
|
||||||
|
from ..core import config as _config
|
||||||
|
if _config.RESEND_AUDIENCE_PLANNER and _config.RESEND_API_KEY:
|
||||||
|
try:
|
||||||
|
import resend
|
||||||
|
resend.api_key = _config.RESEND_API_KEY
|
||||||
|
resend.Contacts.create({
|
||||||
|
"audience_id": _config.RESEND_AUDIENCE_PLANNER,
|
||||||
|
"email": g.user["email"],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
|
||||||
|
|
||||||
count = await count_scenarios(g.user["id"])
|
count = await count_scenarios(g.user["id"])
|
||||||
return jsonify({"ok": True, "id": scenario_id, "count": count})
|
return jsonify({"ok": True, "id": scenario_id, "count": count})
|
||||||
|
|
||||||
@@ -151,3 +167,107 @@ async def set_default(scenario_id: int):
|
|||||||
(scenario_id, g.user["id"]),
|
(scenario_id, g.user["id"]),
|
||||||
)
|
)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Business Plan PDF Export
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/export")
|
||||||
|
@login_required
|
||||||
|
async def export():
|
||||||
|
"""Export options page — language, scenario picker, pricing."""
|
||||||
|
scenarios = await get_scenarios(g.user["id"])
|
||||||
|
|
||||||
|
# Check for existing purchases
|
||||||
|
exports = await fetch_all(
|
||||||
|
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY created_at DESC",
|
||||||
|
(g.user["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"export.html",
|
||||||
|
scenarios=scenarios,
|
||||||
|
exports=exports,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/export/checkout", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@csrf_protect
|
||||||
|
async def export_checkout():
|
||||||
|
"""Return JSON for Paddle.js overlay checkout for business plan PDF."""
|
||||||
|
form = await request.form
|
||||||
|
scenario_id = form.get("scenario_id")
|
||||||
|
language = form.get("language", "en")
|
||||||
|
|
||||||
|
if not scenario_id:
|
||||||
|
return jsonify({"error": "Select a scenario."}), 400
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
scenario = await fetch_one(
|
||||||
|
"SELECT id FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||||
|
(int(scenario_id), g.user["id"]),
|
||||||
|
)
|
||||||
|
if not scenario:
|
||||||
|
return jsonify({"error": "Scenario not found."}), 404
|
||||||
|
|
||||||
|
price_id = await get_paddle_price("business_plan")
|
||||||
|
if not price_id:
|
||||||
|
return jsonify({"error": "Product not configured. Contact support."}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"items": [{"priceId": price_id, "quantity": 1}],
|
||||||
|
"customData": {
|
||||||
|
"user_id": str(g.user["id"]),
|
||||||
|
"scenario_id": str(scenario_id),
|
||||||
|
"language": language,
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"successUrl": f"{config.BASE_URL}/planner/export/success",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/export/success")
|
||||||
|
@login_required
|
||||||
|
async def export_success():
|
||||||
|
"""Post-checkout landing — shows download link when ready."""
|
||||||
|
exports = await fetch_all(
|
||||||
|
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||||
|
(g.user["id"],),
|
||||||
|
)
|
||||||
|
return await render_template("export_success.html", exports=exports)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/export/<int:export_id>")
|
||||||
|
@login_required
|
||||||
|
async def export_download(export_id: int):
|
||||||
|
"""Download a generated PDF."""
|
||||||
|
export = await fetch_one(
|
||||||
|
"SELECT * FROM business_plan_exports WHERE id = ? AND user_id = ?",
|
||||||
|
(export_id, g.user["id"]),
|
||||||
|
)
|
||||||
|
if not export:
|
||||||
|
return jsonify({"error": "Export not found."}), 404
|
||||||
|
|
||||||
|
if export["status"] == "pending" or export["status"] == "generating":
|
||||||
|
return await render_template("export_generating.html", export=export)
|
||||||
|
|
||||||
|
if export["status"] == "failed":
|
||||||
|
return jsonify({"error": "PDF generation failed. Please contact support."}), 500
|
||||||
|
|
||||||
|
# Serve the PDF file
|
||||||
|
from pathlib import Path
|
||||||
|
file_path = Path(export["file_path"])
|
||||||
|
if not file_path.exists():
|
||||||
|
return jsonify({"error": "PDF file not found."}), 404
|
||||||
|
|
||||||
|
pdf_bytes = file_path.read_bytes()
|
||||||
|
return Response(
|
||||||
|
pdf_bytes,
|
||||||
|
mimetype="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="padel-business-plan-{export_id}.pdf"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
146
padelnomics/src/padelnomics/planner/templates/export.html
Normal file
146
padelnomics/src/padelnomics/planner/templates/export.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Export Business Plan - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
||||||
|
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
||||||
|
.exp-hero h1 { font-size: 1.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.exp-hero p { color: #64748B; }
|
||||||
|
.exp-price { font-size: 2rem; font-weight: 800; color: #1E293B; text-align: center; margin: 1rem 0; }
|
||||||
|
.exp-price span { font-size: 0.875rem; font-weight: 400; color: #64748B; }
|
||||||
|
.exp-features {
|
||||||
|
list-style: none; padding: 0; margin: 1.5rem 0; display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr; gap: 8px;
|
||||||
|
}
|
||||||
|
.exp-features li {
|
||||||
|
font-size: 0.8125rem; color: #475569; display: flex; align-items: flex-start; gap: 6px;
|
||||||
|
}
|
||||||
|
.exp-features li::before { content: "✓"; color: #16A34A; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.exp-form { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin: 1.5rem 0; }
|
||||||
|
.exp-form label { display: block; font-size: 0.8125rem; font-weight: 600; color: #475569; margin-bottom: 4px; }
|
||||||
|
.exp-form select, .exp-form input { width: 100%; margin-bottom: 1rem; }
|
||||||
|
.exp-existing { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin-top: 2rem; }
|
||||||
|
.exp-existing h3 { font-size: 0.9375rem; margin-bottom: 0.75rem; }
|
||||||
|
.exp-dl-link {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 8px 0; border-bottom: 1px solid #F1F5F9; font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.exp-dl-link:last-child { border-bottom: none; }
|
||||||
|
.exp-status { font-size: 0.6875rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; }
|
||||||
|
.exp-status--ready { background: #DCFCE7; color: #16A34A; }
|
||||||
|
.exp-status--pending { background: #FEF3C7; color: #D97706; }
|
||||||
|
.exp-status--failed { background: #FEE2E2; color: #DC2626; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page">
|
||||||
|
<div class="exp-wrap">
|
||||||
|
<div class="exp-hero">
|
||||||
|
<h1>Export Business Plan (PDF)</h1>
|
||||||
|
<p>Bank-ready financial projections from your planner scenario.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="exp-price">€99 <span>one-time</span></div>
|
||||||
|
|
||||||
|
<ul class="exp-features">
|
||||||
|
<li>Executive summary</li>
|
||||||
|
<li>CAPEX breakdown</li>
|
||||||
|
<li>5-year P&L projection</li>
|
||||||
|
<li>12-month cash flow</li>
|
||||||
|
<li>Financing structure</li>
|
||||||
|
<li>Key metrics (IRR, MOIC, DSCR)</li>
|
||||||
|
<li>Sensitivity analysis</li>
|
||||||
|
<li>English or German</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="exp-form">
|
||||||
|
<form id="export-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label>Scenario</label>
|
||||||
|
<select name="scenario_id" class="form-input" required>
|
||||||
|
<option value="">Select a scenario...</option>
|
||||||
|
{% for s in scenarios %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }}{% if s.location %} ({{ s.location }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>Language</label>
|
||||||
|
<select name="language" class="form-input">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width:100%;margin-top:0.5rem" id="export-buy-btn">
|
||||||
|
Purchase & Generate PDF — €99
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if exports %}
|
||||||
|
<div class="exp-existing">
|
||||||
|
<h3>Your Exports</h3>
|
||||||
|
{% for e in exports %}
|
||||||
|
<div class="exp-dl-link">
|
||||||
|
<div>
|
||||||
|
<span>Export #{{ e.id }}</span>
|
||||||
|
<span style="color:#94A3B8;font-size:0.75rem;margin-left:8px">{{ e.created_at[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if e.status == 'ready' %}
|
||||||
|
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn-outline" style="font-size:0.75rem;padding:4px 12px">Download PDF</a>
|
||||||
|
{% elif e.status == 'pending' or e.status == 'generating' %}
|
||||||
|
<span class="exp-status exp-status--pending">Generating...</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="exp-status exp-status--failed">Failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="text-align:center;margin-top:1.5rem">
|
||||||
|
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">← Back to Planner</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('export-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('export-buy-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Processing...';
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("{{ url_for('planner.export_checkout') }}", {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
alert(data.error);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Purchase & Generate PDF — €99';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Paddle.Checkout.open({
|
||||||
|
items: data.items,
|
||||||
|
customData: data.customData,
|
||||||
|
settings: data.settings,
|
||||||
|
});
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Purchase & Generate PDF — €99';
|
||||||
|
} catch (err) {
|
||||||
|
alert('Something went wrong. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Purchase & Generate PDF — €99';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Generating PDF - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:1rem;animation:spin 1s linear infinite;display:inline-block">◠</div>
|
||||||
|
<h1 style="font-size:1.5rem;margin-bottom:0.5rem">Generating Your Business Plan</h1>
|
||||||
|
<p style="color:#64748B;margin-bottom:2rem">This usually takes less than a minute. This page will auto-refresh.</p>
|
||||||
|
|
||||||
|
<button onclick="window.location.reload()" class="btn-outline">Refresh Now</button>
|
||||||
|
|
||||||
|
<p style="margin-top:2rem">
|
||||||
|
<a href="{{ url_for('planner.export') }}" style="color:#1D4ED8;font-size:0.875rem">View All Exports</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>setTimeout(function(){ window.location.reload(); }, 5000);</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Export Ready - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
||||||
|
<div style="font-size:3rem;margin-bottom:1rem">✓</div>
|
||||||
|
<h1 style="font-size:1.5rem;margin-bottom:0.5rem">Payment Received</h1>
|
||||||
|
<p style="color:#64748B;margin-bottom:2rem">Your business plan PDF is being generated. This usually takes less than a minute.</p>
|
||||||
|
|
||||||
|
{% if exports %}
|
||||||
|
{% for e in exports %}
|
||||||
|
{% if e.status == 'ready' %}
|
||||||
|
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn" style="display:inline-block;padding:12px 32px">Download PDF</a>
|
||||||
|
{% else %}
|
||||||
|
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:10px;padding:1rem;margin-bottom:1rem;font-size:0.875rem;color:#92400E">
|
||||||
|
Your PDF is being generated. Refresh this page in a moment, or check your email — we'll send you a download link when it's ready.
|
||||||
|
</div>
|
||||||
|
<button onclick="window.location.reload()" class="btn-outline">Refresh Status</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin-top:2rem">
|
||||||
|
<a href="{{ url_for('planner.export') }}" style="color:#1D4ED8;font-size:0.875rem">View All Exports</a>
|
||||||
|
·
|
||||||
|
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">Back to Planner</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
<div class="planner-app">
|
<div class="planner-app">
|
||||||
<header class="planner-header">
|
<header class="planner-header">
|
||||||
<h1>Padel Court Financial Planner</h1>
|
<h1>Padel Court Financial Planner</h1>
|
||||||
<span class="brand-badge">v2.1</span>
|
|
||||||
<span id="headerTag" class="planner-summary"></span>
|
<span id="headerTag" class="planner-summary"></span>
|
||||||
|
|
||||||
{% if user %}
|
{% if user %}
|
||||||
@@ -105,165 +104,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 5: Get Quotes -->
|
<!-- Preview bar + navigation (sticky) -->
|
||||||
<div class="wizard-step" data-wiz="5">
|
<div class="wizard-footer">
|
||||||
<h2 class="wizard-step__title">Get Quotes from Suppliers</h2>
|
|
||||||
<p class="wizard-step__sub">Your project specs are pre-filled from the planner. Complete a few details and we'll match you with verified court builders.</p>
|
|
||||||
|
|
||||||
<div class="wiz-autofill-summary" id="wizAutoSummary"></div>
|
|
||||||
|
|
||||||
<form id="wizQuoteForm" onsubmit="return false">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="facility_type" id="wiz_facility_type">
|
|
||||||
<input type="hidden" name="court_count" id="wiz_court_count">
|
|
||||||
<input type="hidden" name="glass_type" id="wiz_glass_type">
|
|
||||||
<input type="hidden" name="lighting_type" id="wiz_lighting_type">
|
|
||||||
<input type="hidden" name="country" id="wiz_country">
|
|
||||||
<input type="hidden" name="budget_estimate" id="wiz_budget">
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="wiz_city">City / Region</label>
|
|
||||||
<input type="text" id="wiz_city" name="city" class="wiz-input" placeholder="e.g. Munich, Bavaria">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Build Context</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="radio" name="build_context" value="new_standalone"><span class="pill">New Standalone Venue</span></label>
|
|
||||||
<label><input type="radio" name="build_context" value="adding_to_club"><span class="pill">Adding to Existing Club</span></label>
|
|
||||||
<label><input type="radio" name="build_context" value="converting_building"><span class="pill">Converting a Building</span></label>
|
|
||||||
<label><input type="radio" name="build_context" value="venue_search"><span class="pill">Need Help Finding Venue</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Project Phase</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="radio" name="location_status" value="still_searching"><span class="pill">Still searching</span></label>
|
|
||||||
<label><input type="radio" name="location_status" value="location_found"><span class="pill">Location identified</span></label>
|
|
||||||
<label><input type="radio" name="location_status" value="converting_existing"><span class="pill">Converting facility</span></label>
|
|
||||||
<label><input type="radio" name="location_status" value="lease_signed"><span class="pill">Lease / purchase signed</span></label>
|
|
||||||
<label><input type="radio" name="location_status" value="permit_not_filed"><span class="pill">Permit not filed</span></label>
|
|
||||||
<label><input type="radio" name="location_status" value="permit_pending"><span class="pill">Permit in progress</span></label>
|
|
||||||
<label><input type="radio" name="location_status" value="permit_granted"><span class="pill">Permit approved</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Timeline <span class="required">*</span></span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="radio" name="timeline" value="asap" required><span class="pill">ASAP</span></label>
|
|
||||||
<label><input type="radio" name="timeline" value="3-6mo"><span class="pill">3-6 Months</span></label>
|
|
||||||
<label><input type="radio" name="timeline" value="6-12mo"><span class="pill">6-12 Months</span></label>
|
|
||||||
<label><input type="radio" name="timeline" value="12+mo"><span class="pill">12+ Months</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Financing Status</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="radio" name="financing_status" value="self_funded"><span class="pill">Self-Funded</span></label>
|
|
||||||
<label><input type="radio" name="financing_status" value="loan_approved"><span class="pill">Loan Approved</span></label>
|
|
||||||
<label><input type="radio" name="financing_status" value="seeking"><span class="pill">Seeking Financing</span></label>
|
|
||||||
<label><input type="radio" name="financing_status" value="not_started"><span class="pill">Not Started</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label wiz-checkbox-label">
|
|
||||||
<input type="checkbox" name="wants_financing_help" value="1">
|
|
||||||
<span>I'd like help finding financing options</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Decision Process</span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="radio" name="decision_process" value="solo"><span class="pill">Solo Decision</span></label>
|
|
||||||
<label><input type="radio" name="decision_process" value="partners"><span class="pill">With Partners</span></label>
|
|
||||||
<label><input type="radio" name="decision_process" value="committee"><span class="pill">Committee / Board</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">You are... <span class="required">*</span></span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="entrepreneur" required><span class="pill">Entrepreneur / Investor</span></label>
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="tennis_club"><span class="pill">Tennis / Sports Club</span></label>
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="municipality"><span class="pill">Municipality / Public</span></label>
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="developer"><span class="pill">Real Estate Developer</span></label>
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="operator"><span class="pill">Existing Padel Operator</span></label>
|
|
||||||
<label><input type="radio" name="stakeholder_type" value="architect"><span class="pill">Architect / Engineer</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<span class="field-label">Services Needed <span style="color:var(--txt-3);font-weight:400">(select all that apply)</span></span>
|
|
||||||
<div class="pill-grid">
|
|
||||||
<label><input type="checkbox" name="services_needed" value="court_supply"><span class="pill">Court Supply</span></label>
|
|
||||||
<label><input type="checkbox" name="services_needed" value="installation"><span class="pill">Installation</span></label>
|
|
||||||
<label><input type="checkbox" name="services_needed" value="construction"><span class="pill">Hall Construction</span></label>
|
|
||||||
<label><input type="checkbox" name="services_needed" value="design"><span class="pill">Facility Design</span></label>
|
|
||||||
<label><input type="checkbox" name="services_needed" value="lighting"><span class="pill">Lighting</span></label>
|
|
||||||
<label><input type="checkbox" name="services_needed" value="flooring"><span class="pill">Flooring</span></label>
|
|
||||||
<label><input type="checkbox" name="services_needed" value="turnkey"><span class="pill">Full Turnkey</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="wiz_additional">Anything else?</label>
|
|
||||||
<textarea id="wiz_additional" name="additional_info" class="wiz-input" rows="3" placeholder="Any specific requirements, questions, or context..."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0">
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="wiz_name">Full Name <span class="required">*</span></label>
|
|
||||||
<input type="text" id="wiz_name" name="contact_name" class="wiz-input" required>
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="wiz_email">Email <span class="required">*</span></label>
|
|
||||||
<input type="email" id="wiz_email" name="contact_email" class="wiz-input" required>
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="wiz_phone">Phone <span style="color:var(--txt-3);font-weight:400">(optional)</span></label>
|
|
||||||
<input type="tel" id="wiz_phone" name="contact_phone" class="wiz-input">
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="wiz_company">Company <span style="color:var(--txt-3);font-weight:400">(optional)</span></label>
|
|
||||||
<input type="text" id="wiz_company" name="contact_company" class="wiz-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="wiz-privacy-box">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5 7V5a3.5 3.5 0 10-7 0v2M4 7h8a1 1 0 011 1v5a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1z" stroke="#3B82F6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
<span>Your contact details are shared only with 2-5 pre-vetted suppliers that match your project specs.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="consent-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="consent" value="1" required>
|
|
||||||
<span>I agree that my project details and contact info may be shared with verified padel court suppliers. <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> · <a href="{{ url_for('public.terms') }}">Terms</a></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="wiz-success" id="wizSuccess" style="display:none">
|
|
||||||
<div class="wiz-success__icon">✓</div>
|
|
||||||
<h3>You're matched!</h3>
|
|
||||||
<p>We'll connect you with 2-5 verified suppliers within 48 hours. Check your email for confirmation.</p>
|
|
||||||
{% if not user %}
|
|
||||||
<div class="wiz-signup-nudge">
|
|
||||||
<p>Save this plan, compare scenarios, and get early access to financing tools.</p>
|
|
||||||
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">Create Account</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preview bar + navigation -->
|
|
||||||
<div class="wizard-preview" id="wizPreview"></div>
|
<div class="wizard-preview" id="wizPreview"></div>
|
||||||
<div class="wizard-nav" id="wizNav"></div>
|
<div class="wizard-nav" id="wizNav"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- CAPEX -->
|
<!-- CAPEX -->
|
||||||
<div class="tab" id="tab-capex">
|
<div class="tab" id="tab-capex">
|
||||||
@@ -273,10 +119,6 @@
|
|||||||
<div class="chart-container__label">CAPEX Breakdown</div>
|
<div class="chart-container__label">CAPEX Breakdown</div>
|
||||||
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCapex"></canvas></div>
|
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCapex"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lead-cta mt-4" id="capexCta">
|
|
||||||
<span class="lead-cta__text">These are estimates. Get actual quotes from verified court suppliers.</span>
|
|
||||||
<a href="#" class="lead-cta__btn" onclick="wizStep=5;activeTab='assumptions';showWizStep();render();window.scrollTo({top:0,behavior:'smooth'});return false">Get Builder Quotes</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OPERATING -->
|
<!-- OPERATING -->
|
||||||
@@ -344,11 +186,6 @@
|
|||||||
<div class="section-header"><h3>Pricing Sensitivity (at target utilization)</h3></div>
|
<div class="section-header"><h3>Pricing Sensitivity (at target utilization)</h3></div>
|
||||||
<div id="priceSensTable"></div>
|
<div id="priceSensTable"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lead-cta mt-4" id="returnsCta">
|
|
||||||
<span class="lead-cta__text">Your project looks profitable. Ready to take the next step?</span>
|
|
||||||
<a href="#" class="lead-cta__btn" onclick="wizStep=5;activeTab='assumptions';showWizStep();render();window.scrollTo({top:0,behavior:'smooth'});return false">Get Builder Quotes</a>
|
|
||||||
<a href="{{ url_for('billing.checkout', plan='business_plan') }}" class="lead-cta__btn lead-cta__btn--secondary">Export Business Plan (PDF) — €99</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- METRICS -->
|
<!-- METRICS -->
|
||||||
@@ -360,13 +197,45 @@
|
|||||||
<div class="mb-section"><div class="section-header"><h3>Investment Efficiency</h3></div><div class="grid-4" id="mInvest"></div></div>
|
<div class="mb-section"><div class="section-header"><h3>Investment Efficiency</h3></div><div class="grid-4" id="mInvest"></div></div>
|
||||||
<div class="mb-section"><div class="section-header"><h3>Operational</h3></div><div class="grid-4" id="mOps"></div></div>
|
<div class="mb-section"><div class="section-header"><h3>Operational</h3></div><div class="grid-4" id="mOps"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Inline quote CTA (mobile / narrow screens) -->
|
||||||
|
<div class="quote-inline-cta" id="quoteInlineCta">
|
||||||
|
<div class="quote-inline-cta__label">Next Step</div>
|
||||||
|
<h3 class="quote-inline-cta__title">Get quotes from verified court suppliers</h3>
|
||||||
|
<p class="quote-inline-cta__desc">Share your project specs and we'll connect you with matched suppliers.</p>
|
||||||
|
<ul class="quote-inline-cta__checks">
|
||||||
|
<li><span class="quote-inline-cta__check">✓</span> Matched suppliers</li>
|
||||||
|
<li><span class="quote-inline-cta__check">✓</span> Direct contact, no middleman</li>
|
||||||
|
<li><span class="quote-inline-cta__check">✓</span> No commitment</li>
|
||||||
|
<li><span class="quote-inline-cta__check">✓</span> Your data stays private</li>
|
||||||
|
</ul>
|
||||||
|
<button class="quote-inline-cta__btn" onclick="goToQuoteForm()">Get Supplier Quotes →</button>
|
||||||
|
<span class="quote-inline-cta__hint">Takes ~2 minutes</span>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Sidebar CTA (desktop wide screens) -->
|
||||||
|
<aside class="quote-sidebar" id="quoteSidebar">
|
||||||
|
<div class="quote-sidebar__label">Next Step</div>
|
||||||
|
<h3 class="quote-sidebar__title">Get quotes from verified court suppliers</h3>
|
||||||
|
<p class="quote-sidebar__desc">Share your project specs and we'll connect you with matched suppliers.</p>
|
||||||
|
<ul class="quote-sidebar__checks">
|
||||||
|
<li><span class="quote-sidebar__check">✓</span> Matched suppliers</li>
|
||||||
|
<li><span class="quote-sidebar__check">✓</span> Direct contact, no middleman</li>
|
||||||
|
<li><span class="quote-sidebar__check">✓</span> No commitment</li>
|
||||||
|
<li><span class="quote-sidebar__check">✓</span> Your data stays private</li>
|
||||||
|
</ul>
|
||||||
|
<button class="quote-sidebar__btn" onclick="goToQuoteForm()">Get Supplier Quotes →</button>
|
||||||
|
<span class="quote-sidebar__hint">Takes ~2 minutes</span>
|
||||||
|
<div style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1)">
|
||||||
|
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__btn" style="background:#16A34A;text-decoration:none;display:block;text-align:center">Export Business Plan (PDF) →</a>
|
||||||
|
<span class="quote-sidebar__hint">€99 one-time · Bank-ready</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{% if not user %}
|
{% if not user %}
|
||||||
<div class="signup-bar" id="signupBar">
|
<div class="signup-bar" id="signupBar">
|
||||||
<span>Create an account to <b>save scenarios</b>, <b>compare plans</b>, and get early access to <b>financing tools</b>.</span>
|
<span>Create an account to <b>save scenarios</b> and <b>compare plans</b>.</span>
|
||||||
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">Sign Up</a>
|
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">Sign Up Free</a>
|
||||||
<button class="signup-bar__close" onclick="document.getElementById('signupBar').style.display='none'" aria-label="Dismiss">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -385,7 +254,6 @@ window.__PADELNOMICS_INITIAL_D__ = {{ initial_d | safe }};
|
|||||||
window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}";
|
window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}";
|
||||||
window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
||||||
window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
||||||
window.__PADELNOMICS_QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
|
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Public domain: landing page, marketing pages, legal pages.
|
Public domain: landing page, marketing pages, legal pages, feedback.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, Response, render_template
|
from quart import Blueprint, Response, render_template, request, session
|
||||||
|
|
||||||
from ..core import config, fetch_one
|
from ..core import check_rate_limit, config, csrf_protect, execute, fetch_all, fetch_one
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
"public",
|
"public",
|
||||||
@@ -59,10 +59,38 @@ async def about():
|
|||||||
@bp.route("/suppliers")
|
@bp.route("/suppliers")
|
||||||
async def suppliers():
|
async def suppliers():
|
||||||
total_suppliers, total_countries = await _supplier_counts()
|
total_suppliers, total_countries = await _supplier_counts()
|
||||||
|
|
||||||
|
# Live stats
|
||||||
|
calc_requests = await fetch_one("SELECT COUNT(*) as cnt FROM scenarios WHERE deleted_at IS NULL")
|
||||||
|
avg_budget = await fetch_one(
|
||||||
|
"SELECT AVG(budget_estimate) as avg FROM lead_requests WHERE budget_estimate > 0 AND lead_type = 'quote'"
|
||||||
|
)
|
||||||
|
active_suppliers = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as cnt FROM suppliers WHERE tier IN ('growth', 'pro') AND claimed_by IS NOT NULL"
|
||||||
|
)
|
||||||
|
monthly_leads = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' AND created_at >= date('now', '-30 days')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lead feed preview — 3 recent verified hot/warm leads, anonymized
|
||||||
|
preview_leads = await fetch_all(
|
||||||
|
"""SELECT facility_type, court_count, country, timeline, budget_estimate,
|
||||||
|
heat_score, glass_type, build_context, financing_status
|
||||||
|
FROM lead_requests
|
||||||
|
WHERE lead_type = 'quote' AND verified_at IS NOT NULL
|
||||||
|
AND heat_score IN ('hot', 'warm')
|
||||||
|
ORDER BY created_at DESC LIMIT 3"""
|
||||||
|
)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"suppliers.html",
|
"suppliers.html",
|
||||||
total_suppliers=total_suppliers,
|
total_suppliers=total_suppliers,
|
||||||
total_countries=total_countries,
|
total_countries=total_countries,
|
||||||
|
calc_requests=calc_requests["cnt"] if calc_requests else 0,
|
||||||
|
avg_budget=int(avg_budget["avg"]) if avg_budget and avg_budget["avg"] else 0,
|
||||||
|
active_suppliers=active_suppliers["cnt"] if active_suppliers else 0,
|
||||||
|
monthly_leads=monthly_leads["cnt"] if monthly_leads else 0,
|
||||||
|
preview_leads=preview_leads,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,3 +114,33 @@ async def sitemap():
|
|||||||
xml += f" <url><loc>{url}</loc></url>\n"
|
xml += f" <url><loc>{url}</loc></url>\n"
|
||||||
xml += "</urlset>"
|
xml += "</urlset>"
|
||||||
return Response(xml, content_type="application/xml")
|
return Response(xml, content_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Feedback
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/feedback", methods=["POST"])
|
||||||
|
@csrf_protect
|
||||||
|
async def feedback():
|
||||||
|
"""Handle feedback submission. Returns HTMX partial."""
|
||||||
|
# Rate limit: 5 per hour per IP
|
||||||
|
key = f"feedback:{request.remote_addr}"
|
||||||
|
allowed, _info = await check_rate_limit(key, limit=5, window=3600)
|
||||||
|
if not allowed:
|
||||||
|
return '<p style="font-size:0.8125rem;color:#D97706;padding:8px">Too many submissions. Try again later.</p>'
|
||||||
|
|
||||||
|
form = await request.form
|
||||||
|
message = form.get("message", "").strip()
|
||||||
|
if not message:
|
||||||
|
return '<p style="font-size:0.8125rem;color:#DC2626;padding:8px">Please enter a message.</p>'
|
||||||
|
|
||||||
|
page_url = form.get("page_url", "")
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"INSERT INTO feedback (user_id, page_url, message) VALUES (?, ?, ?)",
|
||||||
|
(user_id, page_url, message),
|
||||||
|
)
|
||||||
|
|
||||||
|
return '<p style="font-size:0.8125rem;color:#16A34A;padding:12px;text-align:center;font-weight:600">Thank you for your feedback!</p>'
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers.
|
Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="{{ url_for('planner.index') }}" class="btn">Open the Planner →</a>
|
<a href="{{ url_for('planner.index') }}" class="btn">Plan Your Padel Business →</a>
|
||||||
<a href="{{ url_for('directory.index') }}" class="btn-outline">Browse Suppliers</a>
|
<a href="{{ url_for('directory.index') }}" class="btn-outline">Browse Suppliers</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-bullets">
|
<div class="hero-bullets">
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="roi-calc__note">Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.</p>
|
<p class="roi-calc__note">Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.</p>
|
||||||
<a href="{{ url_for('planner.index') }}" class="roi-calc__cta">Open Full Planner →</a>
|
<a href="{{ url_for('planner.index') }}" class="roi-calc__cta">Plan Your Padel Business →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
<div class="match-step">
|
<div class="match-step">
|
||||||
<div class="match-step__num">3</div>
|
<div class="match-step__num">3</div>
|
||||||
<h3>Compare & Build</h3>
|
<h3>Compare & Build</h3>
|
||||||
<p>Receive proposals from 2-5 relevant suppliers. No cold outreach needed.</p>
|
<p>Receive proposals from matched suppliers. No cold outreach needed.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mt-8">
|
<div class="text-center mt-8">
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>How does supplier matching work?</summary>
|
<summary>How does supplier matching work?</summary>
|
||||||
<p>When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with 2-5 relevant suppliers from our directory. They contact you directly with proposals.</p>
|
<p>When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Is the supplier directory free?</summary>
|
<summary>Is the supplier directory free?</summary>
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
<section class="text-center py-12">
|
<section class="text-center py-12">
|
||||||
<h2 class="text-2xl mb-2">Start Planning Today</h2>
|
<h2 class="text-2xl mb-2">Start Planning Today</h2>
|
||||||
<p class="text-slate mb-6">Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.</p>
|
<p class="text-slate mb-6">Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.</p>
|
||||||
<a href="{{ url_for('planner.index') }}" class="btn">Open the Planner</a>
|
<a href="{{ url_for('planner.index') }}" class="btn">Plan Your Padel Business</a>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,10 +11,15 @@
|
|||||||
.sup-hero .btn { margin-top: 1.5rem; padding: 14px 32px; font-size: 1rem; }
|
.sup-hero .btn { margin-top: 1.5rem; padding: 14px 32px; font-size: 1rem; }
|
||||||
|
|
||||||
.sup-stats {
|
.sup-stats {
|
||||||
display: flex; justify-content: center; gap: 3rem; padding: 1.5rem 0 2rem;
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem;
|
||||||
font-size: 0.875rem; color: #64748B;
|
max-width: 720px; margin: 0 auto; padding: 1.5rem 0 2rem;
|
||||||
}
|
}
|
||||||
.sup-stats strong { display: block; font-size: 1.5rem; color: #1E293B; }
|
.sup-stat-card {
|
||||||
|
text-align: center; background: white; border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 12px; padding: 1rem;
|
||||||
|
}
|
||||||
|
.sup-stat-card strong { display: block; font-size: 1.75rem; color: #1E293B; font-weight: 800; }
|
||||||
|
.sup-stat-card span { font-size: 0.8125rem; color: #64748B; }
|
||||||
|
|
||||||
.sup-section { padding: 2.5rem 0; }
|
.sup-section { padding: 2.5rem 0; }
|
||||||
.sup-section h2 { text-align: center; font-size: 1.5rem; margin-bottom: 0.5rem; }
|
.sup-section h2 { text-align: center; font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
@@ -34,15 +39,53 @@
|
|||||||
.sup-step h3 { font-size: 1rem; margin-bottom: 0.25rem; }
|
.sup-step h3 { font-size: 1rem; margin-bottom: 0.25rem; }
|
||||||
.sup-step p { font-size: 0.8125rem; color: #64748B; }
|
.sup-step p { font-size: 0.8125rem; color: #64748B; }
|
||||||
|
|
||||||
.sup-lead-preview {
|
/* Credit explainer */
|
||||||
|
.credit-explainer {
|
||||||
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
|
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
|
||||||
padding: 1.5rem; max-width: 600px; margin: 2rem auto 0;
|
padding: 1.5rem; max-width: 600px; margin: 2rem auto 0;
|
||||||
|
}
|
||||||
|
.credit-explainer h3 { font-size: 1rem; margin-bottom: 1rem; text-align: center; }
|
||||||
|
.credit-tiers {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.credit-tier {
|
||||||
|
text-align: center; padding: 0.75rem; background: white;
|
||||||
|
border: 1px solid #E2E8F0; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.credit-tier .tier-heat { font-weight: 700; font-size: 0.875rem; margin-bottom: 4px; }
|
||||||
|
.credit-tier .tier-cost { font-size: 1.25rem; font-weight: 800; color: #1D4ED8; }
|
||||||
|
.credit-tier .tier-label { font-size: 0.6875rem; color: #64748B; }
|
||||||
|
.heat-hot { color: #DC2626; }
|
||||||
|
.heat-warm { color: #D97706; }
|
||||||
|
.heat-cool { color: #3B82F6; }
|
||||||
|
|
||||||
|
/* Lead preview cards */
|
||||||
|
.sup-lead-preview {
|
||||||
|
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
|
||||||
|
padding: 1.5rem; max-width: 720px; margin: 2rem auto 0;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||||
}
|
}
|
||||||
.sup-lead-preview h4 { font-size: 0.875rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
|
.sup-lead-preview h4 {
|
||||||
.sup-lead-preview dl { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 1rem; font-size: 0.8125rem; }
|
font-size: 0.875rem; color: #94A3B8; text-transform: uppercase;
|
||||||
.sup-lead-preview dt { color: #94A3B8; }
|
letter-spacing: 0.04em; margin-bottom: 1rem;
|
||||||
.sup-lead-preview dd { color: #1E293B; font-weight: 500; margin: 0; }
|
}
|
||||||
|
.lead-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
|
||||||
|
.lead-preview-card {
|
||||||
|
background: white; border: 1px solid #E2E8F0; border-radius: 10px; padding: 1rem;
|
||||||
|
}
|
||||||
|
.lead-preview-card .lp-heat {
|
||||||
|
font-size: 0.6875rem; font-weight: 700; padding: 2px 8px;
|
||||||
|
border-radius: 999px; display: inline-block; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.lead-preview-card .lp-heat--hot { background: #FEE2E2; color: #DC2626; }
|
||||||
|
.lead-preview-card .lp-heat--warm { background: #FEF3C7; color: #D97706; }
|
||||||
|
.lead-preview-card dl { font-size: 0.8125rem; }
|
||||||
|
.lead-preview-card dt { color: #94A3B8; font-size: 0.6875rem; text-transform: uppercase; margin-top: 0.5rem; }
|
||||||
|
.lead-preview-card dd { color: #1E293B; font-weight: 500; margin: 0; }
|
||||||
|
.lead-preview-card .lp-blur {
|
||||||
|
color: transparent; text-shadow: 0 0 8px rgba(30,41,59,0.5);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pricing cards */
|
/* Pricing cards */
|
||||||
.pricing-grid {
|
.pricing-grid {
|
||||||
@@ -64,14 +107,15 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.pricing-card h3 { font-size: 1.25rem; margin-bottom: 0.25rem; }
|
.pricing-card h3 { font-size: 1.25rem; margin-bottom: 0.25rem; }
|
||||||
.pricing-card .price { font-size: 1.75rem; font-weight: 800; color: #1E293B; margin-bottom: 1rem; }
|
.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 ul { list-style: none; padding: 0; margin: 0 0 1.5rem; }
|
.pricing-card ul { list-style: none; padding: 0; margin: 0 0 1.5rem; }
|
||||||
.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: "✓"; 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 { width: 100%; text-align: center; }
|
||||||
|
|
||||||
/* Boosts */
|
/* Boosts */
|
||||||
@@ -112,10 +156,24 @@
|
|||||||
.sup-cta h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
.sup-cta h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
.sup-cta p { color: #64748B; margin-bottom: 1.5rem; }
|
.sup-cta p { color: #64748B; margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
/* Social proof */
|
||||||
|
.sup-proof {
|
||||||
|
max-width: 720px; margin: 0 auto; text-align: center;
|
||||||
|
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.sup-proof blockquote {
|
||||||
|
font-size: 1rem; color: #334155; font-style: italic;
|
||||||
|
line-height: 1.6; margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.sup-proof cite { font-size: 0.8125rem; color: #94A3B8; font-style: normal; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.sup-stats { flex-direction: column; gap: 1rem; align-items: center; }
|
.sup-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
.sup-steps, .sup-why { grid-template-columns: 1fr; }
|
.sup-steps, .sup-why { grid-template-columns: 1fr; }
|
||||||
.pricing-grid { grid-template-columns: 1fr; }
|
.pricing-grid { grid-template-columns: 1fr; }
|
||||||
|
.credit-tiers { grid-template-columns: 1fr; }
|
||||||
|
.lead-preview-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -128,10 +186,24 @@
|
|||||||
<a href="#pricing" class="btn">See Plans</a>
|
<a href="#pricing" class="btn">See Plans</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Stats -->
|
||||||
<div class="sup-stats">
|
<div class="sup-stats">
|
||||||
<div><strong>{{ total_suppliers }}+</strong> suppliers listed</div>
|
<div class="sup-stat-card">
|
||||||
<div><strong>{{ total_countries }}</strong> countries</div>
|
<strong>{{ calc_requests }}+</strong>
|
||||||
<div><strong>100%</strong> project-qualified leads</div>
|
<span>Business plans created</span>
|
||||||
|
</div>
|
||||||
|
<div class="sup-stat-card">
|
||||||
|
<strong>{% if avg_budget %}€{{ "{:,.0f}".format(avg_budget / 1000) }}K{% else %}—{% endif %}</strong>
|
||||||
|
<span>Avg. project value</span>
|
||||||
|
</div>
|
||||||
|
<div class="sup-stat-card">
|
||||||
|
<strong>{{ total_suppliers }}+</strong>
|
||||||
|
<span>Suppliers in {{ total_countries }} countries</span>
|
||||||
|
</div>
|
||||||
|
<div class="sup-stat-card">
|
||||||
|
<strong>{{ monthly_leads }}</strong>
|
||||||
|
<span>Leads this month</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- How it works -->
|
<!-- How it works -->
|
||||||
@@ -146,8 +218,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sup-step">
|
<div class="sup-step">
|
||||||
<div class="sup-step__num">2</div>
|
<div class="sup-step__num">2</div>
|
||||||
<h3>Get Quotes</h3>
|
<h3>Unlock Leads with Credits</h3>
|
||||||
<p>When entrepreneurs request quotes, we match them with suppliers based on location, services, and project specs.</p>
|
<p>Browse verified leads in your region. Spend credits to unlock full project details and contact info.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="sup-step">
|
<div class="sup-step">
|
||||||
<div class="sup-step__num">3</div>
|
<div class="sup-step__num">3</div>
|
||||||
@@ -156,20 +228,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Example lead -->
|
<!-- Credit explainer -->
|
||||||
|
<div class="credit-explainer">
|
||||||
|
<h3>How Credits Work</h3>
|
||||||
|
<p style="text-align:center;font-size:0.8125rem;color:#64748B;margin-bottom:1rem">
|
||||||
|
Each lead costs credits based on how ready-to-buy they are. Growth plans include 30 credits/mo, Pro includes 100.
|
||||||
|
</p>
|
||||||
|
<div class="credit-tiers">
|
||||||
|
<div class="credit-tier">
|
||||||
|
<div class="tier-heat heat-hot">Hot Lead</div>
|
||||||
|
<div class="tier-cost">35</div>
|
||||||
|
<div class="tier-label">credits · financing secured, ready now</div>
|
||||||
|
</div>
|
||||||
|
<div class="credit-tier">
|
||||||
|
<div class="tier-heat heat-warm">Warm Lead</div>
|
||||||
|
<div class="tier-cost">20</div>
|
||||||
|
<div class="tier-label">credits · active planning, 3-6 months</div>
|
||||||
|
</div>
|
||||||
|
<div class="credit-tier">
|
||||||
|
<div class="tier-heat heat-cool">Cool Lead</div>
|
||||||
|
<div class="tier-cost">8</div>
|
||||||
|
<div class="tier-label">credits · early research, 6-12 months</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live lead preview -->
|
||||||
<div class="sup-lead-preview">
|
<div class="sup-lead-preview">
|
||||||
<h4>Example Lead You'd Receive</h4>
|
<h4>Recent Verified Leads</h4>
|
||||||
|
{% if preview_leads %}
|
||||||
|
<div class="lead-preview-grid">
|
||||||
|
{% for lead in preview_leads %}
|
||||||
|
<div class="lead-preview-card">
|
||||||
|
<span class="lp-heat lp-heat--{{ lead.heat_score }}">{{ lead.heat_score | upper }}</span>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Facility</dt><dd>Indoor (Rent)</dd>
|
<dt>Facility</dt>
|
||||||
<dt>Courts</dt><dd>6 double + 2 single</dd>
|
<dd>{{ lead.facility_type | default("Indoor", true) | capitalize }} · {{ lead.court_count | default("?") }} courts</dd>
|
||||||
<dt>Glass</dt><dd>Panoramic</dd>
|
<dt>Country</dt>
|
||||||
|
<dd>{{ lead.country | default("—") }}</dd>
|
||||||
|
<dt>Budget</dt>
|
||||||
|
<dd>{% if lead.budget_estimate %}€{{ "{:,.0f}".format(lead.budget_estimate / 1000) }}K{% else %}—{% endif %}</dd>
|
||||||
|
<dt>Timeline</dt>
|
||||||
|
<dd>{{ lead.timeline | default("—") }}</dd>
|
||||||
|
<dt>Contact</dt>
|
||||||
|
<dd class="lp-blur">john@example.com</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p style="text-align:center;margin-top:1rem;font-size:0.8125rem;color:#64748B">
|
||||||
|
Unlock full contact details and project specs with credits.
|
||||||
|
<a href="#pricing" style="color:#1D4ED8;font-weight:600">Get started →</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="lead-preview-grid">
|
||||||
|
<div class="lead-preview-card">
|
||||||
|
<span class="lp-heat lp-heat--hot">HOT</span>
|
||||||
|
<dl>
|
||||||
|
<dt>Facility</dt><dd>Indoor (Rent) · 6 courts</dd>
|
||||||
<dt>Country</dt><dd>Germany</dd>
|
<dt>Country</dt><dd>Germany</dd>
|
||||||
<dt>Budget</dt><dd>€450K</dd>
|
<dt>Budget</dt><dd>€450K</dd>
|
||||||
<dt>Timeline</dt><dd>3-6 months</dd>
|
<dt>Timeline</dt><dd>3-6 months</dd>
|
||||||
<dt>Phase</dt><dd>Lease signed</dd>
|
<dt>Contact</dt><dd class="lp-blur">john@example.com</dd>
|
||||||
<dt>Financing</dt><dd>Loan approved</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lead-preview-card">
|
||||||
|
<span class="lp-heat lp-heat--warm">WARM</span>
|
||||||
|
<dl>
|
||||||
|
<dt>Facility</dt><dd>Outdoor · 4 courts</dd>
|
||||||
|
<dt>Country</dt><dd>Spain</dd>
|
||||||
|
<dt>Budget</dt><dd>€280K</dd>
|
||||||
|
<dt>Timeline</dt><dd>6-12 months</dd>
|
||||||
|
<dt>Contact</dt><dd class="lp-blur">maria@example.com</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="lead-preview-card">
|
||||||
|
<span class="lp-heat lp-heat--hot">HOT</span>
|
||||||
|
<dl>
|
||||||
|
<dt>Facility</dt><dd>Indoor (Own) · 8 courts</dd>
|
||||||
|
<dt>Country</dt><dd>Sweden</dd>
|
||||||
|
<dt>Budget</dt><dd>€720K</dd>
|
||||||
|
<dt>Timeline</dt><dd>ASAP</dd>
|
||||||
|
<dt>Contact</dt><dd class="lp-blur">erik@example.com</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="text-align:center;margin-top:1rem;font-size:0.8125rem;color:#64748B">
|
||||||
|
These are example leads. Real leads appear as entrepreneurs submit quote requests.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Why Padelnomics -->
|
||||||
|
<section class="sup-section">
|
||||||
|
<h2>Why Padelnomics Leads Are Different</h2>
|
||||||
|
<p class="sub">Every lead has already built a financial model for their project.</p>
|
||||||
|
<div class="sup-why">
|
||||||
|
<div class="sup-why-card">
|
||||||
|
<h3>Pre-Qualified</h3>
|
||||||
|
<p>Leads come through our financial planner. They've modeled CAPEX, revenue, and ROI before contacting you.</p>
|
||||||
|
</div>
|
||||||
|
<div class="sup-why-card">
|
||||||
|
<h3>Full Project Brief</h3>
|
||||||
|
<p>You get venue type, court count, glass/lighting specs, budget, timeline, financing status, and contact details.</p>
|
||||||
|
</div>
|
||||||
|
<div class="sup-why-card">
|
||||||
|
<h3>No Cold Outreach</h3>
|
||||||
|
<p>Entrepreneurs come to us. You only hear from people actively planning to build padel facilities.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Pricing -->
|
<!-- Pricing -->
|
||||||
@@ -178,24 +346,27 @@
|
|||||||
<p class="sub">Choose the plan that fits your growth goals.</p>
|
<p class="sub">Choose the plan that fits your growth goals.</p>
|
||||||
<div class="pricing-grid">
|
<div class="pricing-grid">
|
||||||
<!-- Growth -->
|
<!-- Growth -->
|
||||||
<div class="pricing-card">
|
<div class="pricing-card pricing-card--highlight">
|
||||||
|
<div class="pricing-card__popular">Most Popular</div>
|
||||||
<h3>Growth</h3>
|
<h3>Growth</h3>
|
||||||
<div class="price">€149 <span>/mo</span></div>
|
<div class="price">€149 <span>/mo</span></div>
|
||||||
|
<div class="credits-inc">30 credits/mo included</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Company name & category badge</li>
|
<li>Company name & category badge</li>
|
||||||
<li>City & country shown</li>
|
<li>City & country shown</li>
|
||||||
<li>Description (3 lines)</li>
|
<li>Description (3 lines)</li>
|
||||||
<li>"Growth" badge</li>
|
<li>"Growth" badge</li>
|
||||||
<li>Priority over free listings</li>
|
<li>Priority over free listings</li>
|
||||||
|
<li>Access to lead feed</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="mailto:{{ config.ADMIN_EMAIL }}?subject=Growth Plan Interest" class="btn-outline" style="display:block;text-align:center">Get Started</a>
|
<a href="{{ url_for('suppliers.signup') }}?plan=supplier_growth" class="btn" style="display:block;text-align:center">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pro -->
|
<!-- Pro -->
|
||||||
<div class="pricing-card pricing-card--highlight">
|
<div class="pricing-card">
|
||||||
<div class="pricing-card__popular">Most Popular</div>
|
|
||||||
<h3>Pro</h3>
|
<h3>Pro</h3>
|
||||||
<div class="price">€399 <span>/mo</span></div>
|
<div class="price">€399 <span>/mo</span></div>
|
||||||
|
<div class="credits-inc">100 credits/mo included</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Everything in Growth</li>
|
<li>Everything in Growth</li>
|
||||||
<li>Company logo displayed</li>
|
<li>Company logo displayed</li>
|
||||||
@@ -205,13 +376,13 @@
|
|||||||
<li>Priority placement</li>
|
<li>Priority placement</li>
|
||||||
<li>Highlighted card border</li>
|
<li>Highlighted card border</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="mailto:{{ config.ADMIN_EMAIL }}?subject=Pro Plan Interest" class="btn" style="display:block;text-align:center">Get Started</a>
|
<a href="{{ url_for('suppliers.signup') }}?plan=supplier_pro" class="btn-outline" style="display:block;text-align:center">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boost add-ons -->
|
<!-- Boost add-ons -->
|
||||||
<h3 style="text-align:center;font-size:1rem;margin-top:2rem;margin-bottom:0.25rem">Boost Add-Ons</h3>
|
<h3 style="text-align:center;font-size:1rem;margin-top:2rem;margin-bottom:0.25rem">Boost Add-Ons</h3>
|
||||||
<p style="text-align:center;color:#64748B;font-size:0.8125rem;margin-bottom:1rem">Available with any paid plan.</p>
|
<p style="text-align:center;color:#64748B;font-size:0.8125rem;margin-bottom:1rem">Available with any paid plan. Manage from your dashboard.</p>
|
||||||
<div class="boost-grid">
|
<div class="boost-grid">
|
||||||
<div class="boost-card">
|
<div class="boost-card">
|
||||||
<strong>Logo</strong>
|
<strong>Logo</strong>
|
||||||
@@ -236,23 +407,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Why Padelnomics -->
|
<!-- Social proof -->
|
||||||
<section class="sup-section">
|
<section class="sup-section">
|
||||||
<h2>Why Padelnomics Leads Are Different</h2>
|
<h2>Trusted by Padel Industry Leaders</h2>
|
||||||
<p class="sub">Every lead has already built a financial model for their project.</p>
|
<p class="sub">Suppliers across {{ total_countries }} countries use Padelnomics to reach new customers.</p>
|
||||||
<div class="sup-why">
|
<div class="sup-proof">
|
||||||
<div class="sup-why-card">
|
<blockquote>"Padelnomics sends us leads that are already serious about building. The project briefs are more detailed than what we get from trade shows."</blockquote>
|
||||||
<h3>Pre-Qualified</h3>
|
<cite>— European padel court manufacturer</cite>
|
||||||
<p>Leads come through our financial planner. They've modeled CAPEX, revenue, and ROI before contacting you.</p>
|
|
||||||
</div>
|
|
||||||
<div class="sup-why-card">
|
|
||||||
<h3>Full Project Brief</h3>
|
|
||||||
<p>You get venue type, court count, glass/lighting specs, budget, timeline, financing status, and contact details.</p>
|
|
||||||
</div>
|
|
||||||
<div class="sup-why-card">
|
|
||||||
<h3>No Cold Outreach</h3>
|
|
||||||
<p>Entrepreneurs come to us. You only hear from people actively planning to build padel facilities.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -266,7 +427,11 @@
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>How much does it cost?</summary>
|
<summary>How much does it cost?</summary>
|
||||||
<p>We offer two plans: Growth (€149/mo) with description, badge, and priority placement; and Pro (€399/mo) with logo, website, verified badge, and maximum visibility. Optional boost add-ons are available on top.</p>
|
<p>We offer two plans: Growth (€149/mo, 30 credits) with description, badge, and priority placement; and Pro (€399/mo, 100 credits) with logo, website, verified badge, and maximum visibility. Optional boost add-ons are available on top.</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>How do credits work?</summary>
|
||||||
|
<p>Credits are how you unlock lead contact details. Each plan includes monthly credits (Growth: 30, Pro: 100). Hot leads cost 35 credits, warm leads 20, and cool leads 8. You can buy additional credit packs anytime from your dashboard. Unused credits roll over month to month.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>What information do leads include?</summary>
|
<summary>What information do leads include?</summary>
|
||||||
@@ -274,7 +439,15 @@
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>How are leads matched to suppliers?</summary>
|
<summary>How are leads matched to suppliers?</summary>
|
||||||
<p>We match based on location, services offered, and project requirements. Each lead is shared with 2-5 relevant suppliers to ensure quality without overwhelming the entrepreneur.</p>
|
<p>We match based on location, services offered, and project requirements. All verified leads appear in your lead feed, with leads in your service area highlighted. You choose which leads to unlock.</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Which countries do you cover?</summary>
|
||||||
|
<p>Padelnomics has suppliers listed across {{ total_countries }} countries. Our strongest coverage is in Europe (Germany, Spain, Sweden, UK, Netherlands, Italy) but we're growing globally as padel expands.</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Can I cancel anytime?</summary>
|
||||||
|
<p>Yes. You can cancel your subscription at any time from your dashboard. Your listing stays active until the end of the current billing period. Unused credits are forfeited upon cancellation.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>My company isn't listed. How do I get added?</summary>
|
<summary>My company isn't listed. How do I get added?</summary>
|
||||||
|
|||||||
0
padelnomics/src/padelnomics/scripts/__init__.py
Normal file
0
padelnomics/src/padelnomics/scripts/__init__.py
Normal file
211
padelnomics/src/padelnomics/scripts/setup_paddle.py
Normal file
211
padelnomics/src/padelnomics/scripts/setup_paddle.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Create all Paddle products and prices for Padelnomics.
|
||||||
|
|
||||||
|
Run once per environment (sandbox, then production).
|
||||||
|
Creates products in Paddle and writes the resulting IDs to the paddle_products DB table.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python -m padelnomics.scripts.setup_paddle
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from paddle_billing import Client as PaddleClient
|
||||||
|
from paddle_billing import Environment, Options
|
||||||
|
from paddle_billing.Entities.Shared import CurrencyCode, Money, TaxCategory
|
||||||
|
from paddle_billing.Resources.Prices.Operations import CreatePrice
|
||||||
|
from paddle_billing.Resources.Products.Operations import CreateProduct
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
PADDLE_API_KEY = os.getenv("PADDLE_API_KEY", "")
|
||||||
|
PADDLE_ENVIRONMENT = os.getenv("PADDLE_ENVIRONMENT", "sandbox")
|
||||||
|
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
||||||
|
|
||||||
|
if not PADDLE_API_KEY:
|
||||||
|
print("ERROR: Set PADDLE_API_KEY in .env first")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCTS = [
|
||||||
|
# Subscriptions
|
||||||
|
{
|
||||||
|
"key": "supplier_growth",
|
||||||
|
"name": "Supplier Growth",
|
||||||
|
"price": 14900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "supplier_pro",
|
||||||
|
"name": "Supplier Pro",
|
||||||
|
"price": 39900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
# Boost add-ons (subscriptions)
|
||||||
|
{
|
||||||
|
"key": "boost_logo",
|
||||||
|
"name": "Boost: Logo",
|
||||||
|
"price": 2900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "boost_highlight",
|
||||||
|
"name": "Boost: Highlight",
|
||||||
|
"price": 3900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "boost_verified",
|
||||||
|
"name": "Boost: Verified Badge",
|
||||||
|
"price": 4900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "boost_newsletter",
|
||||||
|
"name": "Boost: Newsletter Feature",
|
||||||
|
"price": 9900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
# One-time boosts
|
||||||
|
{
|
||||||
|
"key": "boost_sticky_week",
|
||||||
|
"name": "Boost: Sticky Top 1 Week",
|
||||||
|
"price": 7900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "boost_sticky_month",
|
||||||
|
"name": "Boost: Sticky Top 1 Month",
|
||||||
|
"price": 19900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
# Credit packs
|
||||||
|
{
|
||||||
|
"key": "credits_25",
|
||||||
|
"name": "Credit Pack 25",
|
||||||
|
"price": 9900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "credits_50",
|
||||||
|
"name": "Credit Pack 50",
|
||||||
|
"price": 17900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "credits_100",
|
||||||
|
"name": "Credit Pack 100",
|
||||||
|
"price": 32900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "credits_250",
|
||||||
|
"name": "Credit Pack 250",
|
||||||
|
"price": 74900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
# PDF product
|
||||||
|
{
|
||||||
|
"key": "business_plan",
|
||||||
|
"name": "Padel Business Plan (PDF)",
|
||||||
|
"price": 9900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"billing_type": "one_time",
|
||||||
|
},
|
||||||
|
# Planner subscriptions
|
||||||
|
{
|
||||||
|
"key": "starter",
|
||||||
|
"name": "Planner Starter",
|
||||||
|
"price": 1900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pro",
|
||||||
|
"name": "Planner Pro",
|
||||||
|
"price": 4900,
|
||||||
|
"currency": CurrencyCode.EUR,
|
||||||
|
"interval": "month",
|
||||||
|
"billing_type": "subscription",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
env = Environment.SANDBOX if PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
|
||||||
|
paddle = PaddleClient(PADDLE_API_KEY, options=Options(env))
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
print(f"Creating products in {PADDLE_ENVIRONMENT}...\n")
|
||||||
|
|
||||||
|
for spec in PRODUCTS:
|
||||||
|
# Create product
|
||||||
|
product = paddle.products.create(CreateProduct(
|
||||||
|
name=spec["name"],
|
||||||
|
tax_category=TaxCategory.Standard,
|
||||||
|
))
|
||||||
|
print(f" Product: {spec['name']} -> {product.id}")
|
||||||
|
|
||||||
|
# Create price
|
||||||
|
price_kwargs = {
|
||||||
|
"description": spec["name"],
|
||||||
|
"product_id": product.id,
|
||||||
|
"unit_price": Money(str(spec["price"]), spec["currency"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec["billing_type"] == "subscription":
|
||||||
|
from paddle_billing.Entities.Shared import TimePeriod
|
||||||
|
price_kwargs["billing_cycle"] = TimePeriod(interval="month", frequency=1)
|
||||||
|
|
||||||
|
price = paddle.prices.create(CreatePrice(**price_kwargs))
|
||||||
|
print(f" Price: {spec['key']} = {price.id}")
|
||||||
|
|
||||||
|
# Write to DB
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO paddle_products
|
||||||
|
(key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(spec["key"], product.id, price.id, spec["name"],
|
||||||
|
spec["price"], "EUR", spec["billing_type"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n✓ All products written to {db_path}")
|
||||||
|
print(" No .env changes needed — price IDs are now in the paddle_products table.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -621,6 +621,164 @@
|
|||||||
color: var(--txt);
|
color: var(--txt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Quote Sidebar CTA (desktop fixed) ── */
|
||||||
|
.quote-sidebar {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
right: max(1rem, calc((100vw - 72rem) / 2 - 280px));
|
||||||
|
top: 80px;
|
||||||
|
width: 240px;
|
||||||
|
background: #EFF6FF;
|
||||||
|
border: 2px solid #1D4ED8;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 24px rgba(29,78,216,0.1);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.quote-sidebar__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #1D4ED8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.quote-sidebar__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0F172A;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.quote-sidebar__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.quote-sidebar__checks {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.quote-sidebar__checks li {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.quote-sidebar__check {
|
||||||
|
color: #16A34A;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.quote-sidebar__btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: #1D4ED8;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
box-shadow: 0 2px 10px rgba(29,78,216,0.25);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.quote-sidebar__btn:hover {
|
||||||
|
background: #1E40AF;
|
||||||
|
}
|
||||||
|
.quote-sidebar__hint {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.quote-sidebar { display: none !important; }
|
||||||
|
}
|
||||||
|
/* ── Quote inline CTA (mobile/narrow — replaces sidebar) ── */
|
||||||
|
.quote-inline-cta {
|
||||||
|
display: none;
|
||||||
|
background: #EFF6FF;
|
||||||
|
border: 2px solid #1D4ED8;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
max-width: 560px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #1D4ED8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0F172A;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__checks {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__checks li {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__check {
|
||||||
|
color: #16A34A;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: #1D4ED8;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
box-shadow: 0 2px 10px rgba(29,78,216,0.25);
|
||||||
|
transition: background 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.quote-inline-cta__btn:hover { background: #1E40AF; }
|
||||||
|
.quote-inline-cta__hint {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1401px) {
|
||||||
|
.quote-inline-cta { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Exit waterfall ── */
|
/* ── Exit waterfall ── */
|
||||||
.waterfall-row {
|
.waterfall-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -799,20 +957,26 @@
|
|||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wizard preview bar */
|
/* Wizard sticky footer (preview + nav) */
|
||||||
|
.wizard-footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 10px 0 0;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
.wizard-preview {
|
.wizard-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px 16px 0 0;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
|
||||||
margin-top: 1.5rem;
|
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
max-width: 560px;
|
|
||||||
}
|
}
|
||||||
.wiz-preview__item {
|
.wiz-preview__item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -839,7 +1003,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 12px;
|
padding: 10px 16px 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -898,163 +1066,6 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step 5 form styles */
|
|
||||||
.wiz-autofill-summary {
|
|
||||||
background: var(--bg-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.wiz-autofill-summary dt {
|
|
||||||
color: var(--txt-3);
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.wiz-autofill-summary dd {
|
|
||||||
color: var(--head);
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
}
|
|
||||||
#wizQuoteForm .field-group { margin-bottom: 1.25rem; }
|
|
||||||
#wizQuoteForm .field-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--txt-2);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
#wizQuoteForm .field-label .required { color: #EF4444; }
|
|
||||||
#wizQuoteForm .pill-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
#wizQuoteForm .pill-grid label { cursor: pointer; }
|
|
||||||
#wizQuoteForm .pill-grid input[type="radio"],
|
|
||||||
#wizQuoteForm .pill-grid input[type="checkbox"] { display: none; }
|
|
||||||
#wizQuoteForm .pill-grid .pill {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 7px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--txt-3);
|
|
||||||
transition: all 0.15s;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
#wizQuoteForm .pill-grid .pill:hover {
|
|
||||||
background: var(--bg-3);
|
|
||||||
color: var(--txt-2);
|
|
||||||
}
|
|
||||||
#wizQuoteForm .pill-grid input:checked + .pill {
|
|
||||||
background: var(--rd);
|
|
||||||
border-color: var(--rd);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.wiz-input {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-3);
|
|
||||||
border: 1px solid var(--border-2);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
color: var(--head);
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.wiz-input:focus {
|
|
||||||
border-color: rgba(29,78,216,0.5);
|
|
||||||
}
|
|
||||||
textarea.wiz-input {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.wiz-checkbox-label {
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.wiz-checkbox-label input[type="checkbox"] {
|
|
||||||
margin: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wiz-privacy-box {
|
|
||||||
background: var(--bl-bg);
|
|
||||||
border: 1px solid rgba(29,78,216,0.2);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--rd);
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.wiz-privacy-box svg { flex-shrink: 0; margin-top: 1px; }
|
|
||||||
#wizQuoteForm .consent-group { margin-bottom: 1.25rem; }
|
|
||||||
#wizQuoteForm .consent-group label {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--txt-2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#wizQuoteForm .consent-group input[type="checkbox"] {
|
|
||||||
margin-top: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
#wizQuoteForm .consent-group a {
|
|
||||||
color: var(--rd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Success state */
|
|
||||||
.wiz-success {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
.wiz-success__icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--gn);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 24px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.wiz-success h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--head);
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
.wiz-success p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--txt-2);
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.wiz-signup-nudge {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
background: var(--cta-bg);
|
|
||||||
border: 2px solid var(--cta);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
.wiz-signup-nudge p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--txt-2);
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Guest signup bar (sticky on results) ── */
|
/* ── Guest signup bar (sticky on results) ── */
|
||||||
.signup-bar {
|
.signup-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -1072,16 +1083,13 @@ textarea.wiz-input {
|
|||||||
}
|
}
|
||||||
.signup-bar span { flex: 1; }
|
.signup-bar span { flex: 1; }
|
||||||
.signup-bar b { color: var(--head); }
|
.signup-bar b { color: var(--head); }
|
||||||
.signup-bar__close {
|
|
||||||
background: none;
|
/* CAPEX tab — narrower content on wide screens */
|
||||||
border: none;
|
#tab-capex {
|
||||||
font-size: 18px;
|
max-width: 800px;
|
||||||
color: var(--txt-2);
|
margin-left: auto;
|
||||||
cursor: pointer;
|
margin-right: auto;
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
.signup-bar__close:hover { color: var(--txt); }
|
|
||||||
|
|
||||||
/* Mobile wizard */
|
/* Mobile wizard */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -1102,6 +1110,7 @@ textarea.wiz-input {
|
|||||||
.wizard-step { max-width: 100%; }
|
.wizard-step { max-width: 100%; }
|
||||||
.wizard-preview,
|
.wizard-preview,
|
||||||
.wizard-nav { max-width: 100%; }
|
.wizard-nav { max-width: 100%; }
|
||||||
|
.wizard-step { padding-bottom: 100px; } /* space for sticky footer */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Computing indicator ── */
|
/* ── Computing indicator ── */
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const WIZ_STEPS = [
|
|||||||
{n:2, label:'Pricing'},
|
{n:2, label:'Pricing'},
|
||||||
{n:3, label:'Costs'},
|
{n:3, label:'Costs'},
|
||||||
{n:4, label:'Finance'},
|
{n:4, label:'Finance'},
|
||||||
{n:5, label:'Get Quotes'},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
@@ -243,13 +242,6 @@ function rebuildSpaceInputs(){
|
|||||||
h += slider('sqmPerDblOutdoor','Land m\u00B2 per Double Court',200,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
|
h += slider('sqmPerDblOutdoor','Land m\u00B2 per Double Court',200,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
|
||||||
slider('sqmPerSglOutdoor','Land m\u00B2 per Single Court',120,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
|
slider('sqmPerSglOutdoor','Land m\u00B2 per Single Court',120,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
|
||||||
}
|
}
|
||||||
h += '<div class="space-facts">';
|
|
||||||
h += '<div class="space-facts__title">Reference dimensions</div>';
|
|
||||||
h += '<div class="space-facts__item"><span>Double court playing area</span><span class="mono">20\u00D710m = 200 m\u00B2</span></div>';
|
|
||||||
h += '<div class="space-facts__item"><span>Single court playing area</span><span class="mono">20\u00D76m = 120 m\u00B2</span></div>';
|
|
||||||
h += '<div class="space-facts__item"><span>+ 2m buffer all around</span><span class="mono">24\u00D714m = 336 m\u00B2 / 24\u00D710m = 240 m\u00B2</span></div>';
|
|
||||||
h += '<div class="space-facts__item"><span>Min. ceiling height (indoor)</span><span class="mono">8\u201310m clear</span></div>';
|
|
||||||
h += '</div>';
|
|
||||||
$('#inp-space').innerHTML = h;
|
$('#inp-space').innerHTML = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +262,11 @@ function rebuildCapexInputs(){
|
|||||||
// Reset lightingType to led_standard if natural was selected but switched to indoor
|
// Reset lightingType to led_standard if natural was selected but switched to indoor
|
||||||
if(isIn && S.lightingType==='natural') S.lightingType='led_standard';
|
if(isIn && S.lightingType==='natural') S.lightingType='led_standard';
|
||||||
|
|
||||||
|
const lightTip = isIn
|
||||||
|
? 'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament/broadcast standards.'
|
||||||
|
: 'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament standards. Natural: no lighting cost, daylight only.';
|
||||||
let h = pillSelect('glassType','Glass Type',glassOpts,'Standard glass: \u20AC25\u201330K per court. Panoramic glass: \u20AC30\u201345K. Panoramic offers full visibility and premium feel.')+
|
let h = pillSelect('glassType','Glass Type',glassOpts,'Standard glass: \u20AC25\u201330K per court. Panoramic glass: \u20AC30\u201345K. Panoramic offers full visibility and premium feel.')+
|
||||||
pillSelect('lightingType','Lighting Type',lightOpts,'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament/broadcast standards. Natural: outdoor only, no lighting cost.')+
|
pillSelect('lightingType','Lighting Type',lightOpts,lightTip)+
|
||||||
slider('courtCostDbl','Court Cost \u2014 Double',0,80000,1000,fE,'Base price of one double padel court. The glass type multiplier is applied automatically.')+
|
slider('courtCostDbl','Court Cost \u2014 Double',0,80000,1000,fE,'Base price of one double padel court. The glass type multiplier is applied automatically.')+
|
||||||
slider('courtCostSgl','Court Cost \u2014 Single',0,60000,1000,fE,'Base price of one single padel court. Generally 60\u201370% of a double court cost.');
|
slider('courtCostSgl','Court Cost \u2014 Single',0,60000,1000,fE,'Base price of one single padel court. Generally 60\u201370% of a double court cost.');
|
||||||
if(isIn&&isBuy){
|
if(isIn&&isBuy){
|
||||||
@@ -373,6 +368,12 @@ function render(){
|
|||||||
const sb=$('#signupBar');
|
const sb=$('#signupBar');
|
||||||
if(sb) sb.style.display=activeTab!=='assumptions'?'flex':'none';
|
if(sb) sb.style.display=activeTab!=='assumptions'?'flex':'none';
|
||||||
|
|
||||||
|
// Show quote sidebar + inline CTA on all tabs
|
||||||
|
const qs=$('#quoteSidebar');
|
||||||
|
if(qs) qs.style.display='block';
|
||||||
|
const qi=$('#quoteInlineCta');
|
||||||
|
if(qi) qi.style.display='block';
|
||||||
|
|
||||||
// If we have cached data, render immediately with it
|
// If we have cached data, render immediately with it
|
||||||
if(_lastD) renderWith(_lastD);
|
if(_lastD) renderWith(_lastD);
|
||||||
|
|
||||||
@@ -764,8 +765,6 @@ function showWizStep(){
|
|||||||
buildWizardNav();
|
buildWizardNav();
|
||||||
renderWizNav();
|
renderWizNav();
|
||||||
if(_lastD) renderWizPreview();
|
if(_lastD) renderWizPreview();
|
||||||
// Auto-fill hidden fields when entering step 5
|
|
||||||
if(wizStep===5) populateWizAutoFill();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWizPreview(){
|
function renderWizPreview(){
|
||||||
@@ -804,126 +803,23 @@ function renderWizNav(){
|
|||||||
if(wizStep<4){
|
if(wizStep<4){
|
||||||
right=`<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">Next →</button>`;
|
right=`<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">Next →</button>`;
|
||||||
} else if(wizStep===4){
|
} else if(wizStep===4){
|
||||||
right=`<div class="wizard-nav__right">
|
right=`<button class="wiz-btn--next" onclick="activeTab='capex';render()">Show Results →</button>`;
|
||||||
<button class="wiz-skip" onclick="activeTab='capex';render()">Skip to Results</button>
|
|
||||||
<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">Next →</button>
|
|
||||||
</div>`;
|
|
||||||
} else if(wizStep===5){
|
|
||||||
right=`<div class="wizard-nav__right">
|
|
||||||
<button class="wiz-skip" onclick="activeTab='capex';render()">Skip to Results</button>
|
|
||||||
<button class="wiz-btn--submit" onclick="submitQuote()">Submit & Get Quotes →</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML=left+right;
|
el.innerHTML=left+right;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNTRY_NAMES = {
|
// ── Navigate to standalone quote form ─────────────────────
|
||||||
DE:'Germany',ES:'Spain',IT:'Italy',FR:'France',NL:'Netherlands',
|
function goToQuoteForm(){
|
||||||
SE:'Sweden',UK:'United Kingdom',US:'United States'
|
const p = new URLSearchParams({
|
||||||
};
|
|
||||||
|
|
||||||
function populateWizAutoFill(){
|
|
||||||
// Set hidden inputs
|
|
||||||
const ct = S.dblCourts+S.sglCourts;
|
|
||||||
const el=v=>document.getElementById(v);
|
|
||||||
if(el('wiz_facility_type')) el('wiz_facility_type').value=S.venue;
|
|
||||||
if(el('wiz_court_count')) el('wiz_court_count').value=ct;
|
|
||||||
if(el('wiz_glass_type')) el('wiz_glass_type').value=S.glassType;
|
|
||||||
if(el('wiz_lighting_type')) el('wiz_lighting_type').value=S.lightingType;
|
|
||||||
if(el('wiz_country')) el('wiz_country').value=S.country;
|
|
||||||
if(el('wiz_budget')) el('wiz_budget').value=S.budgetTarget||'';
|
|
||||||
|
|
||||||
// Render auto-fill summary
|
|
||||||
const summary=$('#wizAutoSummary');
|
|
||||||
if(summary){
|
|
||||||
summary.innerHTML=`<dl style="display:grid;grid-template-columns:1fr 1fr;gap:2px 1rem;margin:0">
|
|
||||||
<dt>Facility</dt><dd>${S.venue==='indoor'?'Indoor':'Outdoor'} (${S.own==='buy'?'Buy':'Rent'})</dd>
|
|
||||||
<dt>Courts</dt><dd>${ct} (${S.dblCourts} double + ${S.sglCourts} single)</dd>
|
|
||||||
<dt>Glass</dt><dd>${S.glassType==='panoramic'?'Panoramic':'Standard'}</dd>
|
|
||||||
<dt>Lighting</dt><dd>${S.lightingType.replace(/_/g,' ')}</dd>
|
|
||||||
<dt>Country</dt><dd>${COUNTRY_NAMES[S.country]||S.country}</dd>
|
|
||||||
${S.budgetTarget?`<dt>Budget</dt><dd>${fmtK(S.budgetTarget)}</dd>`:''}
|
|
||||||
</dl>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitQuote(){
|
|
||||||
const form=document.getElementById('wizQuoteForm');
|
|
||||||
if(!form) return;
|
|
||||||
// Check required fields
|
|
||||||
const name=form.querySelector('[name="contact_name"]');
|
|
||||||
const email=form.querySelector('[name="contact_email"]');
|
|
||||||
const consent=form.querySelector('[name="consent"]');
|
|
||||||
if(!name.value.trim()||!email.value.trim()){
|
|
||||||
name.reportValidity(); email.reportValidity();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(!consent.checked){
|
|
||||||
consent.reportValidity();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect form data
|
|
||||||
const fd=new FormData(form);
|
|
||||||
const data={};
|
|
||||||
for(const [k,v] of fd.entries()){
|
|
||||||
if(k==='csrf_token') continue;
|
|
||||||
if(k==='services_needed'){
|
|
||||||
if(!data.services_needed) data.services_needed=[];
|
|
||||||
data.services_needed.push(v);
|
|
||||||
} else {
|
|
||||||
data[k]=v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure services_needed is array
|
|
||||||
if(data.services_needed&&!Array.isArray(data.services_needed)) data.services_needed=[data.services_needed];
|
|
||||||
|
|
||||||
const csrf=form.querySelector('[name="csrf_token"]')?.value;
|
|
||||||
const btn=document.querySelector('.wiz-btn--submit');
|
|
||||||
if(btn){ btn.disabled=true; btn.textContent='Submitting\u2026'; }
|
|
||||||
|
|
||||||
fetch(window.__PADELNOMICS_QUOTE_URL__||'/leads/quote',{
|
|
||||||
method:'POST',
|
|
||||||
headers:{
|
|
||||||
'Content-Type':'application/json',
|
|
||||||
'X-CSRF-Token':csrf,
|
|
||||||
},
|
|
||||||
body:JSON.stringify(data),
|
|
||||||
})
|
|
||||||
.then(r=>{
|
|
||||||
if(!r.ok&&r.status===422) return r.json().then(d=>{throw d});
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(resp=>{
|
|
||||||
if(resp.ok){
|
|
||||||
// Hide form, show success
|
|
||||||
form.style.display='none';
|
|
||||||
$('#wizAutoSummary').style.display='none';
|
|
||||||
document.getElementById('wizSuccess').style.display='block';
|
|
||||||
// Hide nav buttons
|
|
||||||
$('#wizNav').innerHTML=`<div></div><button class="wiz-btn--next" onclick="activeTab='capex';render()">View Results →</button>`;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err=>{
|
|
||||||
if(btn){ btn.disabled=false; btn.textContent='Submit & Get Quotes \u2192'; }
|
|
||||||
if(err&&err.errors){
|
|
||||||
alert(err.errors.join('\n'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quote URL builder ─────────────────────────────────────
|
|
||||||
function getQuoteUrl(){
|
|
||||||
const base = window.__PADELNOMICS_QUOTE_URL__ || '/leads/quote';
|
|
||||||
return base+'?'+new URLSearchParams({
|
|
||||||
venue: S.venue,
|
venue: S.venue,
|
||||||
courts: S.dblCourts + S.sglCourts,
|
courts: S.dblCourts + S.sglCourts,
|
||||||
glass: S.glassType,
|
glass: S.glassType,
|
||||||
lighting: S.lightingType,
|
lighting: S.lightingType,
|
||||||
budget:S.budgetTarget||'',
|
|
||||||
country: S.country,
|
country: S.country,
|
||||||
}).toString();
|
});
|
||||||
|
if(S.budgetTarget) p.set('budget', S.budgetTarget);
|
||||||
|
window.location.href = '/leads/quote?' + p.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────
|
||||||
@@ -938,6 +834,9 @@ if(_lastD){
|
|||||||
// Update tab visibility
|
// Update tab visibility
|
||||||
$$('.tab-btn').forEach(b=>b.classList.toggle('tab-btn--active', b.dataset.tab===activeTab));
|
$$('.tab-btn').forEach(b=>b.classList.toggle('tab-btn--active', b.dataset.tab===activeTab));
|
||||||
$$('.tab').forEach(t=>t.classList.toggle('active',t.id===`tab-${activeTab}`));
|
$$('.tab').forEach(t=>t.classList.toggle('active',t.id===`tab-${activeTab}`));
|
||||||
|
// Show CTAs
|
||||||
|
const _qs=$('#quoteSidebar'); if(_qs) _qs.style.display='block';
|
||||||
|
const _qi=$('#quoteInlineCta'); if(_qi) _qi.style.display='block';
|
||||||
} else {
|
} else {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|||||||
0
padelnomics/src/padelnomics/suppliers/__init__.py
Normal file
0
padelnomics/src/padelnomics/suppliers/__init__.py
Normal file
670
padelnomics/src/padelnomics/suppliers/routes.py
Normal file
670
padelnomics/src/padelnomics/suppliers/routes.py
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
"""
|
||||||
|
Suppliers domain: signup wizard, lead feed, dashboard, and supplier-facing features.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, get_paddle_price
|
||||||
|
|
||||||
|
bp = Blueprint(
|
||||||
|
"suppliers",
|
||||||
|
__name__,
|
||||||
|
template_folder=str(Path(__file__).parent / "templates"),
|
||||||
|
url_prefix="/suppliers",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
PLAN_FEATURES = {
|
||||||
|
"supplier_growth": {
|
||||||
|
"name": "Growth",
|
||||||
|
"price": 149,
|
||||||
|
"monthly_credits": 30,
|
||||||
|
"features": [
|
||||||
|
"Company name & category badge",
|
||||||
|
"City & country shown",
|
||||||
|
"Description (3 lines)",
|
||||||
|
"\"Growth\" badge",
|
||||||
|
"Priority over free listings",
|
||||||
|
"30 lead credits/month",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"supplier_pro": {
|
||||||
|
"name": "Pro",
|
||||||
|
"price": 399,
|
||||||
|
"monthly_credits": 100,
|
||||||
|
"features": [
|
||||||
|
"Everything in Growth",
|
||||||
|
"Company logo displayed",
|
||||||
|
"Full description",
|
||||||
|
"Website link shown",
|
||||||
|
"Verified badge",
|
||||||
|
"Priority placement",
|
||||||
|
"Highlighted card border",
|
||||||
|
"100 lead credits/month",
|
||||||
|
],
|
||||||
|
"includes": ["logo", "highlight", "verified"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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"},
|
||||||
|
]
|
||||||
|
|
||||||
|
CREDIT_PACK_OPTIONS = [
|
||||||
|
{"key": "credits_25", "amount": 25, "price": 99},
|
||||||
|
{"key": "credits_50", "amount": 50, "price": 179},
|
||||||
|
{"key": "credits_100", "amount": 100, "price": 329},
|
||||||
|
{"key": "credits_250", "amount": 250, "price": 749},
|
||||||
|
]
|
||||||
|
|
||||||
|
SERVICE_CATEGORIES = [
|
||||||
|
"manufacturer", "turnkey", "consultant", "hall_builder",
|
||||||
|
"turf", "lighting", "software", "industry_body", "franchise",
|
||||||
|
]
|
||||||
|
|
||||||
|
COUNTRIES = [
|
||||||
|
"DE", "ES", "IT", "FR", "PT", "GB", "NL", "BE", "SE", "DK", "FI",
|
||||||
|
"NO", "AT", "SI", "IS", "CH", "EE", "US", "CA", "MX", "BR", "AR",
|
||||||
|
"AE", "SA", "TR", "CN", "IN", "SG", "ID", "TH", "AU", "ZA", "EG",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_accumulated(form_or_args):
|
||||||
|
"""Parse accumulated JSON from form data or query args."""
|
||||||
|
raw = form_or_args.get("_accumulated", "{}")
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_supplier_for_user(user_id: int):
|
||||||
|
"""Get the supplier record claimed by a user."""
|
||||||
|
return fetch_one(
|
||||||
|
"SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Auth decorator
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _supplier_required(f):
|
||||||
|
"""Require authenticated user with a claimed supplier on a paid tier."""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
async def decorated(*args, **kwargs):
|
||||||
|
if not g.get("user"):
|
||||||
|
await flash("Please sign in to continue.", "warning")
|
||||||
|
return redirect(url_for("auth.login", next=request.path))
|
||||||
|
supplier = await fetch_one(
|
||||||
|
"SELECT * FROM suppliers WHERE claimed_by = ? AND tier IN ('growth', 'pro')",
|
||||||
|
(g.user["id"],),
|
||||||
|
)
|
||||||
|
if not supplier:
|
||||||
|
await flash("You need an active supplier plan to access this page.", "warning")
|
||||||
|
return redirect(url_for("suppliers.signup"))
|
||||||
|
g.supplier = supplier
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Signup Wizard
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/signup")
|
||||||
|
async def signup():
|
||||||
|
"""Render signup wizard shell with step 1."""
|
||||||
|
claim_slug = request.args.get("claim", "")
|
||||||
|
prefill = {}
|
||||||
|
if claim_slug:
|
||||||
|
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (claim_slug,))
|
||||||
|
if supplier:
|
||||||
|
prefill = {
|
||||||
|
"claim_slug": claim_slug,
|
||||||
|
"supplier_id": supplier["id"],
|
||||||
|
"supplier_name": supplier["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/signup.html",
|
||||||
|
data=prefill,
|
||||||
|
step=1,
|
||||||
|
plans=PLAN_FEATURES,
|
||||||
|
boosts=BOOST_OPTIONS,
|
||||||
|
credit_packs=CREDIT_PACK_OPTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/signup/step/<int:step>", methods=["POST"])
|
||||||
|
@csrf_protect
|
||||||
|
async def signup_step(step: int):
|
||||||
|
"""HTMX endpoint — validate current step and return next step partial."""
|
||||||
|
form = await request.form
|
||||||
|
accumulated = _parse_accumulated(form)
|
||||||
|
|
||||||
|
# Merge current step's fields
|
||||||
|
for k, v in form.items():
|
||||||
|
if k.startswith("_") or k == "csrf_token":
|
||||||
|
continue
|
||||||
|
accumulated[k] = v
|
||||||
|
|
||||||
|
# Handle multi-value checkboxes
|
||||||
|
boosts = form.getlist("boosts")
|
||||||
|
if boosts:
|
||||||
|
accumulated["boosts"] = boosts
|
||||||
|
|
||||||
|
next_step = step + 1
|
||||||
|
if next_step > 4:
|
||||||
|
next_step = 4
|
||||||
|
|
||||||
|
# Determine included boosts from plan
|
||||||
|
plan = accumulated.get("plan", "supplier_growth")
|
||||||
|
plan_info = PLAN_FEATURES.get(plan, PLAN_FEATURES["supplier_growth"])
|
||||||
|
included_boosts = plan_info.get("includes", [])
|
||||||
|
|
||||||
|
# Compute order summary for step 4
|
||||||
|
order = _compute_order(accumulated, included_boosts)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
f"suppliers/partials/signup_step_{next_step}.html",
|
||||||
|
data=accumulated,
|
||||||
|
step=next_step,
|
||||||
|
plans=PLAN_FEATURES,
|
||||||
|
boosts=BOOST_OPTIONS,
|
||||||
|
credit_packs=CREDIT_PACK_OPTIONS,
|
||||||
|
included_boosts=included_boosts,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_order(data: dict, included_boosts: list) -> dict:
|
||||||
|
"""Compute order summary from accumulated wizard state."""
|
||||||
|
plan = data.get("plan", "supplier_growth")
|
||||||
|
plan_info = PLAN_FEATURES.get(plan, PLAN_FEATURES["supplier_growth"])
|
||||||
|
monthly = plan_info["price"]
|
||||||
|
one_time = 0
|
||||||
|
|
||||||
|
selected_boosts = data.get("boosts", [])
|
||||||
|
boost_monthly = 0
|
||||||
|
for b in BOOST_OPTIONS:
|
||||||
|
if b["type"] in selected_boosts and b["type"] not in included_boosts:
|
||||||
|
boost_monthly += b["price"]
|
||||||
|
|
||||||
|
monthly += boost_monthly
|
||||||
|
|
||||||
|
credit_pack = data.get("credit_pack", "")
|
||||||
|
for cp in CREDIT_PACK_OPTIONS:
|
||||||
|
if cp["key"] == credit_pack:
|
||||||
|
one_time += cp["price"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plan_name": plan_info["name"],
|
||||||
|
"plan_price": plan_info["price"],
|
||||||
|
"boost_monthly": boost_monthly,
|
||||||
|
"monthly_total": monthly,
|
||||||
|
"one_time_total": one_time,
|
||||||
|
"credit_pack": credit_pack,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/signup/checkout", methods=["POST"])
|
||||||
|
@csrf_protect
|
||||||
|
async def signup_checkout():
|
||||||
|
"""Validate form, return JSON for Paddle.js overlay checkout."""
|
||||||
|
form = await request.form
|
||||||
|
accumulated = _parse_accumulated(form)
|
||||||
|
|
||||||
|
# Merge final step fields
|
||||||
|
for k, v in form.items():
|
||||||
|
if k.startswith("_") or k == "csrf_token":
|
||||||
|
continue
|
||||||
|
accumulated[k] = v
|
||||||
|
|
||||||
|
plan = accumulated.get("plan", "supplier_growth")
|
||||||
|
plan_price_id = await get_paddle_price(plan)
|
||||||
|
if not plan_price_id:
|
||||||
|
return jsonify({"error": "Invalid plan selected. Run setup_paddle first."}), 400
|
||||||
|
|
||||||
|
# Build items list
|
||||||
|
items = [{"priceId": plan_price_id, "quantity": 1}]
|
||||||
|
|
||||||
|
# Add boost add-ons
|
||||||
|
plan_info = PLAN_FEATURES.get(plan, {})
|
||||||
|
included_boosts = plan_info.get("includes", [])
|
||||||
|
selected_boosts = accumulated.get("boosts", [])
|
||||||
|
if isinstance(selected_boosts, str):
|
||||||
|
selected_boosts = [selected_boosts]
|
||||||
|
|
||||||
|
for b in BOOST_OPTIONS:
|
||||||
|
if b["type"] in selected_boosts and b["type"] not in included_boosts:
|
||||||
|
price_id = await get_paddle_price(b["key"])
|
||||||
|
if price_id:
|
||||||
|
items.append({"priceId": price_id, "quantity": 1})
|
||||||
|
|
||||||
|
# Add credit pack (one-time)
|
||||||
|
credit_pack = accumulated.get("credit_pack", "")
|
||||||
|
if credit_pack:
|
||||||
|
price_id = await get_paddle_price(credit_pack)
|
||||||
|
if price_id:
|
||||||
|
items.append({"priceId": price_id, "quantity": 1})
|
||||||
|
|
||||||
|
# Get or create user
|
||||||
|
email = accumulated.get("contact_email", "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
return jsonify({"error": "Email is required."}), 400
|
||||||
|
|
||||||
|
from ..auth.routes import create_user, get_user_by_email
|
||||||
|
user = await get_user_by_email(email)
|
||||||
|
if not user:
|
||||||
|
user_id = await create_user(email)
|
||||||
|
else:
|
||||||
|
user_id = user["id"]
|
||||||
|
|
||||||
|
# Resolve supplier_id
|
||||||
|
supplier_id = accumulated.get("supplier_id", "")
|
||||||
|
if not supplier_id:
|
||||||
|
claim_slug = accumulated.get("claim_slug", "")
|
||||||
|
if claim_slug:
|
||||||
|
sup = await fetch_one("SELECT id FROM suppliers WHERE slug = ?", (claim_slug,))
|
||||||
|
if sup:
|
||||||
|
supplier_id = str(sup["id"])
|
||||||
|
|
||||||
|
# Save profile fields on supplier (if claiming)
|
||||||
|
if supplier_id:
|
||||||
|
sid = int(supplier_id)
|
||||||
|
await execute(
|
||||||
|
"""UPDATE suppliers SET
|
||||||
|
contact_name = ?, contact_email = ?, contact_phone = ?,
|
||||||
|
short_description = ?, service_categories = ?, service_area = ?,
|
||||||
|
years_in_business = ?, project_count = ?
|
||||||
|
WHERE id = ? AND claimed_by IS NULL""",
|
||||||
|
(
|
||||||
|
accumulated.get("contact_name", ""),
|
||||||
|
email,
|
||||||
|
accumulated.get("contact_phone", ""),
|
||||||
|
accumulated.get("short_description", ""),
|
||||||
|
accumulated.get("service_categories", ""),
|
||||||
|
accumulated.get("service_area", ""),
|
||||||
|
int(accumulated.get("years_in_business", 0) or 0),
|
||||||
|
int(accumulated.get("project_count", 0) or 0),
|
||||||
|
sid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_data = {
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"supplier_id": str(supplier_id) if supplier_id else "",
|
||||||
|
"plan": plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"items": items,
|
||||||
|
"customData": custom_data,
|
||||||
|
"settings": {
|
||||||
|
"successUrl": f"{config.BASE_URL}/suppliers/signup/success",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/claim/<slug>")
|
||||||
|
async def claim(slug: str):
|
||||||
|
"""Verify supplier is unclaimed and redirect to signup with pre-fill."""
|
||||||
|
supplier = await fetch_one(
|
||||||
|
"SELECT * FROM suppliers WHERE slug = ? AND claimed_by IS NULL", (slug,)
|
||||||
|
)
|
||||||
|
if not supplier:
|
||||||
|
await flash("This listing has already been claimed or does not exist.", "warning")
|
||||||
|
return redirect(url_for("directory.index"))
|
||||||
|
return redirect(url_for("suppliers.signup", claim=slug))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/signup/success")
|
||||||
|
async def signup_success():
|
||||||
|
"""Post-checkout confirmation page."""
|
||||||
|
return await render_template("suppliers/signup_success.html")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Supplier Lead Feed
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def _get_lead_feed_data(supplier, country="", heat="", timeline="", limit=50):
|
||||||
|
"""Shared query for lead feed — used by standalone and dashboard."""
|
||||||
|
wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if country:
|
||||||
|
wheres.append("lr.country = ?")
|
||||||
|
params.append(country)
|
||||||
|
if heat:
|
||||||
|
wheres.append("lr.heat_score = ?")
|
||||||
|
params.append(heat)
|
||||||
|
if timeline:
|
||||||
|
wheres.append("lr.timeline = ?")
|
||||||
|
params.append(timeline)
|
||||||
|
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
leads = await fetch_all(
|
||||||
|
f"""SELECT lr.*,
|
||||||
|
EXISTS(SELECT 1 FROM lead_forwards lf WHERE lf.lead_id = lr.id AND lf.supplier_id = ?) as is_unlocked,
|
||||||
|
(SELECT COUNT(*) FROM lead_forwards lf2 WHERE lf2.lead_id = lr.id) as bidder_count
|
||||||
|
FROM lead_requests lr
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY lr.created_at DESC LIMIT ?""",
|
||||||
|
(supplier["id"], *params),
|
||||||
|
)
|
||||||
|
|
||||||
|
countries = await fetch_all(
|
||||||
|
"SELECT DISTINCT country FROM lead_requests WHERE country IS NOT NULL AND country != '' AND lead_type = 'quote' ORDER BY country"
|
||||||
|
)
|
||||||
|
|
||||||
|
return leads, [c["country"] for c in countries]
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads")
|
||||||
|
@_supplier_required
|
||||||
|
async def lead_feed():
|
||||||
|
"""Lead feed for paying suppliers (standalone page)."""
|
||||||
|
supplier = g.supplier
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
heat = request.args.get("heat", "")
|
||||||
|
|
||||||
|
leads, countries = await _get_lead_feed_data(supplier, country, heat)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/lead_feed.html",
|
||||||
|
leads=leads,
|
||||||
|
supplier=supplier,
|
||||||
|
countries=countries,
|
||||||
|
current_country=country,
|
||||||
|
current_heat=heat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/<int:lead_id>/unlock", methods=["POST"])
|
||||||
|
@_supplier_required
|
||||||
|
@csrf_protect
|
||||||
|
async def unlock_lead(lead_id: int):
|
||||||
|
"""Spend credits to unlock a lead. Returns full-details card via HTMX."""
|
||||||
|
from ..credits import InsufficientCredits
|
||||||
|
from ..credits import unlock_lead as do_unlock
|
||||||
|
|
||||||
|
supplier = g.supplier
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await do_unlock(supplier["id"], lead_id)
|
||||||
|
except InsufficientCredits as e:
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/lead_card_error.html",
|
||||||
|
error=f"Not enough credits. You have {e.balance}, need {e.required}.",
|
||||||
|
lead_id=lead_id,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/lead_card_error.html",
|
||||||
|
error=str(e),
|
||||||
|
lead_id=lead_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue lead forward email
|
||||||
|
from ..worker import enqueue
|
||||||
|
await enqueue("send_lead_forward_email", {
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"supplier_id": supplier["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Notify entrepreneur on first unlock
|
||||||
|
lead = result["lead"]
|
||||||
|
if lead.get("unlock_count", 0) <= 1:
|
||||||
|
await enqueue("send_lead_matched_notification", {"lead_id": lead_id})
|
||||||
|
|
||||||
|
# Return full details card
|
||||||
|
full_lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||||
|
updated_supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier["id"],))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/lead_card_unlocked.html",
|
||||||
|
lead=full_lead,
|
||||||
|
supplier=updated_supplier,
|
||||||
|
credit_cost=result["credit_cost"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Supplier Dashboard
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/dashboard")
|
||||||
|
@_supplier_required
|
||||||
|
async def dashboard():
|
||||||
|
"""Dashboard shell — defaults to overview tab."""
|
||||||
|
supplier = g.supplier
|
||||||
|
tab = request.args.get("tab", "overview")
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/dashboard.html",
|
||||||
|
supplier=supplier,
|
||||||
|
active_tab=tab,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/overview")
|
||||||
|
@_supplier_required
|
||||||
|
async def dashboard_overview():
|
||||||
|
"""HTMX partial — overview tab content."""
|
||||||
|
supplier = g.supplier
|
||||||
|
|
||||||
|
# Leads unlocked count
|
||||||
|
unlocked = await fetch_one(
|
||||||
|
"SELECT COUNT(*) as cnt FROM lead_forwards WHERE supplier_id = ?",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
# New leads matching supplier's area since last login
|
||||||
|
service_area = (supplier.get("service_area") or "").split(",")
|
||||||
|
service_area = [c.strip() for c in service_area if c.strip()]
|
||||||
|
new_leads_count = 0
|
||||||
|
if service_area:
|
||||||
|
placeholders = ",".join("?" * len(service_area))
|
||||||
|
row = await fetch_one(
|
||||||
|
f"""SELECT COUNT(*) as cnt FROM lead_requests
|
||||||
|
WHERE lead_type = 'quote' AND status = 'new' AND verified_at IS NOT NULL
|
||||||
|
AND country IN ({placeholders})
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM lead_forwards WHERE lead_id = lead_requests.id AND supplier_id = ?)""",
|
||||||
|
(*service_area, supplier["id"]),
|
||||||
|
)
|
||||||
|
new_leads_count = row["cnt"] if row else 0
|
||||||
|
else:
|
||||||
|
row = await fetch_one(
|
||||||
|
"""SELECT COUNT(*) as cnt FROM lead_requests
|
||||||
|
WHERE lead_type = 'quote' AND status = 'new' AND verified_at IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM lead_forwards WHERE lead_id = lead_requests.id AND supplier_id = ?)""",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
new_leads_count = row["cnt"] if row else 0
|
||||||
|
|
||||||
|
# Recent activity (last 10 events from credit_ledger + lead_forwards)
|
||||||
|
recent_activity = await fetch_all(
|
||||||
|
"""SELECT 'credit' as type, event_type, delta, note, created_at
|
||||||
|
FROM credit_ledger WHERE supplier_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'forward' as type, 'lead_unlock' as event_type, -credit_cost as delta,
|
||||||
|
'Lead #' || lead_id as note, created_at
|
||||||
|
FROM lead_forwards WHERE supplier_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT 10""",
|
||||||
|
(supplier["id"], supplier["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Active boosts
|
||||||
|
active_boosts = await fetch_all(
|
||||||
|
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND status = 'active'",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/dashboard_overview.html",
|
||||||
|
supplier=supplier,
|
||||||
|
leads_unlocked=unlocked["cnt"] if unlocked else 0,
|
||||||
|
new_leads_count=new_leads_count,
|
||||||
|
recent_activity=recent_activity,
|
||||||
|
active_boosts=active_boosts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/leads")
|
||||||
|
@_supplier_required
|
||||||
|
async def dashboard_leads():
|
||||||
|
"""HTMX partial — lead feed tab in dashboard context."""
|
||||||
|
supplier = g.supplier
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
heat = request.args.get("heat", "")
|
||||||
|
timeline = request.args.get("timeline", "")
|
||||||
|
|
||||||
|
leads, countries = await _get_lead_feed_data(supplier, country, heat, timeline)
|
||||||
|
|
||||||
|
# Parse supplier's service area for region matching
|
||||||
|
service_area = [c.strip() for c in (supplier.get("service_area") or "").split(",") if c.strip()]
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/dashboard_leads.html",
|
||||||
|
leads=leads,
|
||||||
|
supplier=supplier,
|
||||||
|
countries=countries,
|
||||||
|
current_country=country,
|
||||||
|
current_heat=heat,
|
||||||
|
current_timeline=timeline,
|
||||||
|
service_area=service_area,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/listing", methods=["GET"])
|
||||||
|
@_supplier_required
|
||||||
|
async def dashboard_listing():
|
||||||
|
"""HTMX partial — My Listing tab."""
|
||||||
|
supplier = g.supplier
|
||||||
|
|
||||||
|
# Get active boosts for preview
|
||||||
|
boosts = await fetch_all(
|
||||||
|
"SELECT boost_type FROM supplier_boosts WHERE supplier_id = ? AND status = 'active'",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
active_boosts = [b["boost_type"] for b in boosts]
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/dashboard_listing.html",
|
||||||
|
supplier=supplier,
|
||||||
|
active_boosts=active_boosts,
|
||||||
|
service_categories=SERVICE_CATEGORIES,
|
||||||
|
countries=COUNTRIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/listing", methods=["POST"])
|
||||||
|
@_supplier_required
|
||||||
|
@csrf_protect
|
||||||
|
async def dashboard_listing_save():
|
||||||
|
"""Save listing edits, return updated partial."""
|
||||||
|
supplier = g.supplier
|
||||||
|
form = await request.form
|
||||||
|
|
||||||
|
# Handle file upload
|
||||||
|
files = await request.files
|
||||||
|
logo_file = files.get("logo_file")
|
||||||
|
logo_path = supplier.get("logo_file") or ""
|
||||||
|
if logo_file and logo_file.filename:
|
||||||
|
upload_dir = Path(__file__).parent.parent / "static" / "uploads" / "logos"
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = secure_filename(f"{supplier['id']}_{logo_file.filename}")
|
||||||
|
save_path = upload_dir / filename
|
||||||
|
await logo_file.save(str(save_path))
|
||||||
|
logo_path = f"/static/uploads/logos/{filename}"
|
||||||
|
|
||||||
|
# Multi-select categories
|
||||||
|
categories = form.getlist("service_categories")
|
||||||
|
categories_str = ",".join(categories)
|
||||||
|
|
||||||
|
# Multi-select service area
|
||||||
|
areas = form.getlist("service_area")
|
||||||
|
area_str = ",".join(areas)
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""UPDATE suppliers SET
|
||||||
|
name = ?, tagline = ?, short_description = ?, long_description = ?,
|
||||||
|
website = ?, contact_name = ?, contact_email = ?, contact_phone = ?,
|
||||||
|
service_categories = ?, service_area = ?,
|
||||||
|
years_in_business = ?, project_count = ?,
|
||||||
|
logo_file = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(
|
||||||
|
form.get("name", supplier["name"]),
|
||||||
|
form.get("tagline", ""),
|
||||||
|
form.get("short_description", ""),
|
||||||
|
form.get("long_description", ""),
|
||||||
|
form.get("website", ""),
|
||||||
|
form.get("contact_name", ""),
|
||||||
|
form.get("contact_email", ""),
|
||||||
|
form.get("contact_phone", ""),
|
||||||
|
categories_str,
|
||||||
|
area_str,
|
||||||
|
int(form.get("years_in_business", 0) or 0),
|
||||||
|
int(form.get("project_count", 0) or 0),
|
||||||
|
logo_path,
|
||||||
|
supplier["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reload supplier and boosts
|
||||||
|
updated_supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier["id"],))
|
||||||
|
boosts = await fetch_all(
|
||||||
|
"SELECT boost_type FROM supplier_boosts WHERE supplier_id = ? AND status = 'active'",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
active_boosts = [b["boost_type"] for b in boosts]
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/dashboard_listing.html",
|
||||||
|
supplier=updated_supplier,
|
||||||
|
active_boosts=active_boosts,
|
||||||
|
service_categories=SERVICE_CATEGORIES,
|
||||||
|
countries=COUNTRIES,
|
||||||
|
saved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/boosts")
|
||||||
|
@_supplier_required
|
||||||
|
async def dashboard_boosts():
|
||||||
|
"""HTMX partial — Boost & Upsells tab."""
|
||||||
|
supplier = g.supplier
|
||||||
|
|
||||||
|
active_boosts = await fetch_all(
|
||||||
|
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND status = 'active'",
|
||||||
|
(supplier["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"suppliers/partials/dashboard_boosts.html",
|
||||||
|
supplier=supplier,
|
||||||
|
active_boosts=active_boosts,
|
||||||
|
boost_options=BOOST_OPTIONS,
|
||||||
|
credit_packs=CREDIT_PACK_OPTIONS,
|
||||||
|
plan_features=PLAN_FEATURES,
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Supplier Dashboard - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.dash { display: flex; min-height: calc(100vh - 64px); }
|
||||||
|
.dash-sidebar {
|
||||||
|
width: 240px; flex-shrink: 0; background: #F8FAFC; border-right: 1px solid #E2E8F0;
|
||||||
|
padding: 1.5rem 0; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.dash-sidebar__header { padding: 0 1.25rem 1.25rem; border-bottom: 1px solid #E2E8F0; }
|
||||||
|
.dash-sidebar__name { font-size: 0.9375rem; font-weight: 700; color: #1E293B; }
|
||||||
|
.dash-sidebar__tier {
|
||||||
|
display: inline-block; font-size: 0.625rem; font-weight: 700; padding: 2px 8px;
|
||||||
|
border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 4px;
|
||||||
|
}
|
||||||
|
.dash-sidebar__tier--growth { background: #DBEAFE; color: #1D4ED8; }
|
||||||
|
.dash-sidebar__tier--pro { background: #DCFCE7; color: #16A34A; }
|
||||||
|
|
||||||
|
.dash-nav { flex: 1; padding: 1rem 0; }
|
||||||
|
.dash-nav a {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 1.25rem; font-size: 0.8125rem; color: #64748B;
|
||||||
|
text-decoration: none; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.dash-nav a:hover { background: #EFF6FF; color: #1D4ED8; }
|
||||||
|
.dash-nav a.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #1D4ED8; }
|
||||||
|
|
||||||
|
.dash-sidebar__footer {
|
||||||
|
padding: 1rem 1.25rem; border-top: 1px solid #E2E8F0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.dash-sidebar__credits {
|
||||||
|
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 8px;
|
||||||
|
padding: 8px 12px; color: #1D4ED8; font-weight: 600; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-main { flex: 1; padding: 2rem; overflow-y: auto; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dash { flex-direction: column; }
|
||||||
|
.dash-sidebar { width: 100%; flex-direction: row; align-items: center; padding: 0.75rem; overflow-x: auto; }
|
||||||
|
.dash-sidebar__header { display: none; }
|
||||||
|
.dash-sidebar__footer { display: none; }
|
||||||
|
.dash-nav { display: flex; flex: none; padding: 0; gap: 4px; }
|
||||||
|
.dash-nav a { padding: 8px 12px; white-space: nowrap; border-right: none !important; border-radius: 6px; }
|
||||||
|
.dash-main { padding: 1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dash">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="dash-sidebar">
|
||||||
|
<div class="dash-sidebar__header">
|
||||||
|
<div class="dash-sidebar__name">{{ supplier.name }}</div>
|
||||||
|
<span class="dash-sidebar__tier dash-sidebar__tier--{{ supplier.tier }}">{{ supplier.tier | upper }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="dash-nav">
|
||||||
|
<a href="{{ url_for('suppliers.dashboard', tab='overview') }}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_overview') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="{{ url_for('suppliers.dashboard', tab='overview') }}"
|
||||||
|
class="{% if active_tab == 'overview' %}active{% endif %}">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('suppliers.dashboard', tab='leads') }}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="{{ url_for('suppliers.dashboard', tab='leads') }}"
|
||||||
|
class="{% if active_tab == 'leads' %}active{% endif %}">
|
||||||
|
Lead Feed
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('suppliers.dashboard', tab='listing') }}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_listing') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="{{ url_for('suppliers.dashboard', tab='listing') }}"
|
||||||
|
class="{% if active_tab == 'listing' %}active{% endif %}">
|
||||||
|
My Listing
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('suppliers.dashboard', tab='boosts') }}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_boosts') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="{{ url_for('suppliers.dashboard', tab='boosts') }}"
|
||||||
|
class="{% if active_tab == 'boosts' %}active{% endif %}">
|
||||||
|
Boost & Upsells
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="dash-sidebar__footer">
|
||||||
|
<div class="dash-sidebar__credits">
|
||||||
|
{{ supplier.credit_balance }} credits
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<main class="dash-main" id="dashboard-content"
|
||||||
|
hx-get="{% if active_tab == 'leads' %}{{ url_for('suppliers.dashboard_leads') }}{% elif active_tab == 'listing' %}{{ url_for('suppliers.dashboard_listing') }}{% elif active_tab == 'boosts' %}{{ url_for('suppliers.dashboard_boosts') }}{% else %}{{ url_for('suppliers.dashboard_overview') }}{% endif %}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div style="text-align:center;padding:3rem;color:#94A3B8">Loading...</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Lead Feed - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.lf-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||||
|
.lf-balance {
|
||||||
|
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 10px;
|
||||||
|
padding: 8px 16px; font-size: 0.8125rem; color: #1D4ED8; font-weight: 600;
|
||||||
|
}
|
||||||
|
.lf-filters { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||||
|
.lf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||||
|
.lf-card {
|
||||||
|
background: white; border: 1px solid #E2E8F0; border-radius: 14px;
|
||||||
|
padding: 1.25rem; transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.lf-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||||
|
.lf-card__heat {
|
||||||
|
display: inline-block; font-size: 0.625rem; font-weight: 700;
|
||||||
|
padding: 2px 8px; border-radius: 999px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.lf-card__heat--hot { background: #FEE2E2; color: #DC2626; }
|
||||||
|
.lf-card__heat--warm { background: #FEF3C7; color: #D97706; }
|
||||||
|
.lf-card__heat--cool { background: #E2E8F0; color: #64748B; }
|
||||||
|
.lf-card__meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin: 0.75rem 0; font-size: 0.8125rem; }
|
||||||
|
.lf-card__meta dt { color: #94A3B8; font-size: 0.6875rem; }
|
||||||
|
.lf-card__meta dd { color: #1E293B; font-weight: 500; margin: 0; }
|
||||||
|
.lf-card__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #F1F5F9; }
|
||||||
|
.lf-card__cost { font-size: 0.8125rem; color: #64748B; }
|
||||||
|
.lf-card__cost strong { color: #1D4ED8; }
|
||||||
|
.lf-card__unlocks { font-size: 0.6875rem; color: #94A3B8; }
|
||||||
|
.lf-unlock-btn {
|
||||||
|
padding: 8px 20px; font-size: 0.75rem; font-weight: 700; border: none;
|
||||||
|
background: #1D4ED8; color: white; border-radius: 8px; cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
.lf-unlock-btn:hover { background: #1E40AF; }
|
||||||
|
/* Unlocked card extras */
|
||||||
|
.lf-card--unlocked { border-color: #BBF7D0; background: #F0FDF4; }
|
||||||
|
.lf-contact { background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 8px; padding: 0.75rem; margin-top: 0.75rem; }
|
||||||
|
.lf-contact dt { color: #94A3B8; font-size: 0.6875rem; }
|
||||||
|
.lf-contact dd { color: #1E293B; font-weight: 500; margin: 0 0 4px; font-size: 0.8125rem; }
|
||||||
|
.lf-error { color: #DC2626; font-size: 0.8125rem; padding: 8px; background: #FEF2F2; border-radius: 8px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<div class="lf-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Lead Feed</h1>
|
||||||
|
<p class="text-sm text-slate">Browse and unlock qualified padel project leads.</p>
|
||||||
|
</div>
|
||||||
|
<div class="lf-balance">
|
||||||
|
{{ supplier.credit_balance }} credits available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lf-filters">
|
||||||
|
<select onchange="window.location='?country='+this.value+'&heat={{ current_heat }}'" class="form-input" style="min-width:120px">
|
||||||
|
<option value="">All countries</option>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select onchange="window.location='?country={{ current_country }}&heat='+this.value" class="form-input" style="min-width:100px">
|
||||||
|
<option value="">All heat</option>
|
||||||
|
<option value="hot" {% if current_heat == 'hot' %}selected{% endif %}>HOT</option>
|
||||||
|
<option value="warm" {% if current_heat == 'warm' %}selected{% endif %}>WARM</option>
|
||||||
|
<option value="cool" {% if current_heat == 'cool' %}selected{% endif %}>COOL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if leads %}
|
||||||
|
<div class="lf-grid">
|
||||||
|
{% for lead in leads %}
|
||||||
|
<div id="lead-card-{{ lead.id }}">
|
||||||
|
{% if lead.is_unlocked %}
|
||||||
|
{% include "suppliers/partials/lead_card_unlocked.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% include "suppliers/partials/lead_card.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card text-center" style="padding:3rem">
|
||||||
|
<h3 style="color:#64748B">No leads match your filters</h3>
|
||||||
|
<p style="color:#94A3B8;font-size:0.875rem">Try adjusting your country or heat filters, or check back later for new leads.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<style>
|
||||||
|
.bst-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1.5rem; }
|
||||||
|
.bst-section { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem; margin-bottom: 1rem; }
|
||||||
|
.bst-section h3 {
|
||||||
|
font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
color: #94A3B8; margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.bst-plan { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.bst-plan__name { font-size: 1.125rem; font-weight: 700; color: #1E293B; }
|
||||||
|
.bst-plan__price { font-size: 1.125rem; font-weight: 700; color: #1D4ED8; }
|
||||||
|
.bst-plan__price span { font-size: 0.8125rem; font-weight: 400; color: #94A3B8; }
|
||||||
|
.bst-plan__credits { font-size: 0.8125rem; color: #64748B; margin-top: 4px; }
|
||||||
|
|
||||||
|
.bst-boost-card {
|
||||||
|
border: 1px solid #E2E8F0; border-radius: 10px; padding: 0.75rem 1rem;
|
||||||
|
display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.bst-boost-card--active { border-color: #BBF7D0; background: #F0FDF4; }
|
||||||
|
.bst-boost__name { font-size: 0.8125rem; font-weight: 600; color: #1E293B; }
|
||||||
|
.bst-boost__desc { font-size: 0.75rem; color: #64748B; }
|
||||||
|
.bst-boost__price { font-size: 0.8125rem; font-weight: 700; color: #1D4ED8; }
|
||||||
|
.bst-boost__status { font-size: 0.6875rem; font-weight: 700; color: #16A34A; }
|
||||||
|
|
||||||
|
.bst-credits-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; }
|
||||||
|
.bst-credit-card {
|
||||||
|
border: 1px solid #E2E8F0; border-radius: 10px; padding: 1rem; text-align: center;
|
||||||
|
}
|
||||||
|
.bst-credit-card__amount { font-size: 1.5rem; font-weight: 800; color: #1E293B; }
|
||||||
|
.bst-credit-card__label { font-size: 0.75rem; color: #64748B; }
|
||||||
|
.bst-credit-card__price { font-size: 0.9375rem; font-weight: 700; color: #1D4ED8; margin: 0.5rem 0; }
|
||||||
|
.bst-buy-btn {
|
||||||
|
display: inline-block; padding: 6px 16px; font-size: 0.75rem; font-weight: 600;
|
||||||
|
border: 1px solid #1D4ED8; color: #1D4ED8; border-radius: 6px;
|
||||||
|
background: white; cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
.bst-buy-btn:hover { background: #EFF6FF; }
|
||||||
|
|
||||||
|
.bst-sidebar { position: sticky; top: 1rem; }
|
||||||
|
.bst-summary { background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem; }
|
||||||
|
.bst-summary h3 { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94A3B8; margin-bottom: 0.75rem; }
|
||||||
|
.bst-summary__row { display: flex; justify-content: space-between; font-size: 0.8125rem; padding: 4px 0; }
|
||||||
|
.bst-summary__total { border-top: 2px solid #E2E8F0; padding-top: 0.5rem; margin-top: 0.5rem; font-weight: 700; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bst-layout { grid-template-columns: 1fr; }
|
||||||
|
.bst-credits-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% set active_boost_types = active_boosts | map(attribute='boost_type') | list %}
|
||||||
|
{% set plan_info = plan_features.get('supplier_' + supplier.tier, plan_features.get('supplier_growth')) %}
|
||||||
|
{% set monthly_plan = plan_info.price %}
|
||||||
|
{% set boost_monthly = 0 %}
|
||||||
|
|
||||||
|
<div class="bst-layout">
|
||||||
|
<div>
|
||||||
|
<!-- Current Plan -->
|
||||||
|
<div class="bst-section">
|
||||||
|
<h3>Current Plan</h3>
|
||||||
|
<div class="bst-plan">
|
||||||
|
<div>
|
||||||
|
<div class="bst-plan__name">{{ plan_info.name }}</div>
|
||||||
|
<div class="bst-plan__credits">{{ supplier.monthly_credits }} credits/month</div>
|
||||||
|
</div>
|
||||||
|
<div class="bst-plan__price">€{{ plan_info.price }} <span>/mo</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Boosts -->
|
||||||
|
<div class="bst-section">
|
||||||
|
<h3>Active Boosts</h3>
|
||||||
|
{% if active_boosts %}
|
||||||
|
{% for boost in active_boosts %}
|
||||||
|
<div class="bst-boost-card bst-boost-card--active">
|
||||||
|
<div>
|
||||||
|
<div class="bst-boost__name">{{ boost.boost_type | replace('_', ' ') | title }}</div>
|
||||||
|
{% if boost.expires_at %}
|
||||||
|
<div class="bst-boost__desc">Expires {{ boost.expires_at[:10] }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bst-boost__desc">Active subscription</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="bst-boost__status">Active</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color:#94A3B8;font-size:0.8125rem;text-align:center;padding:0.5rem 0">No active boosts</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Boosts -->
|
||||||
|
<div class="bst-section">
|
||||||
|
<h3>Available Boosts</h3>
|
||||||
|
{% for b in boost_options %}
|
||||||
|
{% if b.type not in active_boost_types %}
|
||||||
|
<div class="bst-boost-card">
|
||||||
|
<div>
|
||||||
|
<div class="bst-boost__name">{{ b.name }}</div>
|
||||||
|
<div class="bst-boost__desc">{{ b.desc }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div class="bst-boost__price">€{{ b.price }}/mo</div>
|
||||||
|
<button type="button" class="bst-buy-btn"
|
||||||
|
onclick="Paddle.Checkout.open({items:[{priceId:'{{ b.key }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credit Packs -->
|
||||||
|
<div class="bst-section">
|
||||||
|
<h3>Buy Credit Packs</h3>
|
||||||
|
<div class="bst-credits-grid">
|
||||||
|
{% for cp in credit_packs %}
|
||||||
|
<div class="bst-credit-card">
|
||||||
|
<div class="bst-credit-card__amount">{{ cp.amount }}</div>
|
||||||
|
<div class="bst-credit-card__label">credits</div>
|
||||||
|
<div class="bst-credit-card__price">€{{ cp.price }}</div>
|
||||||
|
<button type="button" class="bst-buy-btn"
|
||||||
|
onclick="Paddle.Checkout.open({items:[{priceId:'{{ cp.key }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
|
||||||
|
Buy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Sidebar -->
|
||||||
|
<div class="bst-sidebar">
|
||||||
|
<div class="bst-summary">
|
||||||
|
<h3>Summary</h3>
|
||||||
|
<div class="bst-summary__row">
|
||||||
|
<span>{{ plan_info.name }} plan</span>
|
||||||
|
<span>€{{ plan_info.price }}/mo</span>
|
||||||
|
</div>
|
||||||
|
{% for boost in active_boosts %}
|
||||||
|
{% if not boost.expires_at %}
|
||||||
|
<div class="bst-summary__row">
|
||||||
|
<span>{{ boost.boost_type | replace('_', ' ') | title }}</span>
|
||||||
|
<span>subscription</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="bst-summary__row bst-summary__total">
|
||||||
|
<span>Credits Balance</span>
|
||||||
|
<span style="color:#1D4ED8">{{ supplier.credit_balance }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<style>
|
||||||
|
.dl-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||||
|
.dl-balance {
|
||||||
|
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 10px;
|
||||||
|
padding: 8px 16px; font-size: 0.8125rem; color: #1D4ED8; font-weight: 600;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.dl-balance a { font-size: 0.75rem; color: #1D4ED8; text-decoration: underline; }
|
||||||
|
.dl-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||||
|
.dl-pill {
|
||||||
|
padding: 6px 14px; font-size: 0.75rem; font-weight: 600; border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 999px; background: white; cursor: pointer; color: #64748B;
|
||||||
|
text-decoration: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
.dl-pill:hover { border-color: #1D4ED8; color: #1D4ED8; }
|
||||||
|
.dl-pill--active { background: #1D4ED8; color: white; border-color: #1D4ED8; }
|
||||||
|
.dl-sep { width: 1px; height: 20px; background: #E2E8F0; margin: 0 4px; }
|
||||||
|
.dl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||||
|
.dl-badge-region {
|
||||||
|
display: inline-block; font-size: 0.5625rem; font-weight: 700; padding: 1px 6px;
|
||||||
|
border-radius: 999px; background: #DCFCE7; color: #16A34A; margin-left: 4px;
|
||||||
|
}
|
||||||
|
.dl-bidders { font-size: 0.6875rem; margin-top: 4px; }
|
||||||
|
.dl-bidders--first { color: #16A34A; font-weight: 600; }
|
||||||
|
.dl-bidders--many { color: #94A3B8; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="dl-top">
|
||||||
|
<h2 style="font-size:1.25rem;margin:0">Lead Feed</h2>
|
||||||
|
<div class="dl-balance">
|
||||||
|
{{ supplier.credit_balance }} credits
|
||||||
|
<a href="{{ url_for('suppliers.dashboard', tab='boosts') }}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_boosts') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="{{ url_for('suppliers.dashboard', tab='boosts') }}">Buy More</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-filters"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="false">
|
||||||
|
<!-- Heat filters -->
|
||||||
|
<a class="dl-pill {% if not current_heat %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', country=current_country, timeline=current_timeline) }}">All</a>
|
||||||
|
<a class="dl-pill {% if current_heat == 'hot' %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat='hot', country=current_country, timeline=current_timeline) }}">Hot</a>
|
||||||
|
<a class="dl-pill {% if current_heat == 'warm' %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat='warm', country=current_country, timeline=current_timeline) }}">Warm</a>
|
||||||
|
<a class="dl-pill {% if current_heat == 'cool' %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat='cool', country=current_country, timeline=current_timeline) }}">Cool</a>
|
||||||
|
|
||||||
|
<div class="dl-sep"></div>
|
||||||
|
|
||||||
|
<!-- Country filter -->
|
||||||
|
<select class="dl-pill" style="appearance:auto;padding-right:24px"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-include="[name='heat_hidden'],[name='timeline_hidden']"
|
||||||
|
name="country">
|
||||||
|
<option value="">All countries</option>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="hidden" name="heat_hidden" value="{{ current_heat }}">
|
||||||
|
<input type="hidden" name="timeline_hidden" value="{{ current_timeline }}">
|
||||||
|
|
||||||
|
<div class="dl-sep"></div>
|
||||||
|
|
||||||
|
<!-- Timeline filters -->
|
||||||
|
<a class="dl-pill {% if not current_timeline %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat=current_heat, country=current_country) }}">Any</a>
|
||||||
|
<a class="dl-pill {% if current_timeline == 'asap' %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat=current_heat, country=current_country, timeline='asap') }}">ASAP</a>
|
||||||
|
<a class="dl-pill {% if current_timeline == '3_6_months' %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat=current_heat, country=current_country, timeline='3_6_months') }}">3-6mo</a>
|
||||||
|
<a class="dl-pill {% if current_timeline == '6_12_months' %}dl-pill--active{% endif %}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads', heat=current_heat, country=current_country, timeline='6_12_months') }}">6-12mo</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if leads %}
|
||||||
|
<div class="dl-grid">
|
||||||
|
{% for lead in leads %}
|
||||||
|
<div id="lead-card-{{ lead.id }}">
|
||||||
|
{% if lead.is_unlocked %}
|
||||||
|
{% include "suppliers/partials/lead_card_unlocked.html" %}
|
||||||
|
{% else %}
|
||||||
|
{# Locked card with bidder info and region match #}
|
||||||
|
<div class="lf-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||||
|
<span class="lf-card__heat lf-card__heat--{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
|
||||||
|
{% if lead.country in service_area %}
|
||||||
|
<span class="dl-badge-region">Your region</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<dl class="lf-card__meta">
|
||||||
|
<dt>Facility</dt><dd>{{ lead.facility_type or '-' }}</dd>
|
||||||
|
<dt>Courts</dt><dd>{{ lead.court_count or '-' }}</dd>
|
||||||
|
<dt>Country</dt><dd>{{ lead.country or '-' }}</dd>
|
||||||
|
<dt>Timeline</dt><dd>{{ lead.timeline or '-' }}</dd>
|
||||||
|
<dt>Budget</dt><dd>{% if lead.budget_estimate %}€{{ lead.budget_estimate }}{% else %}-{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
{# Bidder count messaging #}
|
||||||
|
{% if lead.bidder_count == 0 %}
|
||||||
|
<div class="dl-bidders dl-bidders--first">No other suppliers yet — be first!</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="dl-bidders dl-bidders--many">{{ lead.bidder_count }} supplier{{ 's' if lead.bidder_count != 1 }} already unlocked</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="lf-card__foot">
|
||||||
|
<div class="lf-card__cost"><strong>{{ lead.credit_cost or '?' }}</strong> credits to unlock</div>
|
||||||
|
<form hx-post="{{ url_for('suppliers.unlock_lead', lead_id=lead.id) }}" hx-target="#lead-card-{{ lead.id }}" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="lf-unlock-btn">Unlock</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card text-center" style="padding:3rem">
|
||||||
|
<h3 style="color:#64748B">No leads match your filters</h3>
|
||||||
|
<p style="color:#94A3B8;font-size:0.875rem">Try adjusting your filters, or check back later for new leads.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Include lead feed styles #}
|
||||||
|
<style>
|
||||||
|
.lf-card {
|
||||||
|
background: white; border: 1px solid #E2E8F0; border-radius: 14px;
|
||||||
|
padding: 1.25rem; transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.lf-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||||
|
.lf-card__heat {
|
||||||
|
display: inline-block; font-size: 0.625rem; font-weight: 700;
|
||||||
|
padding: 2px 8px; border-radius: 999px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.lf-card__heat--hot { background: #FEE2E2; color: #DC2626; }
|
||||||
|
.lf-card__heat--warm { background: #FEF3C7; color: #D97706; }
|
||||||
|
.lf-card__heat--cool { background: #E2E8F0; color: #64748B; }
|
||||||
|
.lf-card__meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin: 0.75rem 0; font-size: 0.8125rem; }
|
||||||
|
.lf-card__meta dt { color: #94A3B8; font-size: 0.6875rem; }
|
||||||
|
.lf-card__meta dd { color: #1E293B; font-weight: 500; margin: 0; }
|
||||||
|
.lf-card__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #F1F5F9; }
|
||||||
|
.lf-card__cost { font-size: 0.8125rem; color: #64748B; }
|
||||||
|
.lf-card__cost strong { color: #1D4ED8; }
|
||||||
|
.lf-unlock-btn {
|
||||||
|
padding: 8px 20px; font-size: 0.75rem; font-weight: 700; border: none;
|
||||||
|
background: #1D4ED8; color: white; border-radius: 8px; cursor: pointer;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
.lf-unlock-btn:hover { background: #1E40AF; }
|
||||||
|
.lf-card--unlocked { border-color: #BBF7D0; background: #F0FDF4; }
|
||||||
|
.lf-contact { background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 8px; padding: 0.75rem; margin-top: 0.75rem; }
|
||||||
|
.lf-contact dt { color: #94A3B8; font-size: 0.6875rem; }
|
||||||
|
.lf-contact dd { color: #1E293B; font-weight: 500; margin: 0 0 4px; font-size: 0.8125rem; }
|
||||||
|
.lf-error { color: #DC2626; font-size: 0.8125rem; padding: 8px; background: #FEF2F2; border-radius: 8px; text-align: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<style>
|
||||||
|
.lst-preview { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||||
|
.lst-preview h3 { font-size: 0.9375rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
|
||||||
|
.lst-card {
|
||||||
|
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;
|
||||||
|
{% if 'highlight' in active_boosts %}border-color: #1D4ED8; border-width: 2px;{% endif %}
|
||||||
|
}
|
||||||
|
.lst-card__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
|
||||||
|
.lst-card__logo { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; }
|
||||||
|
.lst-card__name { font-size: 1rem; font-weight: 700; color: #1E293B; }
|
||||||
|
.lst-card__loc { font-size: 0.8125rem; color: #64748B; margin-bottom: 0.5rem; }
|
||||||
|
.lst-badges { display: flex; gap: 6px; margin-bottom: 0.5rem; }
|
||||||
|
.lst-badge {
|
||||||
|
font-size: 0.625rem; font-weight: 700; padding: 2px 8px;
|
||||||
|
border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.lst-badge--tier { background: #DBEAFE; color: #1D4ED8; }
|
||||||
|
.lst-badge--verified { background: #DCFCE7; color: #16A34A; }
|
||||||
|
.lst-card__desc { font-size: 0.8125rem; color: #475569; line-height: 1.5; }
|
||||||
|
|
||||||
|
.lst-form { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; }
|
||||||
|
.lst-form h3 { font-size: 0.9375rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
|
||||||
|
.lst-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem; }
|
||||||
|
.lst-full { margin-bottom: 0.75rem; }
|
||||||
|
.lst-label { display: block; font-size: 0.75rem; font-weight: 600; color: #475569; margin-bottom: 4px; }
|
||||||
|
.lst-input {
|
||||||
|
width: 100%; border: 1px solid #E2E8F0; border-radius: 8px; padding: 8px 12px;
|
||||||
|
font-size: 0.8125rem; font-family: inherit; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.lst-textarea { min-height: 80px; resize: vertical; }
|
||||||
|
.lst-pills { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.lst-pill-label {
|
||||||
|
font-size: 0.75rem; padding: 4px 10px; border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 999px; cursor: pointer; color: #64748B;
|
||||||
|
}
|
||||||
|
.lst-pill-label:has(input:checked) { background: #1D4ED8; color: white; border-color: #1D4ED8; }
|
||||||
|
.lst-pill-label input { display: none; }
|
||||||
|
.lst-saved {
|
||||||
|
background: #DCFCE7; border: 1px solid #BBF7D0; border-radius: 8px;
|
||||||
|
padding: 8px 12px; margin-bottom: 1rem; font-size: 0.8125rem; color: #16A34A; font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) { .lst-row { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% if saved is defined and saved %}
|
||||||
|
<div class="lst-saved">Listing saved successfully.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Listing Preview -->
|
||||||
|
<div class="lst-preview">
|
||||||
|
<h3>Your Directory Card Preview</h3>
|
||||||
|
<div class="lst-card">
|
||||||
|
<div class="lst-card__head">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
{% if supplier.logo_file %}
|
||||||
|
<img src="{{ supplier.logo_file }}" alt="" class="lst-card__logo">
|
||||||
|
{% elif supplier.logo_url %}
|
||||||
|
<img src="{{ supplier.logo_url }}" alt="" class="lst-card__logo">
|
||||||
|
{% endif %}
|
||||||
|
<span class="lst-card__name">{{ supplier.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="lst-badge lst-badge--tier">{{ supplier.category }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lst-card__loc">{{ supplier.city or '' }}{% if supplier.city %}, {% endif %}{{ supplier.country_code }}</div>
|
||||||
|
<div class="lst-badges">
|
||||||
|
<span class="lst-badge lst-badge--tier">{{ supplier.tier | upper }}</span>
|
||||||
|
{% if 'verified' in active_boosts or supplier.is_verified %}
|
||||||
|
<span class="lst-badge lst-badge--verified">Verified ✓</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if supplier.tagline %}
|
||||||
|
<p style="font-size:0.8125rem;color:#1E293B;font-weight:500;margin-bottom:4px">{{ supplier.tagline }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if supplier.short_description or supplier.description %}
|
||||||
|
<p class="lst-card__desc">{{ supplier.short_description or supplier.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if supplier.website %}
|
||||||
|
<div style="margin-top:0.5rem;font-size:0.75rem;color:#1D4ED8">{{ supplier.website }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Form -->
|
||||||
|
<div class="lst-form">
|
||||||
|
<h3>Edit Company Info</h3>
|
||||||
|
<form hx-post="{{ url_for('suppliers.dashboard_listing_save') }}" hx-target="#dashboard-content" hx-swap="innerHTML" hx-encoding="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="lst-row">
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Company Name</label>
|
||||||
|
<input type="text" name="name" value="{{ supplier.name }}" class="lst-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Tagline</label>
|
||||||
|
<input type="text" name="tagline" value="{{ supplier.tagline or '' }}" class="lst-input" placeholder="One-liner for search results">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-full">
|
||||||
|
<label class="lst-label">Short Description</label>
|
||||||
|
<textarea name="short_description" class="lst-input lst-textarea">{{ supplier.short_description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-full">
|
||||||
|
<label class="lst-label">Full Description</label>
|
||||||
|
<textarea name="long_description" class="lst-input lst-textarea" style="min-height:120px">{{ supplier.long_description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-row">
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Website</label>
|
||||||
|
<input type="url" name="website" value="{{ supplier.website or '' }}" class="lst-input" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Logo</label>
|
||||||
|
<input type="file" name="logo_file" accept="image/*" class="lst-input" style="padding:6px 8px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-row">
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Contact Name</label>
|
||||||
|
<input type="text" name="contact_name" value="{{ supplier.contact_name or '' }}" class="lst-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Contact Email</label>
|
||||||
|
<input type="email" name="contact_email" value="{{ supplier.contact_email or '' }}" class="lst-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-row">
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Contact Phone</label>
|
||||||
|
<input type="tel" name="contact_phone" value="{{ supplier.contact_phone or '' }}" class="lst-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lst-label">Years in Business</label>
|
||||||
|
<input type="number" name="years_in_business" value="{{ supplier.years_in_business or '' }}" class="lst-input" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-full">
|
||||||
|
<label class="lst-label">Project Count</label>
|
||||||
|
<input type="number" name="project_count" value="{{ supplier.project_count or '' }}" class="lst-input" min="0" style="max-width:200px">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-full">
|
||||||
|
<label class="lst-label">Service Categories</label>
|
||||||
|
<div class="lst-pills">
|
||||||
|
{% set current_cats = (supplier.service_categories or '').split(',') %}
|
||||||
|
{% for cat in service_categories %}
|
||||||
|
<label class="lst-pill-label">
|
||||||
|
<input type="checkbox" name="service_categories" value="{{ cat }}" {% if cat in current_cats %}checked{% endif %}>
|
||||||
|
{{ cat | replace('_', ' ') | title }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lst-full" style="margin-top:0.75rem">
|
||||||
|
<label class="lst-label">Service Area (Countries)</label>
|
||||||
|
<div class="lst-pills">
|
||||||
|
{% set current_areas = (supplier.service_area or '').split(',') %}
|
||||||
|
{% for c in countries %}
|
||||||
|
<label class="lst-pill-label">
|
||||||
|
<input type="checkbox" name="service_area" value="{{ c }}" {% if c in current_areas %}checked{% endif %}>
|
||||||
|
{{ c }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem">
|
||||||
|
<button type="submit" class="btn">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<style>
|
||||||
|
.ov-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.ov-stat {
|
||||||
|
background: white; border: 1px solid #E2E8F0; border-radius: 12px; padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ov-stat__label { font-size: 0.6875rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.ov-stat__value { font-size: 1.75rem; font-weight: 800; color: #1E293B; margin-top: 4px; }
|
||||||
|
.ov-stat__value--blue { color: #1D4ED8; }
|
||||||
|
.ov-alert {
|
||||||
|
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 10px;
|
||||||
|
padding: 12px 16px; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 10px;
|
||||||
|
font-size: 0.8125rem; color: #1D4ED8;
|
||||||
|
}
|
||||||
|
.ov-alert__count { font-weight: 700; font-size: 1.25rem; }
|
||||||
|
.ov-activity { background: white; border: 1px solid #E2E8F0; border-radius: 12px; padding: 1.25rem; }
|
||||||
|
.ov-activity h3 { font-size: 0.9375rem; margin-bottom: 0.75rem; }
|
||||||
|
.ov-activity__item {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 8px 0; border-bottom: 1px solid #F1F5F9; font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ov-activity__item:last-child { border-bottom: none; }
|
||||||
|
.ov-activity__note { color: #475569; }
|
||||||
|
.ov-activity__delta { font-weight: 600; }
|
||||||
|
.ov-activity__delta--neg { color: #DC2626; }
|
||||||
|
.ov-activity__delta--pos { color: #16A34A; }
|
||||||
|
.ov-activity__time { font-size: 0.6875rem; color: #94A3B8; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ov-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% if new_leads_count > 0 %}
|
||||||
|
<div class="ov-alert">
|
||||||
|
<span class="ov-alert__count">{{ new_leads_count }}</span>
|
||||||
|
<span>new lead{{ 's' if new_leads_count != 1 }} match your profile.
|
||||||
|
<a href="{{ url_for('suppliers.dashboard', tab='leads') }}"
|
||||||
|
hx-get="{{ url_for('suppliers.dashboard_leads') }}"
|
||||||
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="{{ url_for('suppliers.dashboard', tab='leads') }}"
|
||||||
|
style="font-weight:600">View Lead Feed →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="ov-stats">
|
||||||
|
<div class="ov-stat">
|
||||||
|
<div class="ov-stat__label">Profile Views</div>
|
||||||
|
<div class="ov-stat__value">—</div>
|
||||||
|
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">via Umami</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-stat">
|
||||||
|
<div class="ov-stat__label">Leads Unlocked</div>
|
||||||
|
<div class="ov-stat__value">{{ leads_unlocked }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-stat">
|
||||||
|
<div class="ov-stat__label">Credits Balance</div>
|
||||||
|
<div class="ov-stat__value ov-stat__value--blue">{{ supplier.credit_balance }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-stat">
|
||||||
|
<div class="ov-stat__label">Directory Rank</div>
|
||||||
|
<div class="ov-stat__value">—</div>
|
||||||
|
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">via Umami</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ov-activity">
|
||||||
|
<h3>Recent Activity</h3>
|
||||||
|
{% if recent_activity %}
|
||||||
|
{% for item in recent_activity %}
|
||||||
|
<div class="ov-activity__item">
|
||||||
|
<div>
|
||||||
|
<span class="ov-activity__note">{{ item.note or item.event_type }}</span>
|
||||||
|
<span class="ov-activity__time">{{ item.created_at[:16] }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="ov-activity__delta {% if item.delta < 0 %}ov-activity__delta--neg{% else %}ov-activity__delta--pos{% endif %}">
|
||||||
|
{{ '%+d' % item.delta }} credits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color:#94A3B8;font-size:0.8125rem;text-align:center;padding:1rem 0">No activity yet. Unlock your first lead to get started.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<div class="lf-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||||
|
<span class="lf-card__heat lf-card__heat--{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
|
||||||
|
<span class="lf-card__unlocks">{{ lead.unlock_count or 0 }} supplier{{ 's' if (lead.unlock_count or 0) != 1 }} unlocked</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="lf-card__meta">
|
||||||
|
<dt>Facility</dt>
|
||||||
|
<dd>{{ lead.facility_type or '-' }}</dd>
|
||||||
|
<dt>Courts</dt>
|
||||||
|
<dd>{{ lead.court_count or '-' }}</dd>
|
||||||
|
<dt>Country</dt>
|
||||||
|
<dd>{{ lead.country or '-' }}</dd>
|
||||||
|
<dt>Timeline</dt>
|
||||||
|
<dd>{{ lead.timeline or '-' }}</dd>
|
||||||
|
<dt>Budget</dt>
|
||||||
|
<dd>{% if lead.budget_estimate %}~€{{ ((lead.budget_estimate | int / 1000) | round | int) }}K{% else %}-{% endif %}</dd>
|
||||||
|
<dt>Context</dt>
|
||||||
|
<dd>{{ lead.build_context or '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if lead.services_needed %}
|
||||||
|
<p style="font-size:0.6875rem;color:#64748B;margin:0 0 0.5rem">Services: {{ lead.services_needed }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="lf-card__foot">
|
||||||
|
<span class="lf-card__cost"><strong>{{ lead.credit_cost or '?' }}</strong> credits</span>
|
||||||
|
<form hx-post="{{ url_for('suppliers.unlock_lead', lead_id=lead.id) }}"
|
||||||
|
hx-target="#lead-card-{{ lead.id }}" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="lf-unlock-btn">Unlock Lead</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="lf-card">
|
||||||
|
<div class="lf-error">{{ error }}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<div class="lf-card lf-card--unlocked">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||||
|
<span class="lf-card__heat lf-card__heat--{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
|
||||||
|
<span style="font-size:0.6875rem;color:#16A34A;font-weight:600">✓ Unlocked</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="lf-card__meta">
|
||||||
|
<dt>Facility</dt>
|
||||||
|
<dd>{{ lead.facility_type or '-' }} ({{ lead.build_context or '-' }})</dd>
|
||||||
|
<dt>Courts</dt>
|
||||||
|
<dd>{{ lead.court_count or '-' }} | Glass: {{ lead.glass_type or '-' }} | Lighting: {{ lead.lighting_type or '-' }}</dd>
|
||||||
|
<dt>Location</dt>
|
||||||
|
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
||||||
|
<dt>Timeline</dt>
|
||||||
|
<dd>{{ lead.timeline or '-' }}</dd>
|
||||||
|
<dt>Budget</dt>
|
||||||
|
<dd>{% if lead.budget_estimate %}€{{ lead.budget_estimate }}{% else %}-{% endif %}</dd>
|
||||||
|
<dt>Phase</dt>
|
||||||
|
<dd>{{ lead.location_status or '-' }}</dd>
|
||||||
|
<dt>Financing</dt>
|
||||||
|
<dd>{{ lead.financing_status or '-' }}</dd>
|
||||||
|
<dt>Services</dt>
|
||||||
|
<dd>{{ lead.services_needed or '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if lead.additional_info %}
|
||||||
|
<p style="font-size:0.75rem;color:#475569;margin:0.5rem 0;background:#F8FAFC;padding:8px;border-radius:6px">{{ lead.additional_info }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="lf-contact">
|
||||||
|
<dl style="display:grid;grid-template-columns:80px 1fr;gap:2px 8px">
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{{ lead.contact_name or '-' }}</dd>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd><a href="mailto:{{ lead.contact_email }}" style="color:#1D4ED8">{{ lead.contact_email or '-' }}</a></dd>
|
||||||
|
<dt>Phone</dt>
|
||||||
|
<dd>{% if lead.contact_phone %}<a href="tel:{{ lead.contact_phone }}" style="color:#1D4ED8">{{ lead.contact_phone }}</a>{% else %}-{% endif %}</dd>
|
||||||
|
<dt>Company</dt>
|
||||||
|
<dd>{{ lead.contact_company or '-' }}</dd>
|
||||||
|
<dt>Role</dt>
|
||||||
|
<dd>{{ lead.stakeholder_type or '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if credit_cost is defined %}
|
||||||
|
<p style="font-size:0.6875rem;color:#94A3B8;margin-top:0.5rem;text-align:center">{{ credit_cost }} credits used · {{ supplier.credit_balance }} remaining</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<div data-step="1">
|
||||||
|
<h2 class="s-step-title">Choose Your Plan</h2>
|
||||||
|
<p class="s-step-sub">Select the plan that fits your growth goals.</p>
|
||||||
|
|
||||||
|
<form hx-post="{{ url_for('suppliers.signup_step', step=1) }}"
|
||||||
|
hx-target="#signup-step" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||||
|
|
||||||
|
<div class="s-plan-grid">
|
||||||
|
{% for key, plan in plans.items() %}
|
||||||
|
<label class="s-plan-card {% if data.get('plan', 'supplier_growth') == key %}s-plan-card--selected{% endif %}"
|
||||||
|
onclick="this.parentNode.querySelectorAll('.s-plan-card').forEach(c=>c.classList.remove('s-plan-card--selected')); this.classList.add('s-plan-card--selected')">
|
||||||
|
<input type="radio" name="plan" value="{{ key }}" {% if data.get('plan', 'supplier_growth') == key %}checked{% endif %}>
|
||||||
|
{% if key == 'supplier_pro' %}<div class="s-plan-card__popular">Most Popular</div>{% endif %}
|
||||||
|
<h3>{{ plan.name }}</h3>
|
||||||
|
<div class="price">€{{ plan.price }} <span>/mo</span></div>
|
||||||
|
<ul>
|
||||||
|
{% for f in plan.features %}
|
||||||
|
<li>{{ f }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-nav">
|
||||||
|
<span></span>
|
||||||
|
<button type="submit" class="s-btn-next">Next: Add-Ons</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<div data-step="2">
|
||||||
|
<h2 class="s-step-title">Boost Add-Ons</h2>
|
||||||
|
<p class="s-step-sub">Increase your visibility with optional boosts. {% if included_boosts %}Some are included in your plan.{% endif %}</p>
|
||||||
|
|
||||||
|
<form hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
|
||||||
|
hx-target="#signup-step" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||||
|
|
||||||
|
<div class="s-boost-grid">
|
||||||
|
{% set selected_boosts = data.get('boosts', []) %}
|
||||||
|
{% for b in boosts %}
|
||||||
|
{% set is_included = b.type in (included_boosts or []) %}
|
||||||
|
<label class="s-boost-card {% if is_included %}s-boost-card--included{% elif b.type in selected_boosts %}s-boost-card--selected{% endif %}"
|
||||||
|
{% if not is_included %}onclick="this.classList.toggle('s-boost-card--selected')"{% endif %}>
|
||||||
|
{% if is_included %}
|
||||||
|
<input type="checkbox" name="boosts" value="{{ b.type }}" checked disabled>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" name="boosts" value="{{ b.type }}"
|
||||||
|
{% if b.type in selected_boosts %}checked{% endif %}>
|
||||||
|
{% endif %}
|
||||||
|
<strong>{{ b.name }}</strong>
|
||||||
|
{% if is_included %}
|
||||||
|
<span class="boost-included">Included in plan</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="boost-price">€{{ b.price }}/mo</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="boost-desc">{{ b.desc }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-nav">
|
||||||
|
<button type="button" class="s-btn-back"
|
||||||
|
hx-get="{{ url_for('suppliers.signup_step', step=1) }}?_accumulated={{ data | tojson | urlencode }}"
|
||||||
|
hx-target="#signup-step" hx-swap="innerHTML">Back</button>
|
||||||
|
<button type="submit" class="s-btn-next">Next: Credit Packs</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div data-step="3">
|
||||||
|
<h2 class="s-step-title">Credit Packs</h2>
|
||||||
|
<p class="s-step-sub">Optionally top up your lead credits. Your plan includes monthly credits — packs give you extra.</p>
|
||||||
|
|
||||||
|
<form hx-post="{{ url_for('suppliers.signup_step', step=3) }}"
|
||||||
|
hx-target="#signup-step" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||||
|
|
||||||
|
<div class="s-credit-grid">
|
||||||
|
<label class="s-credit-card {% if not data.get('credit_pack') %}s-credit-card--selected{% endif %}"
|
||||||
|
onclick="this.parentNode.querySelectorAll('.s-credit-card').forEach(c=>c.classList.remove('s-credit-card--selected')); this.classList.add('s-credit-card--selected')">
|
||||||
|
<input type="radio" name="credit_pack" value="" {% if not data.get('credit_pack') %}checked{% endif %}>
|
||||||
|
<div class="amount">0</div>
|
||||||
|
<div class="price">Free</div>
|
||||||
|
<div class="per">Plan credits only</div>
|
||||||
|
</label>
|
||||||
|
{% for cp in credit_packs %}
|
||||||
|
<label class="s-credit-card {% if data.get('credit_pack') == cp.key %}s-credit-card--selected{% endif %}"
|
||||||
|
onclick="this.parentNode.querySelectorAll('.s-credit-card').forEach(c=>c.classList.remove('s-credit-card--selected')); this.classList.add('s-credit-card--selected')">
|
||||||
|
<input type="radio" name="credit_pack" value="{{ cp.key }}" {% if data.get('credit_pack') == cp.key %}checked{% endif %}>
|
||||||
|
<div class="amount">{{ cp.amount }}</div>
|
||||||
|
<div class="price">€{{ cp.price }}</div>
|
||||||
|
<div class="per">€{{ '%.2f' | format(cp.price / cp.amount) }}/credit</div>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-nav">
|
||||||
|
<button type="button" class="s-btn-back"
|
||||||
|
hx-post="{{ url_for('suppliers.signup_step', step=1) }}"
|
||||||
|
hx-target="#signup-step" hx-swap="innerHTML"
|
||||||
|
hx-include="[name='_accumulated']">Back</button>
|
||||||
|
<button type="submit" class="s-btn-next">Next: Your Details</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<div data-step="4">
|
||||||
|
<h2 class="s-step-title">Account Details</h2>
|
||||||
|
<p class="s-step-sub">Tell us about your company and how to reach you.</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('suppliers.signup_checkout') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem">
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Contact Name <span style="color:#EF4444">*</span></label>
|
||||||
|
<input type="text" name="contact_name" class="s-input" value="{{ data.get('contact_name', '') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Email <span style="color:#EF4444">*</span></label>
|
||||||
|
<input type="email" name="contact_email" class="s-input" value="{{ data.get('contact_email', '') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Phone</label>
|
||||||
|
<input type="tel" name="contact_phone" class="s-input" value="{{ data.get('contact_phone', '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Short Description</label>
|
||||||
|
<input type="text" name="short_description" class="s-input" maxlength="160"
|
||||||
|
value="{{ data.get('short_description', '') }}" placeholder="160 chars max">
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Service Categories</label>
|
||||||
|
<input type="text" name="service_categories" class="s-input"
|
||||||
|
value="{{ data.get('service_categories', '') }}" placeholder="e.g. turnkey, surfaces, lighting">
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Service Area (countries)</label>
|
||||||
|
<input type="text" name="service_area" class="s-input"
|
||||||
|
value="{{ data.get('service_area', '') }}" placeholder="e.g. DE, ES, FR">
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Years in Business</label>
|
||||||
|
<input type="number" name="years_in_business" class="s-input" min="0"
|
||||||
|
value="{{ data.get('years_in_business', '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="s-field">
|
||||||
|
<label class="s-label">Project Count</label>
|
||||||
|
<input type="number" name="project_count" class="s-input" min="0"
|
||||||
|
value="{{ data.get('project_count', '') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.get('supplier_name') %}
|
||||||
|
<p style="font-size:12px;color:#64748B;margin-top:0.5rem">
|
||||||
|
Claiming listing: <strong>{{ data.get('supplier_name') }}</strong>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Order Summary -->
|
||||||
|
<div class="s-summary">
|
||||||
|
<h3>Order Summary</h3>
|
||||||
|
<div class="s-summary-row">
|
||||||
|
<span>{{ order.plan_name }} Plan</span>
|
||||||
|
<span>€{{ order.plan_price }}/mo</span>
|
||||||
|
</div>
|
||||||
|
{% if order.boost_monthly > 0 %}
|
||||||
|
<div class="s-summary-row">
|
||||||
|
<span>Boost add-ons</span>
|
||||||
|
<span>+€{{ order.boost_monthly }}/mo</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="s-summary-row s-summary-total">
|
||||||
|
<span>Monthly total</span>
|
||||||
|
<span>€{{ order.monthly_total }}/mo</span>
|
||||||
|
</div>
|
||||||
|
{% if order.one_time_total > 0 %}
|
||||||
|
<div class="s-summary-row" style="margin-top:8px">
|
||||||
|
<span>Credit pack (one-time)</span>
|
||||||
|
<span>€{{ order.one_time_total }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-nav">
|
||||||
|
<button type="button" class="s-btn-back"
|
||||||
|
hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
|
||||||
|
hx-target="#signup-step" hx-swap="innerHTML"
|
||||||
|
hx-include="[name='_accumulated']">Back</button>
|
||||||
|
<button type="submit" class="s-btn-next">Proceed to Checkout</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Supplier Signup - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.s-wizard { max-width: 960px; margin: 2rem auto; }
|
||||||
|
.s-progress { margin-bottom: 1.5rem; }
|
||||||
|
.s-progress__meta {
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.s-progress__label { font-size: 12px; font-weight: 600; color: #334155; }
|
||||||
|
.s-progress__count { font-size: 11px; color: #94A3B8; }
|
||||||
|
.s-progress__track {
|
||||||
|
height: 4px; border-radius: 2px; background: #E2E8F0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.s-progress__fill {
|
||||||
|
height: 100%; border-radius: 2px; background: #1D4ED8;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.s-card {
|
||||||
|
background: white; border-radius: 16px; padding: 36px 32px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.s-step-title { font-size: 1.25rem; font-weight: 800; color: #0F172A; margin: 0 0 4px; }
|
||||||
|
.s-step-sub { font-size: 13px; color: #64748B; margin: 0 0 1.5rem; }
|
||||||
|
.s-nav { display: flex; justify-content: space-between; margin-top: 1.5rem; }
|
||||||
|
.s-btn-back {
|
||||||
|
padding: 8px 20px; font-size: 12px; font-weight: 600;
|
||||||
|
border: 1px solid #CBD5E1; background: transparent; color: #64748B;
|
||||||
|
border-radius: 10px; cursor: pointer; font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
.s-btn-back:hover { background: #F1F5F9; }
|
||||||
|
.s-btn-next {
|
||||||
|
padding: 10px 24px; font-size: 13px; font-weight: 700;
|
||||||
|
border: none; background: #1D4ED8; color: white; border-radius: 10px;
|
||||||
|
cursor: pointer; font-family: 'Inter', sans-serif;
|
||||||
|
box-shadow: 0 2px 10px rgba(29,78,216,0.25);
|
||||||
|
}
|
||||||
|
.s-btn-next:hover { background: #1E40AF; }
|
||||||
|
/* Plan cards */
|
||||||
|
.s-plan-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.s-plan-card {
|
||||||
|
border: 2px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;
|
||||||
|
cursor: pointer; transition: all 0.15s; position: relative;
|
||||||
|
}
|
||||||
|
.s-plan-card:hover { border-color: #93C5FD; }
|
||||||
|
.s-plan-card--selected { border-color: #1D4ED8; background: #EFF6FF; }
|
||||||
|
.s-plan-card input[type="radio"] { display: none; }
|
||||||
|
.s-plan-card h3 { font-size: 1.125rem; margin: 0 0 4px; }
|
||||||
|
.s-plan-card .price { font-size: 1.5rem; font-weight: 800; color: #1E293B; }
|
||||||
|
.s-plan-card .price span { font-size: 0.8125rem; font-weight: 400; color: #64748B; }
|
||||||
|
.s-plan-card ul { list-style: none; padding: 0; margin: 0.75rem 0 0; }
|
||||||
|
.s-plan-card li { font-size: 0.75rem; color: #475569; padding: 2px 0; display: flex; gap: 4px; }
|
||||||
|
.s-plan-card li::before { content: "\2713"; color: #16A34A; font-weight: 700; }
|
||||||
|
.s-plan-card__popular {
|
||||||
|
position: absolute; top: -10px; right: 12px;
|
||||||
|
background: #1D4ED8; color: white; font-size: 0.625rem; font-weight: 700;
|
||||||
|
padding: 3px 10px; border-radius: 999px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
/* Boost cards */
|
||||||
|
.s-boost-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; }
|
||||||
|
.s-boost-card {
|
||||||
|
border: 1.5px solid #E2E8F0; border-radius: 10px; padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.s-boost-card:hover { border-color: #93C5FD; }
|
||||||
|
.s-boost-card--selected { border-color: #1D4ED8; background: #EFF6FF; }
|
||||||
|
.s-boost-card--included { border-color: #BBF7D0; background: #F0FDF4; cursor: default; }
|
||||||
|
.s-boost-card input[type="checkbox"] { display: none; }
|
||||||
|
.s-boost-card strong { display: block; font-size: 0.8125rem; color: #1E293B; }
|
||||||
|
.s-boost-card .boost-price { color: #1D4ED8; font-weight: 700; font-size: 0.75rem; }
|
||||||
|
.s-boost-card .boost-desc { font-size: 0.6875rem; color: #64748B; }
|
||||||
|
.s-boost-card .boost-included { font-size: 0.6875rem; color: #16A34A; font-weight: 600; }
|
||||||
|
/* Credit packs */
|
||||||
|
.s-credit-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
|
||||||
|
.s-credit-card {
|
||||||
|
border: 1.5px solid #E2E8F0; border-radius: 10px; padding: 0.75rem;
|
||||||
|
text-align: center; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.s-credit-card:hover { border-color: #93C5FD; }
|
||||||
|
.s-credit-card--selected { border-color: #1D4ED8; background: #EFF6FF; }
|
||||||
|
.s-credit-card input[type="radio"] { display: none; }
|
||||||
|
.s-credit-card .amount { font-size: 1.25rem; font-weight: 800; color: #1E293B; }
|
||||||
|
.s-credit-card .price { font-size: 0.8125rem; color: #1D4ED8; font-weight: 700; }
|
||||||
|
.s-credit-card .per { font-size: 0.625rem; color: #94A3B8; }
|
||||||
|
/* Form fields */
|
||||||
|
.s-field { margin-bottom: 1rem; }
|
||||||
|
.s-label { display: block; font-size: 12px; font-weight: 600; color: #334155; margin-bottom: 4px; }
|
||||||
|
.s-input {
|
||||||
|
width: 100%; background: #F1F5F9; border: 1px solid #CBD5E1;
|
||||||
|
border-radius: 10px; padding: 8px 12px; font-size: 13px;
|
||||||
|
font-family: 'Inter', sans-serif; color: #0F172A; outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.s-input:focus { border-color: rgba(29,78,216,0.5); }
|
||||||
|
/* Order summary */
|
||||||
|
.s-summary {
|
||||||
|
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 12px;
|
||||||
|
padding: 1.25rem; margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.s-summary h3 { font-size: 0.875rem; font-weight: 700; margin: 0 0 0.75rem; }
|
||||||
|
.s-summary-row { display: flex; justify-content: space-between; font-size: 0.8125rem; padding: 3px 0; }
|
||||||
|
.s-summary-total { border-top: 1px solid #CBD5E1; padding-top: 8px; margin-top: 8px; font-weight: 700; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.s-card { padding: 24px 16px; }
|
||||||
|
.s-plan-grid, .s-boost-grid { grid-template-columns: 1fr; }
|
||||||
|
.s-credit-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
||||||
|
<div class="container-page py-12">
|
||||||
|
<div class="s-wizard">
|
||||||
|
<div class="s-progress" id="s-progress">
|
||||||
|
<div class="s-progress__meta">
|
||||||
|
<span class="s-progress__label" id="s-progress-label">Choose Your Plan</span>
|
||||||
|
<span class="s-progress__count" id="s-progress-count">1 of 4</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-progress__track">
|
||||||
|
<div class="s-progress__fill" id="s-progress-fill" style="width: 25%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-card">
|
||||||
|
<div id="signup-step">
|
||||||
|
{% include "suppliers/partials/signup_step_1.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
|
if (e.detail.target.id === 'signup-step') {
|
||||||
|
var step = e.detail.target.querySelector('[data-step]');
|
||||||
|
if (step) {
|
||||||
|
var n = parseInt(step.dataset.step);
|
||||||
|
var labels = ['Choose Your Plan', 'Boost Add-Ons', 'Credit Packs', 'Account Details'];
|
||||||
|
document.getElementById('s-progress-label').textContent = labels[n-1] || '';
|
||||||
|
document.getElementById('s-progress-count').textContent = n + ' of 4';
|
||||||
|
document.getElementById('s-progress-fill').style.width = (n * 25) + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Welcome! - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
||||||
|
<div class="container-page py-12" style="max-width:560px;margin:0 auto;text-align:center">
|
||||||
|
<div style="background:white;border-radius:16px;padding:48px 32px;box-shadow:0 4px 24px rgba(0,0,0,0.06)">
|
||||||
|
<div style="width:64px;height:64px;border-radius:50%;background:#DCFCE7;margin:0 auto 1.5rem;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="font-size:28px;color:#16A34A">✓</span>
|
||||||
|
</div>
|
||||||
|
<h1 style="font-size:1.5rem;font-weight:800;margin:0 0 0.5rem">You're All Set!</h1>
|
||||||
|
<p style="color:#64748B;font-size:0.9375rem;margin:0 0 1.5rem">
|
||||||
|
Your supplier account is being activated. You'll start receiving qualified leads matching your services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background:#F8FAFC;border-radius:12px;padding:1rem;margin-bottom:1.5rem;text-align:left">
|
||||||
|
<h3 style="font-size:0.8125rem;font-weight:700;margin:0 0 0.5rem">What happens next:</h3>
|
||||||
|
<ul style="list-style:none;padding:0;margin:0;font-size:0.8125rem;color:#475569">
|
||||||
|
<li style="padding:3px 0">✓ Your listing will be upgraded within minutes</li>
|
||||||
|
<li style="padding:3px 0">✓ Lead credits have been added to your account</li>
|
||||||
|
<li style="padding:3px 0">✓ Check your email for a sign-in link</li>
|
||||||
|
<li style="padding:3px 0">✓ Browse and unlock leads in your feed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('suppliers.lead_feed') }}" class="btn" style="display:inline-block;padding:12px 28px">Go to Lead Feed</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,6 +16,29 @@
|
|||||||
<!-- Tailwind (compiled) -->
|
<!-- Tailwind (compiled) -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||||
|
|
||||||
|
<!-- Umami Analytics -->
|
||||||
|
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"></script>
|
||||||
|
|
||||||
|
<!-- Paddle.js -->
|
||||||
|
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
||||||
|
<script>
|
||||||
|
{% if config.PADDLE_ENVIRONMENT == "sandbox" %}
|
||||||
|
Paddle.Environment.set("sandbox");
|
||||||
|
{% endif %}
|
||||||
|
{% if config.PADDLE_CLIENT_TOKEN %}
|
||||||
|
Paddle.Initialize({
|
||||||
|
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
|
||||||
|
checkout: {
|
||||||
|
settings: {
|
||||||
|
displayMode: "overlay",
|
||||||
|
theme: "light",
|
||||||
|
locale: "en",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -38,6 +61,25 @@
|
|||||||
<a href="{{ url_for('directory.index') }}">Directory</a>
|
<a href="{{ url_for('directory.index') }}">Directory</a>
|
||||||
<a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
|
<a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
|
||||||
<a href="{{ url_for('public.landing') }}#faq">Help</a>
|
<a href="{{ url_for('public.landing') }}#faq">Help</a>
|
||||||
|
<!-- Feedback button -->
|
||||||
|
<div style="position:relative" id="feedback-wrap">
|
||||||
|
<button type="button" onclick="document.getElementById('feedback-popover').toggleAttribute('hidden')"
|
||||||
|
style="font-size:0.75rem;padding:4px 10px;border:1px solid #E2E8F0;border-radius:6px;background:white;cursor:pointer;color:#64748B;font-family:inherit">
|
||||||
|
Feedback
|
||||||
|
</button>
|
||||||
|
<div id="feedback-popover" hidden
|
||||||
|
style="position:absolute;right:0;top:110%;width:280px;background:white;border:1px solid #E2E8F0;border-radius:10px;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.1);z-index:100">
|
||||||
|
<form hx-post="{{ url_for('public.feedback') }}" hx-target="#feedback-popover" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="page_url" id="feedback-page-url">
|
||||||
|
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">Send Feedback</p>
|
||||||
|
<textarea name="message" rows="3" required placeholder="What's on your mind?"
|
||||||
|
style="width:100%;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
|
||||||
|
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>document.getElementById('feedback-page-url').value = window.location.pathname;</script>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||||
{% if session.get('is_admin') %}
|
{% if session.get('is_admin') %}
|
||||||
|
|||||||
105
padelnomics/src/padelnomics/templates/businessplan/plan.css
Normal file
105
padelnomics/src/padelnomics/templates/businessplan/plan.css
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm 18mm;
|
||||||
|
@bottom-center {
|
||||||
|
content: "Page " counter(page) " of " counter(pages);
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 22pt; font-weight: 800; color: #0F172A; margin: 0 0 4pt; }
|
||||||
|
h2 { font-size: 14pt; font-weight: 700; color: #0F172A; margin: 24pt 0 8pt; page-break-after: avoid; }
|
||||||
|
h3 { font-size: 10pt; font-weight: 700; color: #64748B; text-transform: uppercase; letter-spacing: 0.06em; margin: 16pt 0 6pt; }
|
||||||
|
p { margin: 0 0 6pt; }
|
||||||
|
|
||||||
|
.subtitle { font-size: 12pt; color: #64748B; margin-bottom: 20pt; }
|
||||||
|
.meta { font-size: 8pt; color: #94A3B8; margin-bottom: 6pt; }
|
||||||
|
|
||||||
|
/* Summary grid */
|
||||||
|
.summary-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8pt;
|
||||||
|
margin: 10pt 0 16pt;
|
||||||
|
}
|
||||||
|
.summary-card {
|
||||||
|
flex: 1 1 140pt;
|
||||||
|
border: 0.5pt solid #E2E8F0;
|
||||||
|
border-radius: 6pt;
|
||||||
|
padding: 8pt 10pt;
|
||||||
|
background: #F8FAFC;
|
||||||
|
}
|
||||||
|
.summary-card .label { font-size: 7pt; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.summary-card .value { font-size: 14pt; font-weight: 800; color: #1E293B; }
|
||||||
|
.summary-card .value--blue { color: #1D4ED8; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 6pt 0 16pt;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #F1F5F9;
|
||||||
|
font-size: 7pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748B;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1pt solid #E2E8F0;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 5pt 8pt;
|
||||||
|
border-bottom: 0.5pt solid #F1F5F9;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.total-row td { font-weight: 700; border-top: 1.5pt solid #E2E8F0; background: #F8FAFC; }
|
||||||
|
|
||||||
|
/* Metrics grid */
|
||||||
|
.metrics-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6pt;
|
||||||
|
margin: 8pt 0;
|
||||||
|
}
|
||||||
|
.metric-box {
|
||||||
|
flex: 1 1 90pt;
|
||||||
|
border: 0.5pt solid #E2E8F0;
|
||||||
|
border-radius: 4pt;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.metric-box .label { font-size: 7pt; color: #94A3B8; }
|
||||||
|
.metric-box .value { font-size: 12pt; font-weight: 700; color: #1E293B; }
|
||||||
|
|
||||||
|
/* Financing structure */
|
||||||
|
.fin-bar {
|
||||||
|
height: 12pt;
|
||||||
|
border-radius: 6pt;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 6pt 0 10pt;
|
||||||
|
}
|
||||||
|
.fin-bar__equity { background: #1D4ED8; }
|
||||||
|
.fin-bar__loan { background: #93C5FD; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.disclaimer {
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 30pt;
|
||||||
|
padding-top: 8pt;
|
||||||
|
border-top: 0.5pt solid #E2E8F0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
218
padelnomics/src/padelnomics/templates/businessplan/plan.html
Normal file
218
padelnomics/src/padelnomics/templates/businessplan/plan.html
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>{{ css }}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Cover -->
|
||||||
|
<h1>{{ s.title }}</h1>
|
||||||
|
<div class="subtitle">{{ s.subtitle }}</div>
|
||||||
|
{% if s.scenario_name %}
|
||||||
|
<p class="meta">Scenario: {{ s.scenario_name }}{% if s.location %} — {{ s.location }}{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="meta">{{ s.courts }}</p>
|
||||||
|
<p class="meta">Generated by Padelnomics — padelnomics.io</p>
|
||||||
|
|
||||||
|
<!-- Executive Summary -->
|
||||||
|
<h2>{{ s.executive_summary.heading }}</h2>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">Total Investment</div>
|
||||||
|
<div class="value">{{ s.executive_summary.total_capex }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">Equity Required</div>
|
||||||
|
<div class="value">{{ s.executive_summary.equity }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">Year 3 EBITDA</div>
|
||||||
|
<div class="value">{{ s.executive_summary.y3_ebitda }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">IRR</div>
|
||||||
|
<div class="value value--blue">{{ s.executive_summary.irr }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">Payback Period</div>
|
||||||
|
<div class="value">{{ s.executive_summary.payback }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">Year 1 Revenue</div>
|
||||||
|
<div class="value">{{ s.executive_summary.y1_revenue }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This business plan models a <strong>{{ s.executive_summary.facility_type }}</strong> padel facility with
|
||||||
|
<strong>{{ s.executive_summary.courts }} courts</strong> ({{ s.executive_summary.sqm }} m²).
|
||||||
|
Total investment is {{ s.executive_summary.total_capex }}, financed with {{ s.executive_summary.equity }} equity
|
||||||
|
and {{ s.executive_summary.loan }} debt. The projected IRR is {{ s.executive_summary.irr }} with a payback period
|
||||||
|
of {{ s.executive_summary.payback }}.</p>
|
||||||
|
|
||||||
|
<!-- Investment Plan (CAPEX) -->
|
||||||
|
<h2>{{ s.investment.heading }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item</th><th style="text-align:right">Amount</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in s.investment.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td style="text-align:right">€{{ "{:,.0f}".format(item.amount) }}</td>
|
||||||
|
<td style="color:#94A3B8;font-size:8pt">{{ item.info }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total CAPEX</td>
|
||||||
|
<td style="text-align:right">{{ s.investment.total }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:9pt;color:#64748B">
|
||||||
|
CAPEX per court: {{ s.investment.per_court }} • CAPEX per m²: {{ s.investment.per_sqm }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Financing Structure -->
|
||||||
|
<h2>{{ s.financing.heading }}</h2>
|
||||||
|
<div class="fin-bar">
|
||||||
|
<div class="fin-bar__equity" style="width:{{ (100 - (s.financing.loan_pct | replace('%','') | float)) }}%"></div>
|
||||||
|
<div class="fin-bar__loan" style="width:{{ s.financing.loan_pct | replace('%','') }}%"></div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Equity</td><td style="text-align:right">{{ s.financing.equity }}</td></tr>
|
||||||
|
<tr><td>Loan ({{ s.financing.loan_pct }})</td><td style="text-align:right">{{ s.financing.loan }}</td></tr>
|
||||||
|
<tr><td>Interest Rate</td><td style="text-align:right">{{ s.financing.interest_rate }}</td></tr>
|
||||||
|
<tr><td>Loan Term</td><td style="text-align:right">{{ s.financing.term }}</td></tr>
|
||||||
|
<tr><td>Monthly Payment</td><td style="text-align:right">{{ s.financing.monthly_payment }}</td></tr>
|
||||||
|
<tr><td>Annual Debt Service</td><td style="text-align:right">{{ s.financing.annual_debt_service }}</td></tr>
|
||||||
|
<tr><td>Loan-to-Value</td><td style="text-align:right">{{ s.financing.ltv }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Operating Costs -->
|
||||||
|
<h2>{{ s.operations.heading }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item</th><th style="text-align:right">Monthly</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in s.operations.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td style="text-align:right">€{{ "{:,.0f}".format(item.amount) }}</td>
|
||||||
|
<td style="color:#94A3B8;font-size:8pt">{{ item.info }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total Monthly OPEX</td>
|
||||||
|
<td style="text-align:right">{{ s.operations.monthly_total }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:9pt;color:#64748B">Annual OPEX: {{ s.operations.annual_total }}</p>
|
||||||
|
|
||||||
|
<!-- Revenue Model -->
|
||||||
|
<h2>{{ s.revenue.heading }}</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Weighted Hourly Rate</td><td style="text-align:right">{{ s.revenue.weighted_rate }}</td></tr>
|
||||||
|
<tr><td>Target Utilization</td><td style="text-align:right">{{ s.revenue.utilization }}</td></tr>
|
||||||
|
<tr><td>Gross Monthly Revenue</td><td style="text-align:right">{{ s.revenue.gross_monthly }}</td></tr>
|
||||||
|
<tr><td>Net Monthly Revenue</td><td style="text-align:right">{{ s.revenue.net_monthly }}</td></tr>
|
||||||
|
<tr><td>Monthly EBITDA</td><td style="text-align:right">{{ s.revenue.ebitda_monthly }}</td></tr>
|
||||||
|
<tr><td>Monthly Net Cash Flow</td><td style="text-align:right">{{ s.revenue.net_cf_monthly }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 5-Year Projection -->
|
||||||
|
<h2>{{ s.annuals.heading }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Year</th><th style="text-align:right">Revenue</th><th style="text-align:right">EBITDA</th><th style="text-align:right">Debt Service</th><th style="text-align:right">Net CF</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for yr in s.annuals.years %}
|
||||||
|
<tr>
|
||||||
|
<td>Year {{ yr.year }}</td>
|
||||||
|
<td style="text-align:right">{{ yr.revenue }}</td>
|
||||||
|
<td style="text-align:right">{{ yr.ebitda }}</td>
|
||||||
|
<td style="text-align:right">{{ yr.debt_service }}</td>
|
||||||
|
<td style="text-align:right">{{ yr.net_cf }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Key Metrics -->
|
||||||
|
<h2>{{ s.metrics.heading }}</h2>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">IRR</div>
|
||||||
|
<div class="value">{{ s.metrics.irr }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">MOIC</div>
|
||||||
|
<div class="value">{{ s.metrics.moic }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">Cash-on-Cash (Y3)</div>
|
||||||
|
<div class="value">{{ s.metrics.cash_on_cash }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">Payback</div>
|
||||||
|
<div class="value">{{ s.metrics.payback }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">Break-Even Util.</div>
|
||||||
|
<div class="value">{{ s.metrics.break_even_util }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">EBITDA Margin</div>
|
||||||
|
<div class="value">{{ s.metrics.ebitda_margin }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">DSCR (Y3)</div>
|
||||||
|
<div class="value">{{ s.metrics.dscr_y3 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<div class="label">Yield on Cost</div>
|
||||||
|
<div class="value">{{ s.metrics.yield_on_cost }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 12-Month Cash Flow -->
|
||||||
|
<h2>{{ s.cashflow_12m.heading }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Month</th><th style="text-align:right">Revenue</th><th style="text-align:right">OPEX</th><th style="text-align:right">EBITDA</th><th style="text-align:right">Debt</th><th style="text-align:right">Net CF</th><th style="text-align:right">Cumulative</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in s.cashflow_12m.months %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.month }}</td>
|
||||||
|
<td style="text-align:right">{{ m.revenue }}</td>
|
||||||
|
<td style="text-align:right">{{ m.opex }}</td>
|
||||||
|
<td style="text-align:right">{{ m.ebitda }}</td>
|
||||||
|
<td style="text-align:right">{{ m.debt }}</td>
|
||||||
|
<td style="text-align:right">{{ m.ncf }}</td>
|
||||||
|
<td style="text-align:right">{{ m.cumulative }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Disclaimer -->
|
||||||
|
<div class="disclaimer">
|
||||||
|
<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics
|
||||||
|
financial model. All projections are estimates and do not constitute financial advice. Actual results may vary
|
||||||
|
significantly based on market conditions, execution, and other factors. Consult with financial advisors before
|
||||||
|
making investment decisions. © Padelnomics — padelnomics.io
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,7 +6,7 @@ import json
|
|||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from .core import config, execute, fetch_all, init_db, send_email
|
from .core import EMAIL_ADDRESSES, config, execute, fetch_all, fetch_one, init_db, send_email
|
||||||
|
|
||||||
# Task handlers registry
|
# Task handlers registry
|
||||||
HANDLERS: dict[str, callable] = {}
|
HANDLERS: dict[str, callable] = {}
|
||||||
@@ -138,6 +138,7 @@ async def handle_send_email(payload: dict) -> None:
|
|||||||
subject=payload["subject"],
|
subject=payload["subject"],
|
||||||
html=payload["html"],
|
html=payload["html"],
|
||||||
text=payload.get("text"),
|
text=payload.get("text"),
|
||||||
|
from_addr=payload.get("from_addr"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ async def handle_send_magic_link(payload: dict) -> None:
|
|||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=f"Sign in to {config.APP_NAME}",
|
subject=f"Sign in to {config.APP_NAME}",
|
||||||
html=_email_wrap(body),
|
html=_email_wrap(body),
|
||||||
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,8 +212,9 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
|||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=f"Verify your email to get supplier quotes",
|
subject="Verify your email to get supplier quotes",
|
||||||
html=_email_wrap(body),
|
html=_email_wrap(body),
|
||||||
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -228,6 +231,7 @@ async def handle_send_welcome(payload: dict) -> None:
|
|||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=f"Welcome to {config.APP_NAME}",
|
subject=f"Welcome to {config.APP_NAME}",
|
||||||
html=_email_wrap(body),
|
html=_email_wrap(body),
|
||||||
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +251,194 @@ async def handle_cleanup_rate_limits(payload: dict) -> None:
|
|||||||
await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,))
|
await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,))
|
||||||
|
|
||||||
|
|
||||||
|
@task("send_lead_forward_email")
|
||||||
|
async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||||
|
"""Send full project brief to supplier who unlocked/was forwarded a lead."""
|
||||||
|
lead_id = payload["lead_id"]
|
||||||
|
supplier_id = payload["supplier_id"]
|
||||||
|
|
||||||
|
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||||
|
supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,))
|
||||||
|
if not lead or not supplier:
|
||||||
|
return
|
||||||
|
|
||||||
|
heat = (lead["heat_score"] or "cool").upper()
|
||||||
|
country = lead["country"] or "Unknown"
|
||||||
|
courts = lead["court_count"] or "?"
|
||||||
|
budget = lead["budget_estimate"] or "?"
|
||||||
|
|
||||||
|
subject = f"[{heat}] New padel project in {country} — {courts} courts, €{budget}"
|
||||||
|
|
||||||
|
brief_rows = [
|
||||||
|
("Facility", f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
|
||||||
|
("Courts", f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
|
||||||
|
("Location", f"{lead['location'] or '-'}, {country}"),
|
||||||
|
("Timeline", f"{lead['timeline'] or '-'} | Budget: €{budget}"),
|
||||||
|
("Phase", f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
|
||||||
|
("Services", lead["services_needed"] or "-"),
|
||||||
|
("Additional", lead["additional_info"] or "-"),
|
||||||
|
]
|
||||||
|
|
||||||
|
brief_html = ""
|
||||||
|
for label, value in brief_rows:
|
||||||
|
brief_html += (
|
||||||
|
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top">{label}</td>'
|
||||||
|
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_rows = [
|
||||||
|
("Name", lead["contact_name"] or "-"),
|
||||||
|
("Email", lead["contact_email"] or "-"),
|
||||||
|
("Phone", lead["contact_phone"] or "-"),
|
||||||
|
("Company", lead["contact_company"] or "-"),
|
||||||
|
("Role", lead["stakeholder_type"] or "-"),
|
||||||
|
]
|
||||||
|
|
||||||
|
contact_html = ""
|
||||||
|
for label, value in contact_rows:
|
||||||
|
contact_html += (
|
||||||
|
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{label}</td>'
|
||||||
|
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">New Project Lead</h2>'
|
||||||
|
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">A new padel project matches your services.</p>'
|
||||||
|
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">Project Brief</h3>'
|
||||||
|
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{brief_html}</table>'
|
||||||
|
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">Contact</h3>'
|
||||||
|
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{contact_html}</table>'
|
||||||
|
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View in Lead Feed")}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send to supplier contact email or general contact
|
||||||
|
to_email = supplier.get("contact_email") or supplier.get("contact") or ""
|
||||||
|
if not to_email:
|
||||||
|
print(f"[WORKER] No email for supplier {supplier_id}, skipping lead forward")
|
||||||
|
return
|
||||||
|
|
||||||
|
await send_email(
|
||||||
|
to=to_email,
|
||||||
|
subject=subject,
|
||||||
|
html=_email_wrap(body),
|
||||||
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update email_sent_at on lead_forward
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
await execute(
|
||||||
|
"UPDATE lead_forwards SET email_sent_at = ? WHERE lead_id = ? AND supplier_id = ?",
|
||||||
|
(now, lead_id, supplier_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task("send_lead_matched_notification")
|
||||||
|
async def handle_send_lead_matched_notification(payload: dict) -> None:
|
||||||
|
"""Notify the entrepreneur that a supplier has been matched to their project."""
|
||||||
|
lead_id = payload["lead_id"]
|
||||||
|
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||||
|
if not lead or not lead["contact_email"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">A supplier is reviewing your project</h2>'
|
||||||
|
f'<p>Hi {first_name},</p>'
|
||||||
|
f'<p>Great news — a verified supplier has been matched with your padel project. '
|
||||||
|
f'They have your project brief and will reach out to you directly.</p>'
|
||||||
|
f'<p style="font-size:13px;color:#64748B;">You submitted a quote request for a '
|
||||||
|
f'{lead["facility_type"] or "padel"} facility with {lead["court_count"] or "?"} courts '
|
||||||
|
f'in {lead["country"] or "your area"}.</p>'
|
||||||
|
f'{_email_button(f"{config.BASE_URL}/dashboard", "View Your Dashboard")}'
|
||||||
|
f'<p style="font-size:12px;color:#94A3B8;">You\'ll receive this notification each time '
|
||||||
|
f'a new supplier unlocks your project details.</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
await send_email(
|
||||||
|
to=lead["contact_email"],
|
||||||
|
subject="A supplier is reviewing your padel project",
|
||||||
|
html=_email_wrap(body),
|
||||||
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task("refill_monthly_credits")
|
||||||
|
async def handle_refill_monthly_credits(payload: dict) -> None:
|
||||||
|
"""Refill monthly credits for all claimed suppliers with a paid tier."""
|
||||||
|
from .credits import monthly_credit_refill
|
||||||
|
|
||||||
|
suppliers = await fetch_all(
|
||||||
|
"SELECT id FROM suppliers WHERE tier IN ('growth', 'pro') AND claimed_by IS NOT NULL"
|
||||||
|
)
|
||||||
|
for s in suppliers:
|
||||||
|
try:
|
||||||
|
await monthly_credit_refill(s["id"])
|
||||||
|
print(f"[WORKER] Refilled credits for supplier {s['id']}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WORKER] Failed to refill credits for supplier {s['id']}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@task("generate_business_plan")
|
||||||
|
async def handle_generate_business_plan(payload: dict) -> None:
|
||||||
|
"""Generate a business plan PDF and save it to disk."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
export_id = payload["export_id"]
|
||||||
|
user_id = payload["user_id"]
|
||||||
|
scenario_id = payload["scenario_id"]
|
||||||
|
language = payload.get("language", "en")
|
||||||
|
|
||||||
|
# Mark as generating
|
||||||
|
await execute(
|
||||||
|
"UPDATE business_plan_exports SET status = 'generating' WHERE id = ?",
|
||||||
|
(export_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .businessplan import generate_business_plan
|
||||||
|
|
||||||
|
pdf_bytes = await generate_business_plan(scenario_id, user_id, language)
|
||||||
|
|
||||||
|
# Save PDF
|
||||||
|
export_dir = Path("data/exports")
|
||||||
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = export_dir / f"{export_id}.pdf"
|
||||||
|
file_path.write_bytes(pdf_bytes)
|
||||||
|
|
||||||
|
# Update record
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
await execute(
|
||||||
|
"UPDATE business_plan_exports SET status = 'ready', file_path = ?, completed_at = ? WHERE id = ?",
|
||||||
|
(str(file_path), now, export_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify user via email
|
||||||
|
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||||
|
if user:
|
||||||
|
body = (
|
||||||
|
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Your Business Plan is Ready</h2>'
|
||||||
|
f"<p>Your padel business plan PDF has been generated and is ready for download.</p>"
|
||||||
|
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_id}", "Download PDF")}'
|
||||||
|
)
|
||||||
|
await send_email(
|
||||||
|
to=user["email"],
|
||||||
|
subject="Your Padel Business Plan PDF is Ready",
|
||||||
|
html=_email_wrap(body),
|
||||||
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[WORKER] Generated business plan PDF: export_id={export_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await execute(
|
||||||
|
"UPDATE business_plan_exports SET status = 'failed' WHERE id = ?",
|
||||||
|
(export_id,),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@task("cleanup_old_tasks")
|
@task("cleanup_old_tasks")
|
||||||
async def handle_cleanup_tasks(payload: dict) -> None:
|
async def handle_cleanup_tasks(payload: dict) -> None:
|
||||||
"""Clean up completed/failed tasks older than 7 days."""
|
"""Clean up completed/failed tasks older than 7 days."""
|
||||||
@@ -308,6 +500,8 @@ async def run_scheduler() -> None:
|
|||||||
print("[SCHEDULER] Starting...")
|
print("[SCHEDULER] Starting...")
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
last_credit_refill = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Schedule cleanup tasks every hour
|
# Schedule cleanup tasks every hour
|
||||||
@@ -315,6 +509,15 @@ async def run_scheduler() -> None:
|
|||||||
await enqueue("cleanup_rate_limits")
|
await enqueue("cleanup_rate_limits")
|
||||||
await enqueue("cleanup_old_tasks")
|
await enqueue("cleanup_old_tasks")
|
||||||
|
|
||||||
|
# Monthly credit refill — run on the 1st of each month
|
||||||
|
from datetime import datetime
|
||||||
|
today = datetime.utcnow()
|
||||||
|
this_month = f"{today.year}-{today.month:02d}"
|
||||||
|
if today.day == 1 and last_credit_refill != this_month:
|
||||||
|
await enqueue("refill_monthly_credits")
|
||||||
|
last_credit_refill = this_month
|
||||||
|
print(f"[SCHEDULER] Queued monthly credit refill for {this_month}")
|
||||||
|
|
||||||
await asyncio.sleep(3600) # 1 hour
|
await asyncio.sleep(3600) # 1 hour
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
430
padelnomics/uv.lock
generated
430
padelnomics/uv.lock
generated
@@ -1,6 +1,10 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.13'",
|
||||||
|
"python_full_version < '3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiofiles"
|
name = "aiofiles"
|
||||||
@@ -42,6 +46,74 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotlicffi"
|
||||||
|
version = "1.2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ec/52488a0563f1663e2ccc75834b470650f4b8bcdea3132aef3bf67219c661/brotlicffi-1.2.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fa102a60e50ddbd08de86a63431a722ea216d9bc903b000bf544149cc9b823dc", size = 402002, upload-time = "2025-11-21T18:17:51.76Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/63/d4aea4835fd97da1401d798d9b8ba77227974de565faea402f520b37b10f/brotlicffi-1.2.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d3c4332fc808a94e8c1035950a10d04b681b03ab585ce897ae2a360d479037c", size = 406447, upload-time = "2025-11-21T18:17:53.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/4e/5554ecb2615ff035ef8678d4e419549a0f7a28b3f096b272174d656749fb/brotlicffi-1.2.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb4eb5830026b79a93bf503ad32b2c5257315e9ffc49e76b2715cffd07c8e3db", size = 402521, upload-time = "2025-11-21T18:17:54.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/d3/b07f8f125ac52bbee5dc00ef0d526f820f67321bf4184f915f17f50a4657/brotlicffi-1.2.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3832c66e00d6d82087f20a972b2fc03e21cd99ef22705225a6f8f418a9158ecc", size = 374730, upload-time = "2025-11-21T18:17:56.334Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.1.4"
|
version = "2026.1.4"
|
||||||
@@ -51,6 +123,76 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.4"
|
version = "3.4.4"
|
||||||
@@ -145,6 +287,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssselect2"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tinycss2" },
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flask"
|
name = "flask"
|
||||||
version = "3.1.2"
|
version = "3.1.2"
|
||||||
@@ -162,6 +317,62 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fonttools"
|
||||||
|
version = "4.61.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
woff = [
|
||||||
|
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
|
||||||
|
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
|
||||||
|
{ name = "zopfli" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.3.1"
|
version = "3.3.1"
|
||||||
@@ -431,18 +642,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paddle-python-sdk"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/f9/b7e50071e3bae95b075524d0e7f083f1151dba1ffa08f9df5c2fc97b4844/paddle_python_sdk-1.13.0.tar.gz", hash = "sha256:1252286eba976e740527c2f39bef858904205845676327621a87f57b5edef702", size = 168592, upload-time = "2026-02-04T09:35:12.578Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/f7/228f1e598a9591355d843ad641bee9aeb1eb1d8eafd63f68a76d9776ba39/paddle_python_sdk-1.13.0-py3-none-any.whl", hash = "sha256:7cdddecc15f54929848fdf03e98926095a26c9c484e3e94e3035da836aad280e", size = 451680, upload-time = "2026-02-04T09:35:10.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "padelnomics"
|
name = "padelnomics"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "hypercorn" },
|
{ name = "hypercorn" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
|
{ name = "paddle-python-sdk" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
|
{ name = "resend" },
|
||||||
|
{ name = "weasyprint" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -459,12 +685,14 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
|
||||||
{ name = "hypercorn", specifier = ">=0.17.0" },
|
{ name = "hypercorn", specifier = ">=0.17.0" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
|
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
{ name = "quart", specifier = ">=0.19.0" },
|
{ name = "quart", specifier = ">=0.19.0" },
|
||||||
|
{ name = "resend", specifier = ">=2.22.0" },
|
||||||
|
{ name = "weasyprint", specifier = ">=68.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -478,6 +706,93 @@ dev = [
|
|||||||
{ name = "ruff", specifier = ">=0.3.0" },
|
{ name = "ruff", specifier = ">=0.3.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "playwright"
|
name = "playwright"
|
||||||
version = "1.58.0"
|
version = "1.58.0"
|
||||||
@@ -515,6 +830,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydyf"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyee"
|
name = "pyee"
|
||||||
version = "13.0.0"
|
version = "13.0.0"
|
||||||
@@ -536,6 +869,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyphen"
|
||||||
|
version = "0.17.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
@@ -649,6 +991,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "resend"
|
||||||
|
version = "2.22.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/75/df5e247aa2398a3cafb3136a731fd99760781ce1e27f8afd0355c527319b/resend-2.22.0.tar.gz", hash = "sha256:4f084ea371494e4c811e0417529daeee5c6198b127d2f49a8b287fbc0bc04f97", size = 31056, upload-time = "2026-02-16T14:49:18.009Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/20/9f565a733ab147a3e549b00830efff18140946b5e5b6279b77afd668129f/resend-2.22.0-py2.py3-none-any.whl", hash = "sha256:f3c7c6f23492eaed012ff6ba046dc595a4420d4d614ba6abd2ddd62e0ba810b4", size = 51654, upload-time = "2026-02-16T14:49:16.825Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "respx"
|
name = "respx"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
@@ -704,6 +1059,30 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinycss2"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyhtml5"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fd/03/6111ed99e9bf7dfa1c30baeef0e0fb7e0bd387bd07f8e5b270776fe1de3f/tinyhtml5-2.0.0.tar.gz", hash = "sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc", size = 179507, upload-time = "2024-10-29T15:37:14.078Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/de/27c57899297163a4a84104d5cec0af3b1ac5faf62f44667e506373c6b8ce/tinyhtml5-2.0.0-py3-none-any.whl", hash = "sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e", size = 39793, upload-time = "2024-10-29T15:37:11.743Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
@@ -722,6 +1101,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weasyprint"
|
||||||
|
version = "68.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
{ name = "cssselect2" },
|
||||||
|
{ name = "fonttools", extra = ["woff"] },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pydyf" },
|
||||||
|
{ name = "pyphen" },
|
||||||
|
{ name = "tinycss2" },
|
||||||
|
{ name = "tinyhtml5" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/db/3e/65c0f176e6fb5c2b0a1ac13185b366f727d9723541babfa7fa4309998169/weasyprint-68.1.tar.gz", hash = "sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e", size = 1542379, upload-time = "2026-02-06T15:04:11.203Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/dd/14eb73cea481ad8162d3b18a4850d4a84d6e804a22840cca207648532265/weasyprint-68.1-py3-none-any.whl", hash = "sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be", size = 319789, upload-time = "2026-02-06T15:04:09.189Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webencodings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.5"
|
version = "3.1.5"
|
||||||
@@ -745,3 +1152,22 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0a/4d/a8cc1768b2eda3c0c7470bf8059dcb94ef96d45dd91fc6edd29430d44072/zopfli-0.4.1.tar.gz", hash = "sha256:07a5cdc5d1aaa6c288c5d9f5a5383042ba743641abf8e2fd898dcad622d8a38e", size = 179001, upload-time = "2026-02-13T14:17:27.156Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2f/1a7082e9163ae3703b27d571720bf3c954a02a9cf1fdce47c51e70639256/zopfli-0.4.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54", size = 291570, upload-time = "2026-02-13T14:17:12.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/6f/4a1a88edf9fa0ce102703f38ab4dfb285b7cd2dde5389184264ec759e06e/zopfli-0.4.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07", size = 829437, upload-time = "2026-02-13T14:17:14.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/77/d231012ddcaac9d2e184bd7808e106a8a0048855912e2e1c902b3f383413/zopfli-0.4.1-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7bcee1b189d64ec33d1e05cfa1b6a1268c29329c382f6ca1bd6245b04925c57", size = 818542, upload-time = "2026-02-13T14:17:16.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/4e/9b23690c4ca14fbeae2a8f7f6b2006611bf4cd7d5bcb2d9e6c718bd4b0e9/zopfli-0.4.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:27823dc1161a4031d1c25925fd45d9868ec0cbc7692341830a7dcfa25063662c", size = 1778034, upload-time = "2026-02-13T14:17:17.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/1b/51f7c28d4cde639cac4f5d47ff615548c1d9809f43cbacdd66eba5cd679d/zopfli-0.4.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c22b6161f47f5bd34637dbaee6735abd287cd64e0d1ce28ef1871bf625f4b", size = 1863957, upload-time = "2026-02-13T14:17:19.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/4d/1ef17017d38eabe7ae28f18ef0f16d48966cc23a5657e4555fff61704539/zopfli-0.4.1-cp310-abi3-win32.whl", hash = "sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd", size = 82314, upload-time = "2026-02-13T14:17:20.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/94/806bc84b389c7d70051d7c9a0179cff52de8b9f8dc2fc25bcf0bca302986/zopfli-0.4.1-cp310-abi3-win_amd64.whl", hash = "sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b", size = 102186, upload-time = "2026-02-13T14:17:21.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/53/0afc94574553bad50d7add81f54eed1a864e13f91c3a342c99775a947ff9/zopfli-0.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:02086247dd12fda929f9bfe8b3962b6bcdbfc8c82e99255aebcf367867cf0760", size = 147127, upload-time = "2026-02-13T14:17:22.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/0d9e4bdfd3d646a36b8516a01dec4ccd2967554603801e7c2d6c72fede3d/zopfli-0.4.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a93c2ecafff372de6c0aa2212eff18a75f6c71a100372fee7b4b129cc0b6f9a7", size = 127349, upload-time = "2026-02-13T14:17:24.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user