From f29c56cbaae8d858210d765944c70d300514749e Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 17 Feb 2026 22:23:43 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 112 +++ padelnomics/pyproject.toml | 4 +- padelnomics/src/padelnomics/admin/routes.py | 447 +++++++++++- .../admin/templates/admin/feedback.html | 51 ++ .../admin/templates/admin/index.html | 53 +- .../admin/templates/admin/lead_detail.html | 124 ++++ .../admin/templates/admin/leads.html | 65 ++ .../admin/partials/lead_results.html | 49 ++ .../admin/partials/supplier_results.html | 53 ++ .../templates/admin/supplier_detail.html | 169 +++++ .../admin/templates/admin/suppliers.html | 60 ++ padelnomics/src/padelnomics/app.py | 2 + padelnomics/src/padelnomics/billing/routes.py | 264 +++++-- padelnomics/src/padelnomics/businessplan.py | 187 +++++ padelnomics/src/padelnomics/core.py | 73 +- padelnomics/src/padelnomics/credits.py | 206 ++++++ .../src/padelnomics/directory/routes.py | 49 +- .../directory/templates/partials/results.html | 18 +- .../directory/templates/supplier_detail.html | 122 ++++ padelnomics/src/padelnomics/leads/routes.py | 29 +- .../templates/partials/quote_step_1.html | 87 +++ .../templates/partials/quote_step_2.html | 42 ++ .../templates/partials/quote_step_3.html | 35 + .../templates/partials/quote_step_4.html | 35 + .../templates/partials/quote_step_5.html | 41 ++ .../templates/partials/quote_step_6.html | 51 ++ .../templates/partials/quote_step_7.html | 45 ++ .../templates/partials/quote_step_8.html | 41 ++ .../templates/partials/quote_step_9.html | 69 ++ .../leads/templates/quote_request.html | 405 +++-------- .../src/padelnomics/migrations/schema.sql | 108 ++- .../0007_phase1_credits_and_forwarding.py | 75 ++ .../0008_phase2_analytics_and_exports.py | 61 ++ padelnomics/src/padelnomics/planner/routes.py | 124 +++- .../padelnomics/planner/templates/export.html | 146 ++++ .../planner/templates/export_generating.html | 20 + .../planner/templates/export_success.html | 29 + .../planner/templates/planner.html | 210 +----- padelnomics/src/padelnomics/public/routes.py | 64 +- .../padelnomics/public/templates/landing.html | 10 +- .../public/templates/suppliers.html | 277 ++++++-- .../src/padelnomics/scripts/__init__.py | 0 .../src/padelnomics/scripts/setup_paddle.py | 211 ++++++ .../src/padelnomics/static/css/planner.css | 353 ++++----- .../src/padelnomics/static/js/planner.js | 149 +--- .../src/padelnomics/suppliers/__init__.py | 0 .../src/padelnomics/suppliers/routes.py | 670 ++++++++++++++++++ .../templates/suppliers/dashboard.html | 107 +++ .../templates/suppliers/lead_feed.html | 94 +++ .../suppliers/partials/dashboard_boosts.html | 154 ++++ .../suppliers/partials/dashboard_leads.html | 160 +++++ .../suppliers/partials/dashboard_listing.html | 178 +++++ .../partials/dashboard_overview.html | 84 +++ .../suppliers/partials/lead_card.html | 34 + .../suppliers/partials/lead_card_error.html | 3 + .../partials/lead_card_unlocked.html | 48 ++ .../suppliers/partials/signup_step_1.html | 32 + .../suppliers/partials/signup_step_2.html | 40 ++ .../suppliers/partials/signup_step_3.html | 37 + .../suppliers/partials/signup_step_4.html | 88 +++ .../suppliers/templates/suppliers/signup.html | 152 ++++ .../templates/suppliers/signup_success.html | 30 + .../src/padelnomics/templates/base.html | 42 ++ .../templates/businessplan/plan.css | 105 +++ .../templates/businessplan/plan.html | 218 ++++++ padelnomics/src/padelnomics/worker.py | 211 +++++- padelnomics/uv.lock | 430 ++++++++++- 67 files changed, 6795 insertions(+), 947 deletions(-) create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/feedback.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/lead_detail.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/leads.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/partials/lead_results.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/partials/supplier_results.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html create mode 100644 padelnomics/src/padelnomics/admin/templates/admin/suppliers.html create mode 100644 padelnomics/src/padelnomics/businessplan.py create mode 100644 padelnomics/src/padelnomics/credits.py create mode 100644 padelnomics/src/padelnomics/directory/templates/supplier_detail.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html create mode 100644 padelnomics/src/padelnomics/leads/templates/partials/quote_step_9.html create mode 100644 padelnomics/src/padelnomics/migrations/versions/0007_phase1_credits_and_forwarding.py create mode 100644 padelnomics/src/padelnomics/migrations/versions/0008_phase2_analytics_and_exports.py create mode 100644 padelnomics/src/padelnomics/planner/templates/export.html create mode 100644 padelnomics/src/padelnomics/planner/templates/export_generating.html create mode 100644 padelnomics/src/padelnomics/planner/templates/export_success.html create mode 100644 padelnomics/src/padelnomics/scripts/__init__.py create mode 100644 padelnomics/src/padelnomics/scripts/setup_paddle.py create mode 100644 padelnomics/src/padelnomics/suppliers/__init__.py create mode 100644 padelnomics/src/padelnomics/suppliers/routes.py create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/lead_feed.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_boosts.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_listing.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_overview.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_error.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_unlocked.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_1.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_2.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_3.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_4.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/signup.html create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/signup_success.html create mode 100644 padelnomics/src/padelnomics/templates/businessplan/plan.css create mode 100644 padelnomics/src/padelnomics/templates/businessplan/plan.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f296ab..b34ec15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,118 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [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 (`//website`, `//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/` (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/` detail with profile info, + credit balance + ledger, active boosts, lead forward history; `POST + /admin/suppliers//credits` manual credit adjustment; `POST + /admin/suppliers//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/` detail with project brief + + forward history, `POST /admin/leads//status` update, `POST + /admin/leads//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/` 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/`) — 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//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 - **Double opt-in email verification for quote requests** — guest quote submissions now require email verification before the lead goes live; diff --git a/padelnomics/pyproject.toml b/padelnomics/pyproject.toml index ae44751..65e306b 100644 --- a/padelnomics/pyproject.toml +++ b/padelnomics/pyproject.toml @@ -7,11 +7,13 @@ requires-python = ">=3.11" dependencies = [ "quart>=0.19.0", "aiosqlite>=0.19.0", - "httpx>=0.27.0", "python-dotenv>=1.0.0", "itsdangerous>=2.1.0", "jinja2>=3.1.0", "hypercorn>=0.17.0", + "paddle-python-sdk>=1.13.0", + "resend>=2.22.0", + "weasyprint>=68.1", ] [build-system] diff --git a/padelnomics/src/padelnomics/admin/routes.py b/padelnomics/src/padelnomics/admin/routes.py index e62f8e0..d64b471 100644 --- a/padelnomics/src/padelnomics/admin/routes.py +++ b/padelnomics/src/padelnomics/admin/routes.py @@ -8,7 +8,7 @@ from pathlib import Path 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 bp = Blueprint( @@ -51,14 +51,50 @@ async def get_dashboard_stats() -> dict: "SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL", (week_ago,) ) - + subs = await fetch_one( "SELECT COUNT(*) as count FROM subscriptions WHERE status = 'active'" ) - + 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'") - + + # 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 { "users_total": users_total["count"] if users_total 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, "tasks_pending": tasks_pending["count"] if tasks_pending 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: await flash("Could not delete task.", "error") 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/") +@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//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//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/") +@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//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//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) diff --git a/padelnomics/src/padelnomics/admin/templates/admin/feedback.html b/padelnomics/src/padelnomics/admin/templates/admin/feedback.html new file mode 100644 index 0000000..f2ac366 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/feedback.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Feedback - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Feedback

+

{{ feedback_list | length }} submissions shown

+
+ Back to Dashboard +
+ + {% if feedback_list %} +
+ + + + + + + + + + + {% for f in feedback_list %} + + + + + + + {% endfor %} + +
MessagePageUserDate
+

{{ f.message }}

+
{{ f.page_url or '-' }} + {% if f.email %} + {{ f.email }} + {% else %} + Anonymous + {% endif %} + {{ f.created_at[:16] if f.created_at else '-' }}
+
+ {% else %} +
+

No feedback yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/index.html b/padelnomics/src/padelnomics/admin/templates/admin/index.html index 0483fed..290ad3e 100644 --- a/padelnomics/src/padelnomics/admin/templates/admin/index.html +++ b/padelnomics/src/padelnomics/admin/templates/admin/index.html @@ -45,10 +45,61 @@ + +
+
+

Planner Users

+

{{ stats.planner_users }}

+
+
+

Total Leads

+

{{ stats.leads_total }}

+
+
+

New

+

{{ stats.leads_new }}

+
+
+

Verified

+

{{ stats.leads_verified }}

+
+
+

Unlocked

+

{{ stats.leads_unlocked }}

+
+
+ + +
+
+

Claimed Suppliers

+

{{ stats.suppliers_claimed }}

+
+
+

Growth Tier

+

{{ stats.suppliers_growth }}

+
+
+

Pro Tier

+

{{ stats.suppliers_pro }}

+
+
+

Credits Spent

+

{{ stats.total_credits_spent }}

+
+
+

Leads Forwarded

+

{{ stats.leads_unlocked_by_suppliers }}

+
+
+ -
+ diff --git a/padelnomics/src/padelnomics/admin/templates/admin/lead_detail.html b/padelnomics/src/padelnomics/admin/templates/admin/lead_detail.html new file mode 100644 index 0000000..e1c4148 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/lead_detail.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+ ← All Leads +

Lead #{{ lead.id }} + {% if lead.heat_score == 'hot' %}HOT + {% elif lead.heat_score == 'warm' %}WARM + {% else %}COOL{% endif %} +

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

Project Brief

+
+
Facility
+
{{ lead.facility_type or '-' }}
+
Courts
+
{{ lead.court_count or '-' }}
+
Glass
+
{{ lead.glass_type or '-' }}
+
Lighting
+
{{ lead.lighting_type or '-' }}
+
Build Context
+
{{ lead.build_context or '-' }}
+
Location
+
{{ lead.location or '-' }}, {{ lead.country or '-' }}
+
Timeline
+
{{ lead.timeline or '-' }}
+
Phase
+
{{ lead.location_status or '-' }}
+
Budget
+
{{ lead.budget_estimate or '-' }}
+
Financing
+
{{ lead.financing_status or '-' }}
+
Services
+
{{ lead.services_needed or '-' }}
+
Additional Info
+
{{ lead.additional_info or '-' }}
+
Credit Cost
+
{{ lead.credit_cost or '-' }} credits
+
+
+ + +
+
+

Contact

+
+
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 '-' }}
+
Created
+
{{ lead.created_at or '-' }}
+
Verified
+
{{ lead.verified_at or 'Not verified' }}
+
+
+ + +
+

Forward to Supplier

+
+ + + +
+
+
+
+ + + {% if lead.forwards %} +
+

Forward History

+
+ + + + + + {% for f in lead.forwards %} + + + + + + + {% endfor %} + +
SupplierCreditsStatusSent
{{ f.supplier_name }}{{ f.credit_cost }}{{ f.status }}{{ f.created_at[:16] if f.created_at else '-' }}
+
+
+ {% endif %} +
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/leads.html b/padelnomics/src/padelnomics/admin/templates/admin/leads.html new file mode 100644 index 0000000..5e53649 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/leads.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Lead Management

+

+ {{ leads | length }} leads shown + {% if lead_stats %} + · {{ lead_stats.get('new', 0) }} new + · {{ lead_stats.get('forwarded', 0) }} forwarded + {% endif %} +

+
+ Back to Dashboard +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ {% include "admin/partials/lead_results.html" %} +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/partials/lead_results.html b/padelnomics/src/padelnomics/admin/templates/admin/partials/lead_results.html new file mode 100644 index 0000000..dc0b006 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/partials/lead_results.html @@ -0,0 +1,49 @@ +{% if leads %} +
+ + + + + + + + + + + + + + + + {% for lead in leads %} + + + + + + + + + + + + {% endfor %} + +
IDHeatContactCountryCourtsBudgetStatusUnlocksCreated
#{{ lead.id }} + {% if lead.heat_score == 'hot' %} + HOT + {% elif lead.heat_score == 'warm' %} + WARM + {% else %} + COOL + {% endif %} + + {{ lead.contact_name or '-' }}
+ {{ lead.contact_email or '-' }} +
{{ lead.country or '-' }}{{ lead.court_count or '-' }}{{ lead.budget_estimate or '-' }}{{ lead.status }}{{ lead.unlock_count or 0 }}{{ lead.created_at[:10] if lead.created_at else '-' }}
+
+{% else %} +
+

No leads match the current filters.

+
+{% endif %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/partials/supplier_results.html b/padelnomics/src/padelnomics/admin/templates/admin/partials/supplier_results.html new file mode 100644 index 0000000..ac811b9 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/partials/supplier_results.html @@ -0,0 +1,53 @@ +{% if suppliers %} +
+ + + + + + + + + + + + + + + {% for s in suppliers %} + + + + + + + + + + + {% endfor %} + +
IDNameCountryCategoryTierCreditsClaimedCreated
#{{ s.id }} + {{ s.name }} + {% if s.slug %}
{{ s.slug }}{% endif %} +
{{ s.country_code or '-' }}{{ s.category or '-' }} + {% if s.tier == 'pro' %} + PRO + {% elif s.tier == 'growth' %} + GROWTH + {% else %} + FREE + {% endif %} + {{ s.credit_balance or 0 }} + {% if s.claimed_by %} + Yes + {% else %} + No + {% endif %} + {{ s.created_at[:10] if s.created_at else '-' }}
+
+{% else %} +
+

No suppliers match the current filters.

+
+{% endif %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html b/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html new file mode 100644 index 0000000..66ca7b7 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} +{% block title %}{{ supplier.name }} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+ ← All Suppliers +

{{ supplier.name }} + {% if supplier.tier == 'pro' %}PRO + {% elif supplier.tier == 'growth' %}GROWTH + {% else %}FREE{% endif %} +

+

{{ supplier.slug }} · {{ supplier.country_code or '-' }}

+
+ View Profile +
+ +
+ +
+

Company Info

+
+
Name
+
{{ supplier.name }}
+
Slug
+
{{ supplier.slug }}
+
Category
+
{{ supplier.category or '-' }}
+
Country
+
{{ supplier.country_code or '-' }}
+
City
+
{{ supplier.city or '-' }}
+
Website
+
{% if supplier.website %}{{ supplier.website }}{% else %}-{% endif %}
+
Contact
+
{{ supplier.contact_name or '-' }}
+ {{ supplier.contact_email or '-' }}
+
Tagline
+
{{ supplier.tagline or '-' }}
+
Description
+
{{ supplier.short_description or '-' }}
+
Years
+
{{ supplier.years_in_business or '-' }}
+
Projects
+
{{ supplier.project_count or '-' }}
+
Claimed By
+
{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}
+
Created
+
{{ supplier.created_at or '-' }}
+
+
+ +
+ +
+

Tier

+
+ + + +
+
+ + +
+

Credits

+

{{ credit_balance }} credits

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

Active Boosts

+ {% if boosts %} + + + + {% for b in boosts %} + + + + + + {% endfor %} + +
BoostStatusActivated
{{ b.boost_type }}{{ b.status }}{{ b.created_at[:10] if b.created_at else '-' }}
+ {% else %} +

No active boosts.

+ {% endif %} +
+
+
+ + +
+

Credit Ledger (last 50)

+
+ {% if ledger %} + + + + + + {% for entry in ledger %} + + + + + + + + {% endfor %} + +
TypeAmountBalance AfterNoteDate
{{ entry.entry_type }} + {% if entry.amount > 0 %} + +{{ entry.amount }} + {% else %} + {{ entry.amount }} + {% endif %} + {{ entry.balance_after }}{{ entry.note or '-' }}{{ entry.created_at[:16] if entry.created_at else '-' }}
+ {% else %} +

No credit history.

+ {% endif %} +
+
+ + +
+

Lead Forward History

+
+ {% if forwards %} + + + + + + {% for f in forwards %} + + + + + + + {% endfor %} + +
LeadCreditsStatusDate
#{{ f.lead_id }}{{ f.credit_cost }}{{ f.status }}{{ f.created_at[:16] if f.created_at else '-' }}
+ {% else %} +

No leads forwarded yet.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html b/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html new file mode 100644 index 0000000..85eccad --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/admin/suppliers.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Supplier Management - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Supplier Management

+

+ {{ suppliers | length }} suppliers shown + · {{ supplier_stats.claimed }} claimed + · {{ supplier_stats.growth }} Growth + · {{ supplier_stats.pro }} Pro +

+
+ Back to Dashboard +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ {% include "admin/partials/supplier_results.html" %} +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/app.py b/padelnomics/src/padelnomics/app.py index 7c766fc..9146a6b 100644 --- a/padelnomics/src/padelnomics/app.py +++ b/padelnomics/src/padelnomics/app.py @@ -85,6 +85,7 @@ def create_app() -> Quart: from .leads.routes import bp as leads_bp from .planner.routes import bp as planner_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(auth_bp) @@ -93,6 +94,7 @@ def create_app() -> Quart: app.register_blueprint(planner_bp) app.register_blueprint(leads_bp) app.register_blueprint(directory_bp) + app.register_blueprint(suppliers_bp) app.register_blueprint(admin_bp) # Request ID tracking diff --git a/padelnomics/src/padelnomics/billing/routes.py b/padelnomics/src/padelnomics/billing/routes.py index fec3d1b..a184626 100644 --- a/padelnomics/src/padelnomics/billing/routes.py +++ b/padelnomics/src/padelnomics/billing/routes.py @@ -8,11 +8,19 @@ from datetime import datetime from functools import wraps 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 ..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 bp = Blueprint( @@ -152,37 +160,24 @@ async def success(): # ============================================================================= -# Paddle Implementation +# Paddle Implementation — Paddle.js Overlay Checkout # ============================================================================= @bp.route("/checkout/", methods=["POST"]) @login_required async def checkout(plan: str): - """Create Paddle checkout via API.""" - price_id = config.PADDLE_PRICES.get(plan) + """Return JSON for Paddle.js overlay checkout.""" + price_id = await get_paddle_price(plan) if not price_id: - await flash("Invalid plan selected.", "error") - return redirect(url_for("billing.pricing")) + return jsonify({"error": "Invalid plan selected."}), 400 - async with httpx.AsyncClient() as client: - response = await client.post( - "https://api.paddle.com/transactions", - headers={ - "Authorization": f"Bearer {config.PADDLE_API_KEY}", - "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) + return jsonify({ + "items": [{"priceId": price_id, "quantity": 1}], + "customData": {"user_id": str(g.user["id"]), "plan": plan}, + "settings": { + "successUrl": f"{config.BASE_URL}/billing/success", + }, + }) @bp.route("/manage", methods=["POST"]) @@ -194,14 +189,9 @@ async def manage(): await flash("No active subscription found.", "error") return redirect(url_for("dashboard.settings")) - async with httpx.AsyncClient() as client: - response = await client.get( - f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}", - headers={"Authorization": f"Bearer {config.PADDLE_API_KEY}"}, - ) - response.raise_for_status() - - portal_url = response.json()["data"]["management_urls"]["update_payment_method"] + paddle = _paddle_client() + paddle_sub = paddle.subscriptions.get(sub["paddle_subscription_id"]) + portal_url = paddle_sub.management_urls.update_payment_method return redirect(portal_url) @@ -211,15 +201,12 @@ async def cancel(): """Cancel subscription via Paddle API.""" sub = await get_subscription(g.user["id"]) if sub and sub.get("paddle_subscription_id"): - async with httpx.AsyncClient() as client: - await client.post( - f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}/cancel", - headers={ - "Authorization": f"Bearer {config.PADDLE_API_KEY}", - "Content-Type": "application/json", - }, - json={"effective_from": "next_billing_period"}, - ) + from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription + paddle = _paddle_client() + paddle.subscriptions.cancel( + sub["paddle_subscription_id"], + CancelSubscription(effective_from="next_billing_period"), + ) return redirect(url_for("dashboard.settings")) @@ -229,25 +216,31 @@ async def webhook(): payload = await request.get_data() sig = request.headers.get("Paddle-Signature", "") - if not verify_hmac_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET): - return jsonify({"error": "Invalid signature"}), 400 + if config.PADDLE_WEBHOOK_SECRET: + try: + Verifier().verify(payload, Secret(config.PADDLE_WEBHOOK_SECRET), sig) + except Exception: + return jsonify({"error": "Invalid signature"}), 400 event = json.loads(payload) event_type = event.get("event_type") data = event.get("data", {}) custom_data = data.get("custom_data", {}) user_id = custom_data.get("user_id") + plan = custom_data.get("plan", "") if event_type == "subscription.activated": - plan = custom_data.get("plan", "starter") - await upsert_subscription( - user_id=int(user_id) if user_id else 0, - plan=plan, - status="active", - provider_customer_id=str(data.get("customer_id", "")), - provider_subscription_id=data.get("id", ""), - current_period_end=data.get("current_billing_period", {}).get("ends_at"), - ) + if plan.startswith("supplier_"): + await _handle_supplier_subscription_activated(data, custom_data) + else: + await upsert_subscription( + user_id=int(user_id) if user_id else 0, + plan=plan or "starter", + status="active", + provider_customer_id=str(data.get("customer_id", "")), + provider_subscription_id=data.get("id", ""), + current_period_end=data.get("current_billing_period", {}).get("ends_at"), + ) elif event_type == "subscription.updated": await update_subscription_status( @@ -262,7 +255,170 @@ async def webhook(): elif event_type == "subscription.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}) +# ============================================================================= +# 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, + }) diff --git a/padelnomics/src/padelnomics/businessplan.py b/padelnomics/src/padelnomics/businessplan.py new file mode 100644 index 0000000..b3d2678 --- /dev/null +++ b/padelnomics/src/padelnomics/businessplan.py @@ -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 diff --git a/padelnomics/src/padelnomics/core.py b/padelnomics/src/padelnomics/core.py index a1c12f5..5f2a9a1 100644 --- a/padelnomics/src/padelnomics/core.py +++ b/padelnomics/src/padelnomics/core.py @@ -11,7 +11,7 @@ from functools import wraps from pathlib import Path import aiosqlite -import httpx +import resend from dotenv import load_dotenv from quart import g, request, session @@ -41,16 +41,18 @@ class Config: PAYMENT_PROVIDER: str = "paddle" 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_PRICES: dict = { - "starter": os.getenv("PADDLE_PRICE_STARTER", ""), - "pro": os.getenv("PADDLE_PRICE_PRO", ""), - "business_plan": os.getenv("PADDLE_PRICE_BUSINESS_PLAN", ""), - } + PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox") + + UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io") + 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", "") EMAIL_FROM: str = _env("EMAIL_FROM", "hello@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_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) @@ -161,25 +163,34 @@ class transaction: # Email # ============================================================================= -async def send_email(to: str, subject: str, html: str, text: str = None) -> bool: - """Send email via Resend API.""" +EMAIL_ADDRESSES = { + "transactional": "Padelnomics ", + "leads": "Padelnomics Leads ", + "nurture": "Padelnomics ", +} + + +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: print(f"[EMAIL] Would send to {to}: {subject}") return True - - async with httpx.AsyncClient() as client: - response = await client.post( - "https://api.resend.com/emails", - headers={"Authorization": f"Bearer {config.RESEND_API_KEY}"}, - json={ - "from": config.EMAIL_FROM, - "to": to, - "subject": subject, - "html": html, - "text": text or html, - }, - ) - return response.status_code == 200 + + resend.api_key = config.RESEND_API_KEY + try: + resend.Emails.send({ + "from": from_addr or config.EMAIL_FROM, + "to": to, + "subject": subject, + "html": html, + "text": text or html, + }) + return True + except Exception as e: + print(f"[EMAIL] Error sending to {to}: {e}") + return False # ============================================================================= # 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 < ?", (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} diff --git a/padelnomics/src/padelnomics/credits.py b/padelnomics/src/padelnomics/credits.py new file mode 100644 index 0000000..ac58f37 --- /dev/null +++ b/padelnomics/src/padelnomics/credits.py @@ -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), + ) diff --git a/padelnomics/src/padelnomics/directory/routes.py b/padelnomics/src/padelnomics/directory/routes.py index 2d180ae..5f1d3a7 100644 --- a/padelnomics/src/padelnomics/directory/routes.py +++ b/padelnomics/src/padelnomics/directory/routes.py @@ -1,10 +1,10 @@ """ Supplier directory: public, searchable listing of padel court suppliers. """ -from datetime import datetime, timezone +from datetime import UTC, datetime 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 @@ -53,7 +53,7 @@ REGION_LABELS = { async def _build_directory_query(q, country, category, region, page, per_page=24): """Shared query builder for directory index and HTMX results.""" - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(UTC).isoformat() params: list = [] wheres: list[str] = [] @@ -152,6 +152,49 @@ async def index(): ) +@bp.route("/") +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("//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("//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") async def results(): """HTMX endpoint — returns only the results partial.""" diff --git a/padelnomics/src/padelnomics/directory/templates/partials/results.html b/padelnomics/src/padelnomics/directory/templates/partials/results.html index db40662..ddc0e15 100644 --- a/padelnomics/src/padelnomics/directory/templates/partials/results.html +++ b/padelnomics/src/padelnomics/directory/templates/partials/results.html @@ -31,7 +31,7 @@ {% for s in suppliers %} {# --- Pro tier card --- #} {% if s.tier == 'pro' %} -
+ {% if s.sticky_until and s.sticky_until > now %}{% endif %}
@@ -47,14 +47,15 @@ {% endif %}
- {% if s.website %}{{ s.website }}{% endif %} + {% if s.website %}{{ s.website }}{% endif %} + Request Quote →
-
+ {# --- Growth tier card --- #} {% elif s.tier == 'growth' %} -
+ {% if s.sticky_until and s.sticky_until > now %}{% endif %}

{{ s.name }}

@@ -67,12 +68,13 @@ {% endif %}
+ Request Quote →
-
+
{# --- Free / unclaimed tier card --- #} {% else %} - diff --git a/padelnomics/src/padelnomics/directory/templates/supplier_detail.html b/padelnomics/src/padelnomics/directory/templates/supplier_detail.html new file mode 100644 index 0000000..ed9520d --- /dev/null +++ b/padelnomics/src/padelnomics/directory/templates/supplier_detail.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% block title %}{{ supplier.name }} - Supplier Directory - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+ ← Back to Directory + +
+
+ {% if supplier.logo_url %} + + {% else %} +
{{ supplier.name[0] }}
+ {% endif %} +
+

{{ supplier.name }}

+

{{ country_labels.get(supplier.country_code, supplier.country_code) }}{% if supplier.city %}, {{ supplier.city }}{% endif %}

+
+ {{ category_labels.get(supplier.category, supplier.category) }} + {% if supplier.is_verified %} + Verified ✓ + {% endif %} + {% if supplier.tier != 'free' %} + {{ supplier.tier | title }} + {% endif %} +
+
+
+ + {% set desc = supplier.long_description or supplier.description %} + {% if desc %} +

{{ desc }}

+ {% endif %} + + {% if supplier.service_categories %} +
+ {% for cat in (supplier.service_categories or '').split(',') %} + {% if cat.strip() %} + {{ cat.strip() }} + {% endif %} + {% endfor %} +
+ {% endif %} + +
+ {% if supplier.service_area %} +
+
Service Area
+
{{ supplier.service_area }}
+
+ {% endif %} + {% if supplier.years_in_business %} +
+
Years in Business
+
{{ supplier.years_in_business }}
+
+ {% endif %} + {% if supplier.project_count %} +
+
Projects Completed
+
{{ supplier.project_count }}
+
+ {% endif %} + {% if supplier.website %} +
+
Website
+
{{ supplier.website }}
+
+ {% endif %} +
+ +
+ Request Quote + {% if not supplier.claimed_by %} + Claim This Listing + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/leads/routes.py b/padelnomics/src/padelnomics/leads/routes.py index e15774e..2f922a3 100644 --- a/padelnomics/src/padelnomics/leads/routes.py +++ b/padelnomics/src/padelnomics/leads/routes.py @@ -277,6 +277,10 @@ async def quote_request(): 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 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, contact_name, contact_email, contact_phone, contact_company, stakeholder_type, - heat_score, status, created_at) - VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + heat_score, status, credit_cost, created_at) + VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( user_id, form.get("court_count", 0), @@ -325,10 +329,23 @@ async def quote_request(): form.get("stakeholder_type", ""), heat, status, + credit_cost, 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: # Existing flow: notify admin immediately await send_email( @@ -441,11 +458,13 @@ async def verify_quote(): # Mark token used 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() await execute( - "UPDATE lead_requests SET status = 'new', verified_at = ? WHERE id = ?", - (now, lead_id), + "UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?", + (now, credit_cost, lead_id), ) # Set user name from contact_name if not already set diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html new file mode 100644 index 0000000..e199582 --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_1.html @@ -0,0 +1,87 @@ +{# Step 1: Your Project #} +{% if data.get('facility_type') %} +{# Pre-filled from planner — show read-only summary #} +

Your Project

+

Pre-filled from the planner. You can edit these in the planner.

+ +
+
+
Facility
{{ data.facility_type | replace('_',' ') | title }}
+ {% if data.get('court_count') %}
Courts
{{ data.court_count }}
{% endif %} + {% if data.get('glass_type') %}
Glass
{{ data.glass_type | replace('_',' ') | title }}
{% endif %} + {% if data.get('lighting_type') %}
Lighting
{{ data.lighting_type | replace('_',' ') | title }}
{% endif %} + {% if data.get('budget_estimate') %}
Budget
€{{ data.budget_estimate }}
{% endif %} +
+
+ +
+ + + + +
+
+ +
+
+ +{% else %} +{# Direct visit — show full form #} +
+ + + +

Your Project

+

What type of padel facility are you planning?

+ +
+ Facility Type * + {% if 'facility_type' in errors %}

Please select a facility type

{% endif %} +
+ {% for val, label in [('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('both', 'Indoor + Outdoor')] %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ Glass Type +
+ {% for val, label in [('standard', 'Standard Glass'), ('panoramic', 'Panoramic Glass'), ('no_preference', 'No Preference')] %} + + {% endfor %} +
+
+ +
+ Lighting +
+ {% for val, label in [('led_standard', 'LED Standard'), ('led_competition', 'LED Competition'), ('natural', 'Natural Light'), ('not_sure', 'Not Sure')] %} + + {% endfor %} +
+
+ +
+
+ +
+
+{% endif %} + +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html new file mode 100644 index 0000000..154beab --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_2.html @@ -0,0 +1,42 @@ +{# Step 2: Location #} +
+ + + +

Location

+

Where are you planning to build?

+ +
+ + +
+ +
+ + {% if 'country' in errors %}

Please select a country

{% endif %} + +
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html new file mode 100644 index 0000000..1c35f57 --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_3.html @@ -0,0 +1,35 @@ +{# Step 3: Build Context #} +
+ + + +

Build Context

+

What best describes your project?

+ +
+ Build Context +
+ {% 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')] %} + + {% endfor %} +
+
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html new file mode 100644 index 0000000..9362bd6 --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_4.html @@ -0,0 +1,35 @@ +{# Step 4: Project Phase #} +
+ + + +

Project Phase

+

Where are you in the process?

+ +
+ Project Phase +
+ {% 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')] %} + + {% endfor %} +
+
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html new file mode 100644 index 0000000..a59c35a --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_5.html @@ -0,0 +1,41 @@ +{# Step 5: Timeline #} +
+ + + +

Timeline

+

When do you want to get started?

+ +
+ Timeline * + {% if 'timeline' in errors %}

Please select a timeline

{% endif %} +
+ {% for val, label in [('asap', 'ASAP'), ('3-6mo', '3-6 Months'), ('6-12mo', '6-12 Months'), ('12+mo', '12+ Months')] %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html new file mode 100644 index 0000000..a0c3eef --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_6.html @@ -0,0 +1,51 @@ +{# Step 6: Financing #} +
+ + + +

Financing

+

How are you funding the project?

+ +
+ Financing Status +
+ {% for val, label in [('self_funded', 'Self-Funded'), ('loan_approved', 'Loan Approved'), ('seeking', 'Seeking Financing'), ('not_started', 'Not Started')] %} + + {% endfor %} +
+
+ +
+ +
+ +
+ Decision Process +
+ {% for val, label in [('solo', 'Solo Decision'), ('partners', 'With Partners'), ('committee', 'Committee / Board')] %} + + {% endfor %} +
+
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html new file mode 100644 index 0000000..06b9498 --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_7.html @@ -0,0 +1,45 @@ +{# Step 7: About You #} +
+ + + +

About You

+

This helps us match you with the right suppliers.

+ +
+ You are... * + {% if 'stakeholder_type' in errors %}

Please select your role

{% endif %} +
+ {% 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')] %} + + {% endfor %} +
+
+ +
+ Have you contacted suppliers before? +
+ {% for val, label in [('first_time', 'First time'), ('researching', 'Researching options'), ('received_quotes', 'Already received quotes')] %} + + {% endfor %} +
+
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html new file mode 100644 index 0000000..92dac5d --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_8.html @@ -0,0 +1,41 @@ +{# Step 8: Services Needed #} +
+ + + +

Services Needed

+

Select all that apply. This helps suppliers prepare relevant proposals.

+ +
+ Services (select all that apply) +
+ {% 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')] %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/partials/quote_step_9.html b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_9.html new file mode 100644 index 0000000..d364f0c --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/partials/quote_step_9.html @@ -0,0 +1,69 @@ +{# Step 9: Contact Details — final submit #} +
+ + {# Expand accumulated data into individual hidden fields for the POST handler #} + {% for key, val in data.items() %} + {% if key != 'services_needed' %} + + {% endif %} + {% endfor %} + {% for svc in data.get('services_needed', []) %} + + {% endfor %} + +

Contact Details

+

How should matched suppliers reach you?

+ +
+ + Your contact details are shared only with pre-vetted suppliers that match your project specs. +
+ +
+ + {% if 'contact_name' in errors %}

Full name is required

{% endif %} + +
+ +
+ + {% if 'contact_email' in errors %}

Email is required

{% endif %} + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +

No obligation.

+
+ +
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
+
diff --git a/padelnomics/src/padelnomics/leads/templates/quote_request.html b/padelnomics/src/padelnomics/leads/templates/quote_request.html index 6df6c5a..c2e567c 100644 --- a/padelnomics/src/padelnomics/leads/templates/quote_request.html +++ b/padelnomics/src/padelnomics/leads/templates/quote_request.html @@ -3,337 +3,120 @@ {% block head %} {% endblock %} {% block content %}
-
- -
-
-
-
+
+
+
+ {{ steps[step - 1].title }} + {{ step }} of {{ steps | length }} +
+
+
+
-
- Project - Details - Contact -
- - -
-
- - - -
-

Tell us about your project

-

This helps us match you with the right suppliers. Share what you know.

- -
- Facility Type * -
- {% for val, label in [('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('both', 'Indoor + Outdoor')] %} - - {% endfor %} -
-
- -
- - -
- -
- Glass Type -
- {% for val, label in [('standard', 'Standard Glass'), ('panoramic', 'Panoramic Glass'), ('no_preference', 'No Preference')] %} - - {% endfor %} -
-
- -
- Lighting -
- {% for val, label in [('led_standard', 'LED Standard'), ('led_competition', 'LED Competition'), ('natural', 'Natural Light'), ('not_sure', 'Not Sure')] %} - - {% endfor %} -
-
- -
- Build Context -
- {% 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')] %} - - {% endfor %} -
-
- -
-
- -
-
- - -
-

Project details

-

Help suppliers understand your timeline and scope.

- -
- - -
- -
- - -
- -
- Project Phase -
- {% 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')] %} - - {% endfor %} -
-
- -
- Timeline * -
- {% for val, label in [('asap', 'ASAP'), ('3-6mo', '3-6 Months'), ('6-12mo', '6-12 Months'), ('12+mo', '12+ Months')] %} - - {% endfor %} -
-
- -
- - -
- -
- Financing Status -
- {% for val, label in [('self_funded', 'Self-Funded'), ('loan_approved', 'Loan Approved'), ('seeking', 'Seeking Financing'), ('not_started', 'Not Started')] %} - - {% endfor %} -
-
- -
- -
- -
- Decision Process -
- {% for val, label in [('solo', 'Solo Decision'), ('partners', 'With Partners'), ('committee', 'Committee / Board')] %} - - {% endfor %} -
-
- -
- You are... * -
- {% 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')] %} - - {% endfor %} -
-
- -
- Services Needed (select all that apply) -
- {% for val, label in [('court_supply', 'Court Supply'), ('installation', 'Installation'), ('construction', 'Hall Construction'), ('design', 'Facility Design'), ('lighting', 'Lighting'), ('flooring', 'Flooring'), ('turnkey', 'Full Turnkey')] %} - - {% endfor %} -
-
- -
- - -
- -
- - -
-
- - -
-

How should suppliers reach you?

-

Matched suppliers will contact you directly with tailored proposals.

- -
- - Your contact details are shared only with 2-5 pre-vetted suppliers that match your project specs. -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
- - -
- -

No obligation.

-
-
+
+
+ {% include "partials/quote_step_" ~ step ~ ".html" %} +
{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/padelnomics/src/padelnomics/migrations/schema.sql b/padelnomics/src/padelnomics/migrations/schema.sql index 1fc173b..c5bb24f 100644 --- a/padelnomics/src/padelnomics/migrations/schema.sql +++ b/padelnomics/src/padelnomics/migrations/schema.sql @@ -155,7 +155,11 @@ CREATE TABLE IF NOT EXISTS lead_requests ( contact_company TEXT, stakeholder_type TEXT, 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); @@ -181,7 +185,25 @@ CREATE TABLE IF NOT EXISTS suppliers ( is_verified INTEGER NOT NULL DEFAULT 0, highlight INTEGER NOT NULL DEFAULT 0, 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); @@ -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) VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category); 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')) +); diff --git a/padelnomics/src/padelnomics/migrations/versions/0007_phase1_credits_and_forwarding.py b/padelnomics/src/padelnomics/migrations/versions/0007_phase1_credits_and_forwarding.py new file mode 100644 index 0000000..7e0565c --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0007_phase1_credits_and_forwarding.py @@ -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") diff --git a/padelnomics/src/padelnomics/migrations/versions/0008_phase2_analytics_and_exports.py b/padelnomics/src/padelnomics/migrations/versions/0008_phase2_analytics_and_exports.py new file mode 100644 index 0000000..1228d8c --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0008_phase2_analytics_and_exports.py @@ -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}") diff --git a/padelnomics/src/padelnomics/planner/routes.py b/padelnomics/src/padelnomics/planner/routes.py index e4cd61c..bed0852 100644 --- a/padelnomics/src/padelnomics/planner/routes.py +++ b/padelnomics/src/padelnomics/planner/routes.py @@ -5,10 +5,10 @@ import json from datetime import datetime 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 ..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 bp = Blueprint( @@ -94,6 +94,8 @@ async def save_scenario(): now = datetime.utcnow().isoformat() + is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0 + if scenario_id: # Update existing await execute( @@ -107,6 +109,20 @@ async def save_scenario(): (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"]) 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"]), ) 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/") +@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"' + }, + ) diff --git a/padelnomics/src/padelnomics/planner/templates/export.html b/padelnomics/src/padelnomics/planner/templates/export.html new file mode 100644 index 0000000..7212f0c --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/export.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} +{% block title %}Export Business Plan - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

Export Business Plan (PDF)

+

Bank-ready financial projections from your planner scenario.

+
+ +
€99 one-time
+ +
    +
  • Executive summary
  • +
  • CAPEX breakdown
  • +
  • 5-year P&L projection
  • +
  • 12-month cash flow
  • +
  • Financing structure
  • +
  • Key metrics (IRR, MOIC, DSCR)
  • +
  • Sensitivity analysis
  • +
  • English or German
  • +
+ +
+
+ + + + + + + + +
+
+ + {% if exports %} +
+

Your Exports

+ {% for e in exports %} + + {% endfor %} +
+ {% endif %} + +

+ ← Back to Planner +

+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/padelnomics/src/padelnomics/planner/templates/export_generating.html b/padelnomics/src/padelnomics/planner/templates/export_generating.html new file mode 100644 index 0000000..e01ec82 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/export_generating.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Generating PDF - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Generating Your Business Plan

+

This usually takes less than a minute. This page will auto-refresh.

+ + + +

+ View All Exports +

+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/padelnomics/src/padelnomics/planner/templates/export_success.html b/padelnomics/src/padelnomics/planner/templates/export_success.html new file mode 100644 index 0000000..303deda --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/export_success.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Export Ready - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Payment Received

+

Your business plan PDF is being generated. This usually takes less than a minute.

+ + {% if exports %} + {% for e in exports %} + {% if e.status == 'ready' %} + Download PDF + {% else %} +
+ 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. +
+ + {% endif %} + {% endfor %} + {% endif %} + +

+ View All Exports + · + Back to Planner +

+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/planner/templates/planner.html b/padelnomics/src/padelnomics/planner/templates/planner.html index 3a66d96..e0a2416 100644 --- a/padelnomics/src/padelnomics/planner/templates/planner.html +++ b/padelnomics/src/padelnomics/planner/templates/planner.html @@ -15,7 +15,6 @@

Padel Court Financial Planner

- v2.1 {% if user %} @@ -105,164 +104,11 @@
- -
-

Get Quotes from Suppliers

-

Your project specs are pre-filled from the planner. Complete a few details and we'll match you with verified court builders.

- -
- -
- - - - - - - - -
- - -
- -
- Build Context -
- - - - -
-
- -
- Project Phase -
- - - - - - - -
-
- -
- Timeline * -
- - - - -
-
- -
- Financing Status -
- - - - -
-
- -
- -
- -
- Decision Process -
- - - -
-
- -
- You are... * -
- - - - - - -
-
- -
- Services Needed (select all that apply) -
- - - - - - - -
-
- -
- - -
- -
- -
- - -
-
- - -
-
- - -
-
- - -
- -
- - Your contact details are shared only with 2-5 pre-vetted suppliers that match your project specs. -
- - -
- - + + - - -
-
@@ -273,10 +119,6 @@
CAPEX Breakdown
-
- These are estimates. Get actual quotes from verified court suppliers. - Get Builder Quotes -
@@ -344,11 +186,6 @@

Pricing Sensitivity (at target utilization)

-
- Your project looks profitable. Ready to take the next step? - Get Builder Quotes - Export Business Plan (PDF) — €99 -
@@ -360,13 +197,45 @@

Investment Efficiency

Operational

+ +
+
Next Step
+

Get quotes from verified court suppliers

+

Share your project specs and we'll connect you with matched suppliers.

+
    +
  • Matched suppliers
  • +
  • Direct contact, no middleman
  • +
  • No commitment
  • +
  • Your data stays private
  • +
+ + Takes ~2 minutes +
+ + + {% if not user %} {% endif %} @@ -385,7 +254,6 @@ window.__PADELNOMICS_INITIAL_D__ = {{ initial_d | safe }}; window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}"; window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}"; window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/"; -window.__PADELNOMICS_QUOTE_URL__ = "{{ url_for('leads.quote_request') }}"; {% endblock %} diff --git a/padelnomics/src/padelnomics/public/routes.py b/padelnomics/src/padelnomics/public/routes.py index de25273..4063072 100644 --- a/padelnomics/src/padelnomics/public/routes.py +++ b/padelnomics/src/padelnomics/public/routes.py @@ -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 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( "public", @@ -59,10 +59,38 @@ async def about(): @bp.route("/suppliers") async def suppliers(): 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( "suppliers.html", total_suppliers=total_suppliers, 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}\n" 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 '

Too many submissions. Try again later.

' + + form = await request.form + message = form.get("message", "").strip() + if not message: + return '

Please enter a message.

' + + 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 '

Thank you for your feedback!

' diff --git a/padelnomics/src/padelnomics/public/templates/landing.html b/padelnomics/src/padelnomics/public/templates/landing.html index 889c755..c5eb2f0 100644 --- a/padelnomics/src/padelnomics/public/templates/landing.html +++ b/padelnomics/src/padelnomics/public/templates/landing.html @@ -126,7 +126,7 @@ Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers.

@@ -172,7 +172,7 @@

Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.

- Open Full Planner → + Plan Your Padel Business → @@ -255,7 +255,7 @@
3

Compare & Build

-

Receive proposals from 2-5 relevant suppliers. No cold outreach needed.

+

Receive proposals from matched suppliers. No cold outreach needed.

@@ -277,7 +277,7 @@
How does supplier matching work? -

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.

+

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.

Is the supplier directory free? @@ -307,7 +307,7 @@

Start Planning Today

Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.

- Open the Planner + Plan Your Padel Business
{% endblock %} diff --git a/padelnomics/src/padelnomics/public/templates/suppliers.html b/padelnomics/src/padelnomics/public/templates/suppliers.html index 4ff2438..2a7e432 100644 --- a/padelnomics/src/padelnomics/public/templates/suppliers.html +++ b/padelnomics/src/padelnomics/public/templates/suppliers.html @@ -11,10 +11,15 @@ .sup-hero .btn { margin-top: 1.5rem; padding: 14px 32px; font-size: 1rem; } .sup-stats { - display: flex; justify-content: center; gap: 3rem; padding: 1.5rem 0 2rem; - font-size: 0.875rem; color: #64748B; + display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; + 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 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 p { font-size: 0.8125rem; color: #64748B; } - .sup-lead-preview { + /* Credit explainer */ + .credit-explainer { background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 16px; 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); } - .sup-lead-preview h4 { font-size: 0.875rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; } - .sup-lead-preview dl { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 1rem; font-size: 0.8125rem; } - .sup-lead-preview dt { color: #94A3B8; } - .sup-lead-preview dd { color: #1E293B; font-weight: 500; margin: 0; } + .sup-lead-preview h4 { + font-size: 0.875rem; color: #94A3B8; text-transform: uppercase; + letter-spacing: 0.04em; margin-bottom: 1rem; + } + .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-grid { @@ -64,14 +107,15 @@ letter-spacing: 0.04em; } .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 .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 li { font-size: 0.8125rem; color: #475569; padding: 4px 0; 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; } /* Boosts */ @@ -112,10 +156,24 @@ .sup-cta h2 { font-size: 1.5rem; margin-bottom: 0.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) { - .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; } .pricing-grid { grid-template-columns: 1fr; } + .credit-tiers { grid-template-columns: 1fr; } + .lead-preview-grid { grid-template-columns: 1fr; } } {% endblock %} @@ -128,10 +186,24 @@ See Plans
+
-
{{ total_suppliers }}+ suppliers listed
-
{{ total_countries }} countries
-
100% project-qualified leads
+
+ {{ calc_requests }}+ + Business plans created +
+
+ {% if avg_budget %}€{{ "{:,.0f}".format(avg_budget / 1000) }}K{% else %}—{% endif %} + Avg. project value +
+
+ {{ total_suppliers }}+ + Suppliers in {{ total_countries }} countries +
+
+ {{ monthly_leads }} + Leads this month +
@@ -146,8 +218,8 @@
2
-

Get Quotes

-

When entrepreneurs request quotes, we match them with suppliers based on location, services, and project specs.

+

Unlock Leads with Credits

+

Browse verified leads in your region. Spend credits to unlock full project details and contact info.

3
@@ -156,19 +228,115 @@
- + +
+

How Credits Work

+

+ Each lead costs credits based on how ready-to-buy they are. Growth plans include 30 credits/mo, Pro includes 100. +

+
+
+
Hot Lead
+
35
+
credits · financing secured, ready now
+
+
+
Warm Lead
+
20
+
credits · active planning, 3-6 months
+
+
+
Cool Lead
+
8
+
credits · early research, 6-12 months
+
+
+
+ +
-

Example Lead You'd Receive

-
-
Facility
Indoor (Rent)
-
Courts
6 double + 2 single
-
Glass
Panoramic
-
Country
Germany
-
Budget
€450K
-
Timeline
3-6 months
-
Phase
Lease signed
-
Financing
Loan approved
-
+

Recent Verified Leads

+ {% if preview_leads %} +
+ {% for lead in preview_leads %} +
+ {{ lead.heat_score | upper }} +
+
Facility
+
{{ lead.facility_type | default("Indoor", true) | capitalize }} · {{ lead.court_count | default("?") }} courts
+
Country
+
{{ lead.country | default("—") }}
+
Budget
+
{% if lead.budget_estimate %}€{{ "{:,.0f}".format(lead.budget_estimate / 1000) }}K{% else %}—{% endif %}
+
Timeline
+
{{ lead.timeline | default("—") }}
+
Contact
+
john@example.com
+
+
+ {% endfor %} +
+

+ Unlock full contact details and project specs with credits. + Get started → +

+ {% else %} +
+
+ HOT +
+
Facility
Indoor (Rent) · 6 courts
+
Country
Germany
+
Budget
€450K
+
Timeline
3-6 months
+
Contact
john@example.com
+
+
+
+ WARM +
+
Facility
Outdoor · 4 courts
+
Country
Spain
+
Budget
€280K
+
Timeline
6-12 months
+
Contact
maria@example.com
+
+
+
+ HOT +
+
Facility
Indoor (Own) · 8 courts
+
Country
Sweden
+
Budget
€720K
+
Timeline
ASAP
+
Contact
erik@example.com
+
+
+
+

+ These are example leads. Real leads appear as entrepreneurs submit quote requests. +

+ {% endif %} +
+ + + +
+

Why Padelnomics Leads Are Different

+

Every lead has already built a financial model for their project.

+
+
+

Pre-Qualified

+

Leads come through our financial planner. They've modeled CAPEX, revenue, and ROI before contacting you.

+
+
+

Full Project Brief

+

You get venue type, court count, glass/lighting specs, budget, timeline, financing status, and contact details.

+
+
+

No Cold Outreach

+

Entrepreneurs come to us. You only hear from people actively planning to build padel facilities.

+
@@ -178,24 +346,27 @@

Choose the plan that fits your growth goals.

-
+
+

Growth

€149 /mo
+
30 credits/mo included
  • Company name & category badge
  • City & country shown
  • Description (3 lines)
  • "Growth" badge
  • Priority over free listings
  • +
  • Access to lead feed
- Get Started + Get Started
-
- +

Pro

€399 /mo
+
100 credits/mo included
  • Everything in Growth
  • Company logo displayed
  • @@ -205,13 +376,13 @@
  • Priority placement
  • Highlighted card border
- Get Started + Get Started

Boost Add-Ons

-

Available with any paid plan.

+

Available with any paid plan. Manage from your dashboard.

Logo @@ -236,23 +407,13 @@
- +
-

Why Padelnomics Leads Are Different

-

Every lead has already built a financial model for their project.

-
-
-

Pre-Qualified

-

Leads come through our financial planner. They've modeled CAPEX, revenue, and ROI before contacting you.

-
-
-

Full Project Brief

-

You get venue type, court count, glass/lighting specs, budget, timeline, financing status, and contact details.

-
-
-

No Cold Outreach

-

Entrepreneurs come to us. You only hear from people actively planning to build padel facilities.

-
+

Trusted by Padel Industry Leaders

+

Suppliers across {{ total_countries }} countries use Padelnomics to reach new customers.

+
+
"Padelnomics sends us leads that are already serious about building. The project briefs are more detailed than what we get from trade shows."
+ — European padel court manufacturer
@@ -266,7 +427,11 @@
How much does it cost? -

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.

+

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.

+
+
+ How do credits work? +

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.

What information do leads include? @@ -274,7 +439,15 @@
How are leads matched to suppliers? -

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.

+

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.

+
+
+ Which countries do you cover? +

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.

+
+
+ Can I cancel anytime? +

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.

My company isn't listed. How do I get added? diff --git a/padelnomics/src/padelnomics/scripts/__init__.py b/padelnomics/src/padelnomics/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/padelnomics/src/padelnomics/scripts/setup_paddle.py b/padelnomics/src/padelnomics/scripts/setup_paddle.py new file mode 100644 index 0000000..6091f18 --- /dev/null +++ b/padelnomics/src/padelnomics/scripts/setup_paddle.py @@ -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() diff --git a/padelnomics/src/padelnomics/static/css/planner.css b/padelnomics/src/padelnomics/static/css/planner.css index ed3fddc..4b7a57d 100644 --- a/padelnomics/src/padelnomics/static/css/planner.css +++ b/padelnomics/src/padelnomics/static/css/planner.css @@ -621,6 +621,164 @@ 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 ── */ .waterfall-row { display: flex; @@ -799,20 +957,26 @@ 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 { display: flex; gap: 1rem; padding: 12px 16px; background: var(--bg-2); border: 1px solid var(--border); - border-radius: 16px; - box-shadow: 0 1px 3px rgba(0,0,0,0.04); - margin-top: 1.5rem; + border-radius: 16px 16px 0 0; + box-shadow: 0 -2px 8px rgba(0,0,0,0.04); max-width: 560px; margin-left: auto; margin-right: auto; - max-width: 560px; } .wiz-preview__item { flex: 1; @@ -839,7 +1003,11 @@ display: flex; align-items: center; 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; margin-left: auto; margin-right: auto; @@ -898,163 +1066,6 @@ 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) ── */ .signup-bar { position: sticky; @@ -1072,16 +1083,13 @@ textarea.wiz-input { } .signup-bar span { flex: 1; } .signup-bar b { color: var(--head); } -.signup-bar__close { - background: none; - border: none; - font-size: 18px; - color: var(--txt-2); - cursor: pointer; - padding: 0 4px; - line-height: 1; + +/* CAPEX tab — narrower content on wide screens */ +#tab-capex { + max-width: 800px; + margin-left: auto; + margin-right: auto; } -.signup-bar__close:hover { color: var(--txt); } /* Mobile wizard */ @media (max-width: 768px) { @@ -1102,6 +1110,7 @@ textarea.wiz-input { .wizard-step { max-width: 100%; } .wizard-preview, .wizard-nav { max-width: 100%; } + .wizard-step { padding-bottom: 100px; } /* space for sticky footer */ } /* ── Computing indicator ── */ diff --git a/padelnomics/src/padelnomics/static/js/planner.js b/padelnomics/src/padelnomics/static/js/planner.js index 13072cb..b770bf6 100644 --- a/padelnomics/src/padelnomics/static/js/planner.js +++ b/padelnomics/src/padelnomics/static/js/planner.js @@ -51,7 +51,6 @@ const WIZ_STEPS = [ {n:2, label:'Pricing'}, {n:3, label:'Costs'}, {n:4, label:'Finance'}, - {n:5, label:'Get Quotes'}, ]; // ── 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.')+ 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 += '
'; - h += '
Reference dimensions
'; - h += '
Double court playing area20\u00D710m = 200 m\u00B2
'; - h += '
Single court playing area20\u00D76m = 120 m\u00B2
'; - h += '
+ 2m buffer all around24\u00D714m = 336 m\u00B2 / 24\u00D710m = 240 m\u00B2
'; - h += '
Min. ceiling height (indoor)8\u201310m clear
'; - h += '
'; $('#inp-space').innerHTML = h; } @@ -270,8 +262,11 @@ function rebuildCapexInputs(){ // Reset lightingType to led_standard if natural was selected but switched to indoor 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.')+ - 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('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){ @@ -373,6 +368,12 @@ function render(){ const sb=$('#signupBar'); 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(_lastD) renderWith(_lastD); @@ -764,8 +765,6 @@ function showWizStep(){ buildWizardNav(); renderWizNav(); if(_lastD) renderWizPreview(); - // Auto-fill hidden fields when entering step 5 - if(wizStep===5) populateWizAutoFill(); } function renderWizPreview(){ @@ -804,126 +803,23 @@ function renderWizNav(){ if(wizStep<4){ right=``; } else if(wizStep===4){ - right=`
- - -
`; - } else if(wizStep===5){ - right=`
- - -
`; + right=``; } el.innerHTML=left+right; } -const COUNTRY_NAMES = { - DE:'Germany',ES:'Spain',IT:'Italy',FR:'France',NL:'Netherlands', - SE:'Sweden',UK:'United Kingdom',US:'United States' -}; - -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=`
-
Facility
${S.venue==='indoor'?'Indoor':'Outdoor'} (${S.own==='buy'?'Buy':'Rent'})
-
Courts
${ct} (${S.dblCourts} double + ${S.sglCourts} single)
-
Glass
${S.glassType==='panoramic'?'Panoramic':'Standard'}
-
Lighting
${S.lightingType.replace(/_/g,' ')}
-
Country
${COUNTRY_NAMES[S.country]||S.country}
- ${S.budgetTarget?`
Budget
${fmtK(S.budgetTarget)}
`:''} -
`; - } -} - -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=`
`; - } - }) - .catch(err=>{ - if(btn){ btn.disabled=false; btn.textContent='Submit & Get Quotes \u2192'; } - if(err&&err.errors){ - alert(err.errors.join('\n')); - } +// ── Navigate to standalone quote form ───────────────────── +function goToQuoteForm(){ + const p = new URLSearchParams({ + venue: S.venue, + courts: S.dblCourts + S.sglCourts, + glass: S.glassType, + lighting: S.lightingType, + country: S.country, }); -} - -// ── Quote URL builder ───────────────────────────────────── -function getQuoteUrl(){ - const base = window.__PADELNOMICS_QUOTE_URL__ || '/leads/quote'; - return base+'?'+new URLSearchParams({ - venue:S.venue, - courts:S.dblCourts+S.sglCourts, - glass:S.glassType, - lighting:S.lightingType, - budget:S.budgetTarget||'', - country:S.country, - }).toString(); + if(S.budgetTarget) p.set('budget', S.budgetTarget); + window.location.href = '/leads/quote?' + p.toString(); } // ── Init ────────────────────────────────────────────────── @@ -938,6 +834,9 @@ if(_lastD){ // Update tab visibility $$('.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}`)); + // Show CTAs + const _qs=$('#quoteSidebar'); if(_qs) _qs.style.display='block'; + const _qi=$('#quoteInlineCta'); if(_qi) _qi.style.display='block'; } else { render(); } diff --git a/padelnomics/src/padelnomics/suppliers/__init__.py b/padelnomics/src/padelnomics/suppliers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/padelnomics/src/padelnomics/suppliers/routes.py b/padelnomics/src/padelnomics/suppliers/routes.py new file mode 100644 index 0000000..b4d17bf --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/routes.py @@ -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/", 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/") +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//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, + ) diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html new file mode 100644 index 0000000..d0b0f10 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% block title %}Supplier Dashboard - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + + + +
+
Loading...
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/lead_feed.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/lead_feed.html new file mode 100644 index 0000000..2a5e18c --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/lead_feed.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block title %}Lead Feed - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

Lead Feed

+

Browse and unlock qualified padel project leads.

+
+
+ {{ supplier.credit_balance }} credits available +
+
+ +
+ + +
+ + {% if leads %} +
+ {% for lead in leads %} +
+ {% if lead.is_unlocked %} + {% include "suppliers/partials/lead_card_unlocked.html" %} + {% else %} + {% include "suppliers/partials/lead_card.html" %} + {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

No leads match your filters

+

Try adjusting your country or heat filters, or check back later for new leads.

+
+ {% endif %} +
+{% endblock %} diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_boosts.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_boosts.html new file mode 100644 index 0000000..e4b9726 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_boosts.html @@ -0,0 +1,154 @@ + + +{% 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 %} + +
+
+ +
+

Current Plan

+
+
+
{{ plan_info.name }}
+
{{ supplier.monthly_credits }} credits/month
+
+
€{{ plan_info.price }} /mo
+
+
+ + +
+

Active Boosts

+ {% if active_boosts %} + {% for boost in active_boosts %} +
+
+
{{ boost.boost_type | replace('_', ' ') | title }}
+ {% if boost.expires_at %} +
Expires {{ boost.expires_at[:10] }}
+ {% else %} +
Active subscription
+ {% endif %} +
+ Active +
+ {% endfor %} + {% else %} +

No active boosts

+ {% endif %} +
+ + +
+

Available Boosts

+ {% for b in boost_options %} + {% if b.type not in active_boost_types %} +
+
+
{{ b.name }}
+
{{ b.desc }}
+
+
+
€{{ b.price }}/mo
+ +
+
+ {% endif %} + {% endfor %} +
+ + +
+

Buy Credit Packs

+
+ {% for cp in credit_packs %} +
+
{{ cp.amount }}
+
credits
+
€{{ cp.price }}
+ +
+ {% endfor %} +
+
+
+ + +
+
+

Summary

+
+ {{ plan_info.name }} plan + €{{ plan_info.price }}/mo +
+ {% for boost in active_boosts %} + {% if not boost.expires_at %} +
+ {{ boost.boost_type | replace('_', ' ') | title }} + subscription +
+ {% endif %} + {% endfor %} +
+ Credits Balance + {{ supplier.credit_balance }} +
+
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html new file mode 100644 index 0000000..844bd13 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html @@ -0,0 +1,160 @@ + + +
+

Lead Feed

+
+ {{ supplier.credit_balance }} credits + Buy More +
+
+ +
+ + All + Hot + Warm + Cool + +
+ + + + + + +
+ + + Any + ASAP + 3-6mo + 6-12mo +
+ +{% if leads %} +
+ {% for lead in leads %} +
+ {% if lead.is_unlocked %} + {% include "suppliers/partials/lead_card_unlocked.html" %} + {% else %} + {# Locked card with bidder info and region match #} +
+
+ {{ (lead.heat_score or 'cool') | upper }} + {% if lead.country in service_area %} + Your region + {% endif %} +
+
+
Facility
{{ lead.facility_type or '-' }}
+
Courts
{{ lead.court_count or '-' }}
+
Country
{{ lead.country or '-' }}
+
Timeline
{{ lead.timeline or '-' }}
+
Budget
{% if lead.budget_estimate %}€{{ lead.budget_estimate }}{% else %}-{% endif %}
+
+ {# Bidder count messaging #} + {% if lead.bidder_count == 0 %} +
No other suppliers yet — be first!
+ {% else %} +
{{ lead.bidder_count }} supplier{{ 's' if lead.bidder_count != 1 }} already unlocked
+ {% endif %} +
+
{{ lead.credit_cost or '?' }} credits to unlock
+
+ + +
+
+
+ {% endif %} +
+ {% endfor %} +
+{% else %} +
+

No leads match your filters

+

Try adjusting your filters, or check back later for new leads.

+
+{% endif %} + +{# Include lead feed styles #} + diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_listing.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_listing.html new file mode 100644 index 0000000..a01ecf6 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_listing.html @@ -0,0 +1,178 @@ + + +{% if saved is defined and saved %} +
Listing saved successfully.
+{% endif %} + + +
+

Your Directory Card Preview

+
+
+
+ {% if supplier.logo_file %} + + {% elif supplier.logo_url %} + + {% endif %} + {{ supplier.name }} +
+ {{ supplier.category }} +
+
{{ supplier.city or '' }}{% if supplier.city %}, {% endif %}{{ supplier.country_code }}
+
+ {{ supplier.tier | upper }} + {% if 'verified' in active_boosts or supplier.is_verified %} + Verified ✓ + {% endif %} +
+ {% if supplier.tagline %} +

{{ supplier.tagline }}

+ {% endif %} + {% if supplier.short_description or supplier.description %} +

{{ supplier.short_description or supplier.description }}

+ {% endif %} + {% if supplier.website %} +
{{ supplier.website }}
+ {% endif %} +
+
+ + +
+

Edit Company Info

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ {% set current_cats = (supplier.service_categories or '').split(',') %} + {% for cat in service_categories %} + + {% endfor %} +
+
+ +
+ +
+ {% set current_areas = (supplier.service_area or '').split(',') %} + {% for c in countries %} + + {% endfor %} +
+
+ +
+ +
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_overview.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_overview.html new file mode 100644 index 0000000..9438857 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_overview.html @@ -0,0 +1,84 @@ + + +{% if new_leads_count > 0 %} +
+ {{ new_leads_count }} + new lead{{ 's' if new_leads_count != 1 }} match your profile. + View Lead Feed → + +
+{% endif %} + +
+
+
Profile Views
+
+
via Umami
+
+
+
Leads Unlocked
+
{{ leads_unlocked }}
+
+
+
Credits Balance
+
{{ supplier.credit_balance }}
+
+
+
Directory Rank
+
+
via Umami
+
+
+ +
+

Recent Activity

+ {% if recent_activity %} + {% for item in recent_activity %} +
+
+ {{ item.note or item.event_type }} + {{ item.created_at[:16] }} +
+ + {{ '%+d' % item.delta }} credits + +
+ {% endfor %} + {% else %} +

No activity yet. Unlock your first lead to get started.

+ {% endif %} +
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html new file mode 100644 index 0000000..3282098 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html @@ -0,0 +1,34 @@ +
+
+ {{ (lead.heat_score or 'cool') | upper }} + {{ lead.unlock_count or 0 }} supplier{{ 's' if (lead.unlock_count or 0) != 1 }} unlocked +
+ +
+
Facility
+
{{ lead.facility_type or '-' }}
+
Courts
+
{{ lead.court_count or '-' }}
+
Country
+
{{ lead.country or '-' }}
+
Timeline
+
{{ lead.timeline or '-' }}
+
Budget
+
{% if lead.budget_estimate %}~€{{ ((lead.budget_estimate | int / 1000) | round | int) }}K{% else %}-{% endif %}
+
Context
+
{{ lead.build_context or '-' }}
+
+ + {% if lead.services_needed %} +

Services: {{ lead.services_needed }}

+ {% endif %} + +
+ {{ lead.credit_cost or '?' }} credits +
+ + +
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_error.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_error.html new file mode 100644 index 0000000..12d4003 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_error.html @@ -0,0 +1,3 @@ +
+
{{ error }}
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_unlocked.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_unlocked.html new file mode 100644 index 0000000..4b74a2c --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/lead_card_unlocked.html @@ -0,0 +1,48 @@ +
+
+ {{ (lead.heat_score or 'cool') | upper }} + ✓ Unlocked +
+ +
+
Facility
+
{{ lead.facility_type or '-' }} ({{ lead.build_context or '-' }})
+
Courts
+
{{ lead.court_count or '-' }} | Glass: {{ lead.glass_type or '-' }} | Lighting: {{ lead.lighting_type or '-' }}
+
Location
+
{{ lead.location or '-' }}, {{ lead.country or '-' }}
+
Timeline
+
{{ lead.timeline or '-' }}
+
Budget
+
{% if lead.budget_estimate %}€{{ lead.budget_estimate }}{% else %}-{% endif %}
+
Phase
+
{{ lead.location_status or '-' }}
+
Financing
+
{{ lead.financing_status or '-' }}
+
Services
+
{{ lead.services_needed or '-' }}
+
+ + {% if lead.additional_info %} +

{{ lead.additional_info }}

+ {% endif %} + +
+
+
Name
+
{{ lead.contact_name or '-' }}
+
Email
+
{{ lead.contact_email or '-' }}
+
Phone
+
{% if lead.contact_phone %}{{ lead.contact_phone }}{% else %}-{% endif %}
+
Company
+
{{ lead.contact_company or '-' }}
+
Role
+
{{ lead.stakeholder_type or '-' }}
+
+
+ + {% if credit_cost is defined %} +

{{ credit_cost }} credits used · {{ supplier.credit_balance }} remaining

+ {% endif %} +
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_1.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_1.html new file mode 100644 index 0000000..347461a --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_1.html @@ -0,0 +1,32 @@ +
+

Choose Your Plan

+

Select the plan that fits your growth goals.

+ +
+ + + +
+ {% for key, plan in plans.items() %} + + {% endfor %} +
+ +
+ + +
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_2.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_2.html new file mode 100644 index 0000000..a42e020 --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_2.html @@ -0,0 +1,40 @@ +
+

Boost Add-Ons

+

Increase your visibility with optional boosts. {% if included_boosts %}Some are included in your plan.{% endif %}

+ +
+ + + +
+ {% set selected_boosts = data.get('boosts', []) %} + {% for b in boosts %} + {% set is_included = b.type in (included_boosts or []) %} + + {% endfor %} +
+ +
+ + +
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_3.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_3.html new file mode 100644 index 0000000..ba7361e --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_3.html @@ -0,0 +1,37 @@ +
+

Credit Packs

+

Optionally top up your lead credits. Your plan includes monthly credits — packs give you extra.

+ +
+ + + +
+ + {% for cp in credit_packs %} + + {% endfor %} +
+ +
+ + +
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_4.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_4.html new file mode 100644 index 0000000..3bfa9aa --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/signup_step_4.html @@ -0,0 +1,88 @@ +
+

Account Details

+

Tell us about your company and how to reach you.

+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% if data.get('supplier_name') %} +

+ Claiming listing: {{ data.get('supplier_name') }} +

+ {% endif %} + + +
+

Order Summary

+
+ {{ order.plan_name }} Plan + €{{ order.plan_price }}/mo +
+ {% if order.boost_monthly > 0 %} +
+ Boost add-ons + +€{{ order.boost_monthly }}/mo +
+ {% endif %} +
+ Monthly total + €{{ order.monthly_total }}/mo +
+ {% if order.one_time_total > 0 %} +
+ Credit pack (one-time) + €{{ order.one_time_total }} +
+ {% endif %} +
+ +
+ + +
+
+
diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/signup.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/signup.html new file mode 100644 index 0000000..2238a4a --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/signup.html @@ -0,0 +1,152 @@ +{% extends "base.html" %} +{% block title %}Supplier Signup - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+ Choose Your Plan + 1 of 4 +
+
+
+
+
+
+
+ {% include "suppliers/partials/signup_step_1.html" %} +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/signup_success.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/signup_success.html new file mode 100644 index 0000000..47ec2fa --- /dev/null +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/signup_success.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Welcome! - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+
+ +
+

You're All Set!

+

+ Your supplier account is being activated. You'll start receiving qualified leads matching your services. +

+ +
+

What happens next:

+
    +
  • ✓ Your listing will be upgraded within minutes
  • +
  • ✓ Lead credits have been added to your account
  • +
  • ✓ Check your email for a sign-in link
  • +
  • ✓ Browse and unlock leads in your feed
  • +
+
+ + Go to Lead Feed +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/templates/base.html b/padelnomics/src/padelnomics/templates/base.html index d507fc8..cc4b3de 100644 --- a/padelnomics/src/padelnomics/templates/base.html +++ b/padelnomics/src/padelnomics/templates/base.html @@ -16,6 +16,29 @@ + + + + + + + {% block head %}{% endblock %} @@ -38,6 +61,25 @@ Directory For Suppliers Help + +
+ + +
+ {% if user %} Dashboard {% if session.get('is_admin') %} diff --git a/padelnomics/src/padelnomics/templates/businessplan/plan.css b/padelnomics/src/padelnomics/templates/businessplan/plan.css new file mode 100644 index 0000000..d06ba06 --- /dev/null +++ b/padelnomics/src/padelnomics/templates/businessplan/plan.css @@ -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; +} diff --git a/padelnomics/src/padelnomics/templates/businessplan/plan.html b/padelnomics/src/padelnomics/templates/businessplan/plan.html new file mode 100644 index 0000000..e935378 --- /dev/null +++ b/padelnomics/src/padelnomics/templates/businessplan/plan.html @@ -0,0 +1,218 @@ + + + + + + + + + +

{{ s.title }}

+
{{ s.subtitle }}
+{% if s.scenario_name %} +

Scenario: {{ s.scenario_name }}{% if s.location %} — {{ s.location }}{% endif %}

+{% endif %} +

{{ s.courts }}

+

Generated by Padelnomics — padelnomics.io

+ + +

{{ s.executive_summary.heading }}

+
+
+
Total Investment
+
{{ s.executive_summary.total_capex }}
+
+
+
Equity Required
+
{{ s.executive_summary.equity }}
+
+
+
Year 3 EBITDA
+
{{ s.executive_summary.y3_ebitda }}
+
+
+
IRR
+
{{ s.executive_summary.irr }}
+
+
+
Payback Period
+
{{ s.executive_summary.payback }}
+
+
+
Year 1 Revenue
+
{{ s.executive_summary.y1_revenue }}
+
+
+ +

This business plan models a {{ s.executive_summary.facility_type }} padel facility with +{{ s.executive_summary.courts }} courts ({{ 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 }}.

+ + +

{{ s.investment.heading }}

+ + + + + + {% for item in s.investment.items %} + + + + + + {% endfor %} + + + + + + +
ItemAmountNotes
{{ item.name }}€{{ "{:,.0f}".format(item.amount) }}{{ item.info }}
Total CAPEX{{ s.investment.total }}
+

+ CAPEX per court: {{ s.investment.per_court }} • CAPEX per m²: {{ s.investment.per_sqm }} +

+ + +

{{ s.financing.heading }}

+
+
+
+
+ + + + + + + + + + +
Equity{{ s.financing.equity }}
Loan ({{ s.financing.loan_pct }}){{ s.financing.loan }}
Interest Rate{{ s.financing.interest_rate }}
Loan Term{{ s.financing.term }}
Monthly Payment{{ s.financing.monthly_payment }}
Annual Debt Service{{ s.financing.annual_debt_service }}
Loan-to-Value{{ s.financing.ltv }}
+ + +

{{ s.operations.heading }}

+ + + + + + {% for item in s.operations.items %} + + + + + + {% endfor %} + + + + + + +
ItemMonthlyNotes
{{ item.name }}€{{ "{:,.0f}".format(item.amount) }}{{ item.info }}
Total Monthly OPEX{{ s.operations.monthly_total }}
+

Annual OPEX: {{ s.operations.annual_total }}

+ + +

{{ s.revenue.heading }}

+ + + + + + + + + +
Weighted Hourly Rate{{ s.revenue.weighted_rate }}
Target Utilization{{ s.revenue.utilization }}
Gross Monthly Revenue{{ s.revenue.gross_monthly }}
Net Monthly Revenue{{ s.revenue.net_monthly }}
Monthly EBITDA{{ s.revenue.ebitda_monthly }}
Monthly Net Cash Flow{{ s.revenue.net_cf_monthly }}
+ + +

{{ s.annuals.heading }}

+ + + + + + {% for yr in s.annuals.years %} + + + + + + + + {% endfor %} + +
YearRevenueEBITDADebt ServiceNet CF
Year {{ yr.year }}{{ yr.revenue }}{{ yr.ebitda }}{{ yr.debt_service }}{{ yr.net_cf }}
+ + +

{{ s.metrics.heading }}

+
+
+
IRR
+
{{ s.metrics.irr }}
+
+
+
MOIC
+
{{ s.metrics.moic }}
+
+
+
Cash-on-Cash (Y3)
+
{{ s.metrics.cash_on_cash }}
+
+
+
Payback
+
{{ s.metrics.payback }}
+
+
+
Break-Even Util.
+
{{ s.metrics.break_even_util }}
+
+
+
EBITDA Margin
+
{{ s.metrics.ebitda_margin }}
+
+
+
DSCR (Y3)
+
{{ s.metrics.dscr_y3 }}
+
+
+
Yield on Cost
+
{{ s.metrics.yield_on_cost }}
+
+
+ + +

{{ s.cashflow_12m.heading }}

+ + + + + + {% for m in s.cashflow_12m.months %} + + + + + + + + + + {% endfor %} + +
MonthRevenueOPEXEBITDADebtNet CFCumulative
{{ m.month }}{{ m.revenue }}{{ m.opex }}{{ m.ebitda }}{{ m.debt }}{{ m.ncf }}{{ m.cumulative }}
+ + +
+ Disclaimer: 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 +
+ + + diff --git a/padelnomics/src/padelnomics/worker.py b/padelnomics/src/padelnomics/worker.py index 4af2880..5c3f0de 100644 --- a/padelnomics/src/padelnomics/worker.py +++ b/padelnomics/src/padelnomics/worker.py @@ -6,7 +6,7 @@ import json import traceback 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 HANDLERS: dict[str, callable] = {} @@ -138,6 +138,7 @@ async def handle_send_email(payload: dict) -> None: subject=payload["subject"], html=payload["html"], 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"], subject=f"Sign in to {config.APP_NAME}", 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( to=payload["email"], - subject=f"Verify your email to get supplier quotes", + subject="Verify your email to get supplier quotes", html=_email_wrap(body), + from_addr=EMAIL_ADDRESSES["transactional"], ) @@ -228,6 +231,7 @@ async def handle_send_welcome(payload: dict) -> None: to=payload["email"], subject=f"Welcome to {config.APP_NAME}", 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,)) +@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'{label}' + f'{value}' + ) + + 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'{label}' + f'{value}' + ) + + body = ( + f'

New Project Lead

' + f'

A new padel project matches your services.

' + f'

Project Brief

' + f'{brief_html}
' + f'

Contact

' + f'{contact_html}
' + 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'

A supplier is reviewing your project

' + f'

Hi {first_name},

' + f'

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.

' + f'

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"}.

' + f'{_email_button(f"{config.BASE_URL}/dashboard", "View Your Dashboard")}' + f'

You\'ll receive this notification each time ' + f'a new supplier unlocks your project details.

' + ) + + 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'

Your Business Plan is Ready

' + f"

Your padel business plan PDF has been generated and is ready for download.

" + 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") async def handle_cleanup_tasks(payload: dict) -> None: """Clean up completed/failed tasks older than 7 days.""" @@ -307,14 +499,25 @@ async def run_scheduler() -> None: """Schedule periodic cleanup tasks.""" print("[SCHEDULER] Starting...") await init_db() - + + last_credit_refill = None + while True: try: # Schedule cleanup tasks every hour await enqueue("cleanup_expired_tokens") await enqueue("cleanup_rate_limits") 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 except Exception as e: diff --git a/padelnomics/uv.lock b/padelnomics/uv.lock index 4e11dc7..4892a6f 100644 --- a/padelnomics/uv.lock +++ b/padelnomics/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [[package]] 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" }, ] +[[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]] name = "certifi" 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" }, ] +[[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]] name = "charset-normalizer" 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" }, ] +[[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]] name = "flask" 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" }, ] +[[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]] name = "greenlet" 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" }, ] +[[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]] name = "padelnomics" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, - { name = "httpx" }, { name = "hypercorn" }, { name = "itsdangerous" }, { name = "jinja2" }, + { name = "paddle-python-sdk" }, { name = "python-dotenv" }, { name = "quart" }, + { name = "resend" }, + { name = "weasyprint" }, ] [package.dev-dependencies] @@ -459,12 +685,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.19.0" }, - { name = "httpx", specifier = ">=0.27.0" }, { name = "hypercorn", specifier = ">=0.17.0" }, { name = "itsdangerous", specifier = ">=2.1.0" }, { name = "jinja2", specifier = ">=3.1.0" }, + { name = "paddle-python-sdk", specifier = ">=1.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "quart", specifier = ">=0.19.0" }, + { name = "resend", specifier = ">=2.22.0" }, + { name = "weasyprint", specifier = ">=68.1" }, ] [package.metadata.requires-dev] @@ -478,6 +706,93 @@ dev = [ { 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]] name = "playwright" 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" }, ] +[[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]] name = "pyee" 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" }, ] +[[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]] name = "pytest" 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" }, ] +[[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]] name = "respx" 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" }, ] +[[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]] name = "typing-extensions" 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" }, ] +[[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]] name = "werkzeug" version = "3.1.5" @@ -745,3 +1152,22 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b 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" }, ] + +[[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" }, +]