From 536eefffdb2469756badbd166faa8434295434ff Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 19 Feb 2026 15:03:21 +0100 Subject: [PATCH] add Basic tier, monthly/yearly billing, and supplier detail redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Basic tier (€39/mo or €349/yr): verified directory listing with enquiry form, contact sidebar, services checklist, social links, no leads - Monthly + yearly billing for all paid tiers; yearly defaults selected in signup wizard with CSS-only price toggle (no JS state) - Redesigned supplier_detail.html: navy hero with court-grid pattern, two-column body+sidebar for Basic+, tier-adaptive CTA strips - Supplier enquiry form: HTMX-powered, rate-limited 5/24h, email relayed via worker task; supplier_enquiries table tracks all submissions - New supplier columns: services_offered, contact_role, linkedin_url, instagram_url, youtube_url (migration 0012) - _lead_tier_required decorator restricts lead feed to growth/pro; Basic users see overview + listing tabs only - Admin: basic tier in dropdown, new fields in form/detail + enquiry count - setup_paddle.py: adds 4 new products with yearly interval support - Webhook handler strips _monthly/_yearly suffixes, Basic gets 0 credits and is_verified=1; existing growth/pro webhooks unchanged - Sort order: pro > growth > basic > free - 572 tests pass (+2 new for basic tier + yearly webhook variants) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 54 ++ padelnomics/src/padelnomics/admin/routes.py | 186 +++--- .../templates/admin/supplier_detail.html | 29 +- .../admin/templates/admin/supplier_form.html | 41 +- padelnomics/src/padelnomics/billing/routes.py | 135 +++-- .../src/padelnomics/directory/routes.py | 98 +++- .../templates/partials/enquiry_result.html | 22 + .../directory/templates/partials/results.html | 30 +- .../directory/templates/supplier_detail.html | 551 +++++++++++++++--- .../versions/0000_initial_schema.py | 26 +- .../0011_add_rbac_and_billing_customers.py | 85 +++ .../versions/0012_add_basic_tier_fields.py | 42 ++ .../src/padelnomics/scripts/setup_paddle.py | 53 +- .../src/padelnomics/suppliers/routes.py | 199 ++++++- .../templates/suppliers/dashboard.html | 22 +- .../suppliers/partials/dashboard_listing.html | 95 +-- .../partials/dashboard_listing_preview.html | 29 + .../partials/dashboard_overview.html | 16 +- .../suppliers/partials/signup_step_1.html | 82 ++- .../suppliers/partials/signup_step_4.html | 64 +- padelnomics/src/padelnomics/worker.py | 34 ++ padelnomics/tests/test_supplier_webhooks.py | 49 ++ 22 files changed, 1592 insertions(+), 350 deletions(-) create mode 100644 padelnomics/src/padelnomics/directory/templates/partials/enquiry_result.html create mode 100644 padelnomics/src/padelnomics/migrations/versions/0011_add_rbac_and_billing_customers.py create mode 100644 padelnomics/src/padelnomics/migrations/versions/0012_add_basic_tier_fields.py create mode 100644 padelnomics/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_listing_preview.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 6491164..0a590a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Basic subscription tier** — verified directory listing with contact info, services checklist, social links, and enquiry form; no lead credits +- **Monthly + yearly billing** — all paid supplier tiers now offer yearly pricing with annual discount (Basic: €349/yr, Growth: €1,799/yr, Pro: €4,499/yr) +- **Billing period toggle** in supplier signup wizard — monthly/yearly pill switch, defaults to yearly; price cards update in real time via CSS sibling selectors +- **Redesigned supplier detail page** — navy hero section with court-grid CSS pattern, two-column body (main + 320px sidebar), contact card with avatar/role/social links, stats grid, services checklist, enquiry form for Basic+ listings, tier-adaptive CTA strip +- **Supplier enquiry form** on Basic+ listing pages — HTMX-powered inline form, rate-limited at 5 per email per 24 h, email relayed to supplier via worker task +- **New DB columns on suppliers**: `services_offered`, `contact_role`, `linkedin_url`, `instagram_url`, `youtube_url` +- **`supplier_enquiries` table** for tracking incoming enquiries from listing pages +- **Basic card variant** in directory results — shows verified badge, logo, website, short description; sits between growth and free in sort order +- **Dashboard access for Basic tier** — overview + listing tabs; leads/boosts tabs hidden; upgrade CTA in sidebar +- **Listing form: new fields** — services offered (multi-select checkboxes), contact role, LinkedIn/Instagram/YouTube social links +- **Admin: Basic tier support** — tier dropdown updated, new fields in supplier form and detail view, enquiry count shown + ### Changed +- Supplier Growth monthly price adjusted to €199/mo (yearly: €150/mo billed at €1,799/yr) +- Supplier Pro monthly price adjusted to €499/mo (yearly: €375/mo billed at €4,499/yr) +- Directory sort order updated: pro → growth → basic → free +- `_supplier_required` decorator now grants access to basic, growth, and pro tiers +- New `_lead_tier_required` decorator restricts lead feed, unlock, and dashboard leads to growth/pro only + +### Fixed +- **Supplier detail page: locked quote CTA** — "Request Quote" button is now + visually disabled (greyed-out) for unverified/free-tier suppliers; clicking + opens an inline popover explaining the limitation and linking to the general + quote wizard instead +- **Supplier signup Paddle checkout** — form now intercepts submit, fetches + checkout config via JS, and opens `Paddle.Checkout.open()` overlay instead + of displaying raw JSON in the browser +- **Credit balance OOB updates** — sidebar and lead feed header credits now + update instantly via HTMX OOB swaps after unlocking a lead (no page refresh) +- **Boosts page layout** — capped main content column at 720px on wide screens; + credit card grid uses `auto-fill` for graceful responsive adaptation + +### Added +- **Listing preview live update** — form fields (name, tagline, description, + website) trigger HTMX `hx-get` with 500ms debounce to update the directory + card preview in real time; new `/dashboard/listing/preview` endpoint and + extracted `dashboard_listing_preview.html` partial +- **Lead cards: full qualification data** — unlocked cards now show decision + process, prior supplier contact, financing help preference, with clear + section headers (Project, Location & Timeline, Readiness, Contact) and + human-readable enum labels; includes "View their plan" link to linked scenario +- **Lead feed search bar** — text input with 300ms debounced HTMX filtering on + country, facility type, and additional info; mirrors directory search pattern +- **Phone number mandatory in quote form** — step 9 now requires phone with + HTML `required` attribute and server-side validation +- **Supplier-aware dashboard redirect** — `/dashboard/` checks if user has a + claimed supplier with paid tier and redirects to supplier dashboard +- **Inline SVG logo** — replaced PNG logo with inline SVG padel racket icon + + Bricolage Grotesque wordmark in navbar and footer + +### Changed +- **Planner fonts** — replaced Inter with DM Sans for body text, JetBrains Mono + with Commit Mono for numeric values, added Bricolage Grotesque for planner + header and wizard step titles; loaded Commit Mono via fontsource CDN - **Migration system: single source of truth** — eliminated dual-maintenance of `schema.sql` + versioned migrations; all databases (fresh and existing) now replay migrations in order starting from `0000_initial_schema.py`; diff --git a/padelnomics/src/padelnomics/admin/routes.py b/padelnomics/src/padelnomics/admin/routes.py index c0ca40d..a68140b 100644 --- a/padelnomics/src/padelnomics/admin/routes.py +++ b/padelnomics/src/padelnomics/admin/routes.py @@ -1,18 +1,17 @@ """ -Admin domain: password-protected admin panel for managing users, tasks, etc. +Admin domain: role-based admin panel for managing users, tasks, etc. """ import csv import io import json -import secrets from datetime import date, datetime, timedelta -from functools import wraps from pathlib import Path import mistune from quart import Blueprint, flash, redirect, render_template, request, session, url_for -from ..core import config, csrf_protect, execute, execute_many, fetch_all, fetch_one, slugify, transaction +from ..auth.routes import role_required +from ..core import csrf_protect, execute, execute_many, fetch_all, fetch_one, slugify, transaction # Blueprint with its own template folder bp = Blueprint( @@ -23,20 +22,6 @@ bp = Blueprint( ) -# ============================================================================= -# Config -# ============================================================================= - -def get_admin_password() -> str: - """Get admin password from env. Generate one if not set (dev only).""" - import os - password = os.getenv("ADMIN_PASSWORD", "") - if not password and config.DEBUG: - # In dev, use a default password - return "admin" - return password - - # ============================================================================= # SQL Queries # ============================================================================= @@ -147,12 +132,16 @@ async def get_users(limit: int = 50, offset: int = 0, search: str = None) -> lis async def get_user_by_id(user_id: int) -> dict | None: - """Get user by ID with subscription info.""" + """Get user by ID with subscription and billing info.""" return await fetch_one( """ - SELECT u.*, s.plan, s.status as sub_status, s.paddle_customer_id + SELECT u.*, s.plan, s.status as sub_status, bc.provider_customer_id FROM users u - LEFT JOIN subscriptions s ON s.user_id = u.id + LEFT JOIN subscriptions s ON s.id = ( + SELECT id FROM subscriptions WHERE user_id = u.id + ORDER BY created_at DESC LIMIT 1 + ) + LEFT JOIN billing_customers bc ON bc.user_id = u.id WHERE u.id = ? """, (user_id,) @@ -197,62 +186,12 @@ async def delete_task(task_id: int) -> bool: return result > 0 -# ============================================================================= -# Decorators -# ============================================================================= - -def admin_required(f): - """Require admin authentication.""" - @wraps(f) - async def decorated(*args, **kwargs): - if not session.get("is_admin"): - return redirect(url_for("admin.login")) - return await f(*args, **kwargs) - return decorated - - # ============================================================================= # Routes # ============================================================================= -@bp.route("/login", methods=["GET", "POST"]) -@csrf_protect -async def login(): - """Admin login page.""" - admin_password = get_admin_password() - - if not admin_password: - await flash("Admin access not configured. Set ADMIN_PASSWORD env var.", "error") - return redirect(url_for("public.landing")) - - if session.get("is_admin"): - return redirect(url_for("admin.index")) - - if request.method == "POST": - form = await request.form - password = form.get("password", "") - - if secrets.compare_digest(password, admin_password): - session["is_admin"] = True - await flash("Welcome, admin!", "success") - return redirect(url_for("admin.index")) - else: - await flash("Invalid password.", "error") - - return await render_template("admin/login.html") - - -@bp.route("/logout", methods=["POST"]) -@csrf_protect -async def logout(): - """Admin logout.""" - session.pop("is_admin", None) - await flash("Logged out of admin.", "info") - return redirect(url_for("admin.login")) - - @bp.route("/") -@admin_required +@role_required("admin") async def index(): """Admin dashboard.""" stats = await get_dashboard_stats() @@ -268,7 +207,7 @@ async def index(): @bp.route("/users") -@admin_required +@role_required("admin") async def users(): """User list.""" search = request.args.get("search", "").strip() @@ -287,7 +226,7 @@ async def users(): @bp.route("/users/") -@admin_required +@role_required("admin") async def user_detail(user_id: int): """User detail page.""" user = await get_user_by_id(user_id) @@ -299,7 +238,7 @@ async def user_detail(user_id: int): @bp.route("/users//impersonate", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def impersonate(user_id: int): """Impersonate a user (login as them).""" @@ -333,7 +272,7 @@ async def stop_impersonating(): @bp.route("/tasks") -@admin_required +@role_required("admin") async def tasks(): """Task queue management.""" task_list = await get_recent_tasks(limit=100) @@ -347,7 +286,7 @@ async def tasks(): @bp.route("/tasks//retry", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def task_retry(task_id: int): """Retry a failed task.""" @@ -360,7 +299,7 @@ async def task_retry(task_id: int): @bp.route("/tasks//delete", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def task_delete(task_id: int): """Delete a task.""" @@ -436,7 +375,7 @@ async def get_lead_stats() -> dict: @bp.route("/leads") -@admin_required +@role_required("admin") async def leads(): """Lead management list.""" status = request.args.get("status", "") @@ -468,7 +407,7 @@ async def leads(): @bp.route("/leads/results") -@admin_required +@role_required("admin") async def lead_results(): """HTMX partial for filtered lead results.""" status = request.args.get("status", "") @@ -483,7 +422,7 @@ async def lead_results(): @bp.route("/leads/") -@admin_required +@role_required("admin") async def lead_detail(lead_id: int): """Lead detail page.""" lead = await get_lead_detail(lead_id) @@ -503,7 +442,7 @@ async def lead_detail(lead_id: int): @bp.route("/leads//status", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def lead_status(lead_id: int): """Update lead status.""" @@ -521,7 +460,7 @@ async def lead_status(lead_id: int): @bp.route("/leads/new", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def lead_new(): """Create a new lead from admin.""" @@ -572,7 +511,7 @@ async def lead_new(): @bp.route("/leads//forward", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def lead_forward(lead_id: int): """Manually forward a lead to a supplier (no credit cost).""" @@ -618,7 +557,7 @@ async def lead_forward(lead_id: int): # Supplier Management # ============================================================================= -SUPPLIER_TIERS = ["free", "growth", "pro"] +SUPPLIER_TIERS = ["free", "basic", "growth", "pro"] async def get_suppliers_list( @@ -663,7 +602,7 @@ async def get_supplier_stats() -> dict: @bp.route("/suppliers") -@admin_required +@role_required("admin") async def suppliers(): """Supplier management list.""" search = request.args.get("search", "").strip() @@ -695,7 +634,7 @@ async def suppliers(): @bp.route("/suppliers/results") -@admin_required +@role_required("admin") async def supplier_results(): """HTMX partial for filtered supplier results.""" search = request.args.get("search", "").strip() @@ -711,7 +650,7 @@ async def supplier_results(): @bp.route("/suppliers/") -@admin_required +@role_required("admin") async def supplier_detail(supplier_id: int): """Supplier detail page.""" supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,)) @@ -740,6 +679,12 @@ async def supplier_detail(supplier_id: int): (supplier_id,), ) + enquiry_row = await fetch_one( + "SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?", + (supplier_id,), + ) + enquiry_count = enquiry_row["cnt"] if enquiry_row else 0 + return await render_template( "admin/supplier_detail.html", supplier=supplier, @@ -748,11 +693,12 @@ async def supplier_detail(supplier_id: int): ledger=ledger, boosts=boosts, forwards=forwards, + enquiry_count=enquiry_count, ) @bp.route("/suppliers/new", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def supplier_new(): """Create a new supplier from admin.""" @@ -783,14 +729,22 @@ async def supplier_new(): await flash(f"Slug '{slug}' already exists.", "error") return await render_template("admin/supplier_form.html", data=dict(form)) + contact_role = form.get("contact_role", "").strip() + services_offered = form.get("services_offered", "").strip() + linkedin_url = form.get("linkedin_url", "").strip() + instagram_url = form.get("instagram_url", "").strip() + youtube_url = form.get("youtube_url", "").strip() + now = datetime.utcnow().isoformat() supplier_id = await execute( """INSERT INTO suppliers (name, slug, country_code, city, region, website, description, category, - tier, contact_name, contact_email, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + tier, contact_name, contact_email, contact_role, services_offered, + linkedin_url, instagram_url, youtube_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (name, slug, country_code, city, region, website, description, - category, tier, contact_name, contact_email, now), + category, tier, contact_name, contact_email, contact_role, + services_offered, linkedin_url, instagram_url, youtube_url, now), ) await flash(f"Supplier '{name}' created.", "success") return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id)) @@ -799,7 +753,7 @@ async def supplier_new(): @bp.route("/suppliers//credits", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def supplier_credits(supplier_id: int): """Manually adjust supplier credits.""" @@ -828,7 +782,7 @@ async def supplier_credits(supplier_id: int): @bp.route("/suppliers//tier", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def supplier_tier(supplier_id: int): """Manually change supplier tier.""" @@ -850,7 +804,7 @@ async def supplier_tier(supplier_id: int): # ============================================================================= @bp.route("/feedback") -@admin_required +@role_required("admin") async def feedback(): """View user feedback submissions.""" feedback_list = await fetch_all( @@ -868,7 +822,7 @@ async def feedback(): # ============================================================================= @bp.route("/templates") -@admin_required +@role_required("admin") async def templates(): """List article templates.""" template_list = await fetch_all( @@ -889,7 +843,7 @@ async def templates(): @bp.route("/templates/new", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_new(): """Create a new article template.""" @@ -937,7 +891,7 @@ async def template_new(): @bp.route("/templates//edit", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_edit(template_id: int): """Edit an article template.""" @@ -988,7 +942,7 @@ async def template_edit(template_id: int): @bp.route("/templates//delete", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_delete(template_id: int): """Delete an article template.""" @@ -1002,7 +956,7 @@ async def template_delete(template_id: int): # ============================================================================= @bp.route("/templates//data") -@admin_required +@role_required("admin") async def template_data(template_id: int): """View data rows for a template.""" template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,)) @@ -1038,7 +992,7 @@ async def template_data(template_id: int): @bp.route("/templates//data/add", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_data_add(template_id: int): """Add a single data row.""" @@ -1070,7 +1024,7 @@ async def template_data_add(template_id: int): @bp.route("/templates//data/upload", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_data_upload(template_id: int): """Bulk upload data rows from CSV.""" @@ -1103,7 +1057,7 @@ async def template_data_upload(template_id: int): @bp.route("/templates//data//delete", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_data_delete(template_id: int, data_id: int): """Delete a single data row.""" @@ -1125,7 +1079,7 @@ def _render_jinja_string(template_str: str, context: dict) -> str: @bp.route("/templates//generate", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def template_generate(template_id: int): """Bulk-generate scenarios + articles from template data.""" @@ -1298,7 +1252,7 @@ SCENARIO_FORM_FIELDS = [ @bp.route("/scenarios") -@admin_required +@role_required("admin") async def scenarios(): """List published scenarios.""" scenario_list = await fetch_all( @@ -1308,7 +1262,7 @@ async def scenarios(): @bp.route("/scenarios/new", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def scenario_new(): """Create a published scenario manually.""" @@ -1362,7 +1316,7 @@ async def scenario_new(): @bp.route("/scenarios//edit", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def scenario_edit(scenario_id: int): """Edit a published scenario.""" @@ -1425,7 +1379,7 @@ async def scenario_edit(scenario_id: int): @bp.route("/scenarios//delete", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def scenario_delete(scenario_id: int): """Delete a published scenario.""" @@ -1435,7 +1389,7 @@ async def scenario_delete(scenario_id: int): @bp.route("/scenarios//preview") -@admin_required +@role_required("admin") async def scenario_preview(scenario_id: int): """Preview a rendered scenario card.""" scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,)) @@ -1456,7 +1410,7 @@ async def scenario_preview(scenario_id: int): # ============================================================================= @bp.route("/articles") -@admin_required +@role_required("admin") async def articles(): """List all articles.""" article_list = await fetch_all( @@ -1466,7 +1420,7 @@ async def articles(): @bp.route("/articles/new", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def article_new(): """Create a manual article.""" @@ -1523,7 +1477,7 @@ async def article_new(): @bp.route("/articles//edit", methods=["GET", "POST"]) -@admin_required +@role_required("admin") @csrf_protect async def article_edit(article_id: int): """Edit a manual article.""" @@ -1588,7 +1542,7 @@ async def article_edit(article_id: int): @bp.route("/articles//delete", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def article_delete(article_id: int): """Delete an article.""" @@ -1609,7 +1563,7 @@ async def article_delete(article_id: int): @bp.route("/articles//publish", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def article_publish(article_id: int): """Toggle article status between draft and published.""" @@ -1629,7 +1583,7 @@ async def article_publish(article_id: int): @bp.route("/articles//rebuild", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def article_rebuild(article_id: int): """Re-render an article's HTML from source.""" @@ -1639,7 +1593,7 @@ async def article_rebuild(article_id: int): @bp.route("/rebuild-all", methods=["POST"]) -@admin_required +@role_required("admin") @csrf_protect async def rebuild_all(): """Re-render all articles.""" diff --git a/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html b/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html index a7ef0c8..67fcc3d 100644 --- a/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html +++ b/padelnomics/src/padelnomics/admin/templates/admin/supplier_detail.html @@ -1,8 +1,8 @@ -{% extends "base.html" %} +{% extends "admin/base_admin.html" %} +{% set admin_page = "suppliers" %} {% block title %}{{ supplier.name }} - Admin - {{ config.APP_NAME }}{% endblock %} -{% block content %} -
+{% block admin_content %}
← All Suppliers @@ -11,7 +11,9 @@ {% elif supplier.tier == 'growth' %}GROWTH {% else %}FREE{% endif %} -

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

+

{{ supplier.slug }} · {{ supplier.country_code or '-' }} + {% if supplier.tier == 'basic' %}BASIC{% endif %} +

{% if supplier.claimed_by %} @@ -52,6 +54,24 @@
{{ supplier.years_in_business or '-' }}
Projects
{{ supplier.project_count or '-' }}
+ {% if supplier.contact_role %} +
Contact Role
+
{{ supplier.contact_role }}
+ {% endif %} + {% if supplier.services_offered %} +
Services
+
{{ supplier.services_offered }}
+ {% endif %} + {% if supplier.linkedin_url or supplier.instagram_url or supplier.youtube_url %} +
Social
+
+ {% if supplier.linkedin_url %}LinkedIn {% endif %} + {% if supplier.instagram_url %}Instagram {% endif %} + {% if supplier.youtube_url %}YouTube {% endif %} +
+ {% endif %} +
Enquiries
+
{{ enquiry_count }}
Claimed By
{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}
Created
@@ -173,5 +193,4 @@ {% endif %}
-
{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html b/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html index 56ab8fd..ed0042b 100644 --- a/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html +++ b/padelnomics/src/padelnomics/admin/templates/admin/supplier_form.html @@ -1,8 +1,9 @@ -{% extends "base.html" %} +{% extends "admin/base_admin.html" %} +{% set admin_page = "suppliers" %} {% block title %}New Supplier - Admin - {{ config.APP_NAME }}{% endblock %} -{% block content %} -
+{% block admin_content %} +
← All Suppliers

Create Supplier

@@ -55,7 +56,7 @@
@@ -82,7 +83,37 @@
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ -
+ {% endblock %} diff --git a/padelnomics/src/padelnomics/billing/routes.py b/padelnomics/src/padelnomics/billing/routes.py index 3bccf7e..fb19a0e 100644 --- a/padelnomics/src/padelnomics/billing/routes.py +++ b/padelnomics/src/padelnomics/billing/routes.py @@ -5,7 +5,6 @@ Payment provider: paddle import json from datetime import datetime -from functools import wraps from pathlib import Path from paddle_billing import Client as PaddleClient @@ -36,60 +35,71 @@ bp = Blueprint( # ============================================================================= async def get_subscription(user_id: int) -> dict | None: - """Get user's subscription.""" + """Get user's most recent subscription.""" return await fetch_one( "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", (user_id,) ) +async def upsert_billing_customer(user_id: int, provider_customer_id: str) -> None: + """Create or update billing customer record (idempotent).""" + await execute( + """INSERT INTO billing_customers (user_id, provider_customer_id) + VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET provider_customer_id = excluded.provider_customer_id""", + (user_id, provider_customer_id), + ) + + +async def get_billing_customer(user_id: int) -> dict | None: + """Get billing customer for a user.""" + return await fetch_one( + "SELECT * FROM billing_customers WHERE user_id = ?", (user_id,) + ) + + async def upsert_subscription( user_id: int, plan: str, status: str, - provider_customer_id: str, provider_subscription_id: str, current_period_end: str = None, ) -> int: - """Create or update subscription.""" + """Create or update subscription. Finds existing by provider_subscription_id.""" now = datetime.utcnow().isoformat() - customer_col = "paddle_customer_id" - subscription_col = "paddle_subscription_id" - - - existing = await fetch_one("SELECT id FROM subscriptions WHERE user_id = ?", (user_id,)) + existing = await fetch_one( + "SELECT id FROM subscriptions WHERE provider_subscription_id = ?", + (provider_subscription_id,), + ) if existing: await execute( - f"""UPDATE subscriptions - SET plan = ?, status = ?, {customer_col} = ?, {subscription_col} = ?, - current_period_end = ?, updated_at = ? - WHERE user_id = ?""", - (plan, status, provider_customer_id, provider_subscription_id, - current_period_end, now, user_id), + """UPDATE subscriptions + SET plan = ?, status = ?, current_period_end = ?, updated_at = ? + WHERE id = ?""", + (plan, status, current_period_end, now, existing["id"]), ) return existing["id"] else: return await execute( - f"""INSERT INTO subscriptions - (user_id, plan, status, {customer_col}, {subscription_col}, + """INSERT INTO subscriptions + (user_id, plan, status, provider_subscription_id, current_period_end, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - (user_id, plan, status, provider_customer_id, provider_subscription_id, + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (user_id, plan, status, provider_subscription_id, current_period_end, now, now), ) - async def get_subscription_by_provider_id(subscription_id: str) -> dict | None: return await fetch_one( - "SELECT * FROM subscriptions WHERE paddle_subscription_id = ?", + "SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (subscription_id,) ) - async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None: """Update subscription status by provider subscription ID.""" extra["updated_at"] = datetime.utcnow().isoformat() @@ -98,7 +108,7 @@ async def update_subscription_status(provider_subscription_id: str, status: str, values = list(extra.values()) values.append(provider_subscription_id) - await execute(f"UPDATE subscriptions SET {sets} WHERE paddle_subscription_id = ?", tuple(values)) + await execute(f"UPDATE subscriptions SET {sets} WHERE provider_subscription_id = ?", tuple(values)) @@ -119,25 +129,6 @@ async def is_within_limits(user_id: int, resource: str, current_count: int) -> b return current_count < limit -# ============================================================================= -# Access Gating -# ============================================================================= - -def subscription_required(allowed=("active", "on_trial", "cancelled")): - """Decorator to gate routes behind active subscription.""" - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - if "user_id" not in session: - return redirect(url_for("auth.login")) - sub = await get_subscription(session["user_id"]) - if not sub or sub["status"] not in allowed: - return redirect(url_for("billing.pricing")) - return await func(*args, **kwargs) - return wrapper - return decorator - - # ============================================================================= # Routes # ============================================================================= @@ -185,12 +176,12 @@ async def checkout(plan: str): async def manage(): """Redirect to Paddle customer portal.""" sub = await get_subscription(g.user["id"]) - if not sub or not sub.get("paddle_subscription_id"): + if not sub or not sub.get("provider_subscription_id"): await flash("No active subscription found.", "error") return redirect(url_for("dashboard.settings")) paddle = _paddle_client() - paddle_sub = paddle.subscriptions.get(sub["paddle_subscription_id"]) + paddle_sub = paddle.subscriptions.get(sub["provider_subscription_id"]) portal_url = paddle_sub.management_urls.update_payment_method return redirect(portal_url) @@ -200,11 +191,11 @@ async def manage(): async def cancel(): """Cancel subscription via Paddle API.""" sub = await get_subscription(g.user["id"]) - if sub and sub.get("paddle_subscription_id"): + if sub and sub.get("provider_subscription_id"): from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription paddle = _paddle_client() paddle.subscriptions.cancel( - sub["paddle_subscription_id"], + sub["provider_subscription_id"], CancelSubscription(effective_from="next_billing_period"), ) return redirect(url_for("dashboard.settings")) @@ -246,6 +237,11 @@ async def webhook(): user_id = custom_data.get("user_id") plan = custom_data.get("plan", "") + # Store billing customer for any subscription event with a customer_id + customer_id = str(data.get("customer_id", "")) + if customer_id and user_id: + await upsert_billing_customer(int(user_id), customer_id) + if event_type == "subscription.activated": if plan.startswith("supplier_"): await _handle_supplier_subscription_activated(data, custom_data) @@ -254,7 +250,6 @@ async def webhook(): user_id=int(user_id), 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") or {}).get("ends_at"), ) @@ -290,7 +285,7 @@ CREDIT_PACK_AMOUNTS = { "credits_250": 250, } -PLAN_MONTHLY_CREDITS = {"supplier_growth": 30, "supplier_pro": 100} +PLAN_MONTHLY_CREDITS = {"supplier_basic": 0, "supplier_growth": 30, "supplier_pro": 100} BOOST_PRICE_KEYS = { "boost_logo": "logo", @@ -310,6 +305,28 @@ async def _price_id_to_key(price_id: str) -> str | None: return row["key"] if row else None +def _derive_tier_from_plan(plan: str) -> tuple[str, str]: + """Derive (base_plan, tier) from a plan key, stripping _monthly/_yearly suffixes. + + Returns (base_plan, tier) where base_plan is the canonical key used for + PLAN_MONTHLY_CREDITS lookup and tier is the DB tier string. + """ + base = plan + for suffix in ("_monthly", "_yearly"): + if plan.endswith(suffix): + base = plan[: -len(suffix)] + break + + if base == "supplier_pro": + tier = "pro" + elif base == "supplier_basic": + tier = "basic" + else: + tier = "growth" + + return base, tier + + async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None: """Handle supplier plan subscription activation.""" from ..core import transaction as db_transaction @@ -321,26 +338,28 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) if not supplier_id: return - monthly_credits = PLAN_MONTHLY_CREDITS.get(plan, 0) - tier = "pro" if plan == "supplier_pro" else "growth" + base_plan, tier = _derive_tier_from_plan(plan) + monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0) now = datetime.utcnow().isoformat() async with db_transaction() as db: - # Update supplier record + # Update supplier record — Basic tier also gets is_verified = 1 await db.execute( """UPDATE suppliers SET tier = ?, claimed_at = ?, claimed_by = ?, - monthly_credits = ?, credit_balance = ?, last_credit_refill = ? + monthly_credits = ?, credit_balance = ?, last_credit_refill = ?, + is_verified = 1 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), - ) + # Initial credit allocation — skip for Basic (0 credits) + if monthly_credits > 0: + 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", []) diff --git a/padelnomics/src/padelnomics/directory/routes.py b/padelnomics/src/padelnomics/directory/routes.py index cb74394..6e53258 100644 --- a/padelnomics/src/padelnomics/directory/routes.py +++ b/padelnomics/src/padelnomics/directory/routes.py @@ -6,7 +6,7 @@ from pathlib import Path from quart import Blueprint, redirect, render_template, request, url_for -from ..core import fetch_all, fetch_one +from ..core import csrf_protect, execute, fetch_all, fetch_one bp = Blueprint( "directory", @@ -94,7 +94,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24 f"""SELECT * FROM suppliers s WHERE {where} ORDER BY CASE WHEN s.sticky_until > ? AND (s.sticky_country IS NULL OR s.sticky_country = '' OR s.sticky_country = ?) THEN 0 ELSE 1 END, - CASE s.tier WHEN 'pro' THEN 0 WHEN 'growth' THEN 1 ELSE 2 END, + CASE s.tier WHEN 'pro' THEN 0 WHEN 'growth' THEN 1 WHEN 'basic' THEN 2 ELSE 3 END, s.name LIMIT ? OFFSET ?""", tuple(order_params), @@ -178,6 +178,7 @@ async def index(): @bp.route("/") async def supplier_detail(slug: str): """Public supplier profile page.""" + import json as _json supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,)) if not supplier: from quart import abort @@ -190,12 +191,105 @@ async def supplier_detail(slug: str): ) active_boosts = [b["boost_type"] for b in boosts] + # Parse services_offered into list + raw_services = (supplier.get("services_offered") or "").strip() + services_list = [s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else [] + + # Build social links dict + social_links = { + "linkedin": supplier.get("linkedin_url") or "", + "instagram": supplier.get("instagram_url") or "", + "youtube": supplier.get("youtube_url") or "", + } + + # Enquiry count (Basic+) + enquiry_count = 0 + if supplier.get("tier") in ("basic", "growth", "pro"): + row = await fetch_one( + "SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?", + (supplier["id"],), + ) + enquiry_count = row["cnt"] if row else 0 + return await render_template( "supplier_detail.html", supplier=supplier, active_boosts=active_boosts, country_labels=COUNTRY_LABELS, category_labels=CATEGORY_LABELS, + services_list=services_list, + social_links=social_links, + enquiry_count=enquiry_count, + ) + + +@bp.route("//enquiry", methods=["POST"]) +@csrf_protect +async def supplier_enquiry(slug: str): + """Handle enquiry form submission for Basic+ supplier listings.""" + supplier = await fetch_one( + "SELECT * FROM suppliers WHERE slug = ? AND tier IN ('basic', 'growth', 'pro')", + (slug,), + ) + if not supplier: + from quart import abort + abort(404) + + form = await request.form + contact_name = (form.get("contact_name", "") or "").strip() + contact_email = (form.get("contact_email", "") or "").strip().lower() + message = (form.get("message", "") or "").strip() + + errors = [] + if not contact_name: + errors.append("Name is required.") + if not contact_email or "@" not in contact_email: + errors.append("Valid email is required.") + if not message: + errors.append("Message is required.") + + if errors: + return await render_template( + "partials/enquiry_result.html", + success=False, + errors=errors, + ) + + # Rate-limit: max 5 enquiries per email per 24 h + row = await fetch_one( + """SELECT COUNT(*) as cnt FROM supplier_enquiries + WHERE contact_email = ? AND created_at >= datetime('now', '-1 day')""", + (contact_email,), + ) + if row and row["cnt"] >= 5: + return await render_template( + "partials/enquiry_result.html", + success=False, + errors=["Too many enquiries from this address. Please wait 24 hours."], + ) + + await execute( + """INSERT INTO supplier_enquiries (supplier_id, contact_name, contact_email, message) + VALUES (?, ?, ?, ?)""", + (supplier["id"], contact_name, contact_email, message), + ) + + # Enqueue email to supplier + if supplier.get("contact_email"): + from ..worker import enqueue + await enqueue("send_supplier_enquiry_email", { + "supplier_id": supplier["id"], + "supplier_name": supplier["name"], + "supplier_email": supplier["contact_email"], + "contact_name": contact_name, + "contact_email": contact_email, + "message": message, + }) + + return await render_template( + "partials/enquiry_result.html", + success=True, + supplier=supplier, ) diff --git a/padelnomics/src/padelnomics/directory/templates/partials/enquiry_result.html b/padelnomics/src/padelnomics/directory/templates/partials/enquiry_result.html new file mode 100644 index 0000000..e2425e2 --- /dev/null +++ b/padelnomics/src/padelnomics/directory/templates/partials/enquiry_result.html @@ -0,0 +1,22 @@ +{% if success %} +
+
+

Enquiry sent!

+

+ {% if supplier and supplier.contact_email %} + We've forwarded your message to {{ supplier.name }}. They'll be in touch directly. + {% else %} + Your message has been received. The team will be in touch shortly. + {% endif %} +

+
+{% else %} +
+

Please fix the following:

+
    + {% for e in errors %} +
  • {{ e }}
  • + {% endfor %} +
+
+{% endif %} diff --git a/padelnomics/src/padelnomics/directory/templates/partials/results.html b/padelnomics/src/padelnomics/directory/templates/partials/results.html index d86f8ef..28c0212 100644 --- a/padelnomics/src/padelnomics/directory/templates/partials/results.html +++ b/padelnomics/src/padelnomics/directory/templates/partials/results.html @@ -35,7 +35,7 @@ {% if s.sticky_until and s.sticky_until > now %}{% endif %}
- {% if s.logo_url %}{% endif %} + {% if s.logo_file or s.logo_url %}{% endif %}

{{ s.name }}

{{ category_labels.get(s.category, s.category) }} @@ -72,6 +72,28 @@
+ {# --- Basic tier card --- #} + {% elif s.tier == 'basic' %} + + {% if s.sticky_until and s.sticky_until > now %}{% endif %} +
+
+ {% if s.logo_file or s.logo_url %}{% endif %} +

{{ s.name }}

+
+ {{ category_labels.get(s.category, s.category) }} +
+

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

+
Verified ✓
+ {% if s.short_description or s.description %} +

{{ s.short_description or s.description }}

+ {% endif %} +
+ {% if s.website %}{{ s.website }}{% endif %} + View Listing → +
+
+ {# --- Free / unclaimed tier card --- #} {% else %} @@ -94,19 +116,19 @@ {% if total_pages > 1 %} {% endif %} diff --git a/padelnomics/src/padelnomics/directory/templates/supplier_detail.html b/padelnomics/src/padelnomics/directory/templates/supplier_detail.html index ed9520d..4c8f42b 100644 --- a/padelnomics/src/padelnomics/directory/templates/supplier_detail.html +++ b/padelnomics/src/padelnomics/directory/templates/supplier_detail.html @@ -3,120 +3,517 @@ {% 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 %} -
+{# ── Hero ─────────────────────────────────────────────────────── #} +
+
+ + + Back to Directory + + +
+
+ {% if supplier.logo_file or 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.tagline %} +

{{ supplier.tagline }}

+ {% endif %}
+
- {% set desc = supplier.long_description or supplier.description %} +
+ {% if supplier.tier in ('growth', 'pro') %} + + Request Quote → + + {% endif %} + {% if supplier.website %} + + + Visit Website + + {% endif %} +
+
+
+
+ +{# ── Body ──────────────────────────────────────────────────────── #} +
+ {% if supplier.tier in ('basic', 'growth', 'pro') %} + {# Full two-column layout for paid tiers #} +
+ {# Main column #} +
+ {# About #} + {% set desc = supplier.long_description or supplier.short_description or supplier.description %} + {% if desc or supplier.service_categories %} +
+

About

{% if desc %}

{{ desc }}

{% endif %} - {% if supplier.service_categories %}
{% for cat in (supplier.service_categories or '').split(',') %} {% if cat.strip() %} - {{ cat.strip() }} + {{ cat.strip() | replace('_', ' ') | title }} {% endif %} {% endfor %}
{% endif %} +
+ {% endif %} -
- {% if supplier.service_area %} -
-
Service Area
-
{{ supplier.service_area }}
-
+ {# Services offered #} + {% if services_list %} +
+

Services Offered

+
    + {% for s in services_list %} +
  • {{ s }}
  • + {% endfor %} +
+
+ {% endif %} + + {# Service area #} + {% if supplier.service_area %} +
+

Service Area

+
+ {% for area in (supplier.service_area or '').split(',') %} + {% if area.strip() %} + {{ area.strip() }} {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# Enquiry form for Basic+ #} +
+

Send an Enquiry

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + {# Sidebar #} + +
+ + {# CTA strip — tier-dependent #} + {% if supplier.tier == 'basic' %} +
+
+

Looking for direct quote matching?

+

Upgrade to Growth to appear in our supplier matching and receive qualified project leads.

+
+ + Upgrade to Growth → + +
+ {% elif supplier.tier == 'growth' %} + {# Subtle upgrade nudge — optional #} + {% endif %} + + {% else %} + {# Free / unclaimed tier — minimal layout #} +
+
+ {% set desc = supplier.long_description or supplier.short_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() | replace('_', ' ') | title }} + {% endif %} + {% endfor %} +
+ {% endif %} + + {# Locked quote CTA #} +
+ +

Listing not yet verified

+
+ + {# Claim CTA strip #} + {% if not supplier.claimed_by %} +
+
+

Is this your company?

+

Claim and verify this listing to start receiving project enquiries from padel developers.

+
+ + Claim This Listing → + +
+ {% endif %}
-
+ + + {% endif %} + {% endblock %} diff --git a/padelnomics/src/padelnomics/migrations/versions/0000_initial_schema.py b/padelnomics/src/padelnomics/migrations/versions/0000_initial_schema.py index 52c61d8..6b09481 100644 --- a/padelnomics/src/padelnomics/migrations/versions/0000_initial_schema.py +++ b/padelnomics/src/padelnomics/migrations/versions/0000_initial_schema.py @@ -1,4 +1,4 @@ -"""Initial schema baseline — all tables as of migration 0011.""" +"""Initial schema baseline — all tables as of migration 0012.""" def up(conn): @@ -216,7 +216,14 @@ def up(conn): -- Phase 2: editable profile fields logo_file TEXT, - tagline TEXT + tagline TEXT, + + -- Phase 3: Basic tier fields + services_offered TEXT, + contact_role TEXT, + linkedin_url TEXT, + instagram_url TEXT, + youtube_url TEXT ); CREATE INDEX IF NOT EXISTS idx_suppliers_country ON suppliers(country_code); @@ -276,6 +283,21 @@ def up(conn): 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 enquiries (Basic+ listing contact form) + CREATE TABLE IF NOT EXISTS supplier_enquiries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + contact_name TEXT NOT NULL, + contact_email TEXT NOT NULL, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_supplier + ON supplier_enquiries(supplier_id); + CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_email + ON supplier_enquiries(contact_email, created_at); + -- Supplier boost subscriptions/purchases CREATE TABLE IF NOT EXISTS supplier_boosts ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/padelnomics/src/padelnomics/migrations/versions/0011_add_rbac_and_billing_customers.py b/padelnomics/src/padelnomics/migrations/versions/0011_add_rbac_and_billing_customers.py new file mode 100644 index 0000000..1fedfc2 --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0011_add_rbac_and_billing_customers.py @@ -0,0 +1,85 @@ +""" +Add user_roles and billing_customers tables. +Migrate paddle_customer_id from subscriptions to billing_customers. +Rename paddle_subscription_id -> provider_subscription_id. +Drop UNIQUE on subscriptions.user_id (allow multiple subs per user). +""" + + +def _column_names(conn, table): + return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + + +def up(conn): + # 1. Create new tables (idempotent) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, role) + ); + CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id); + CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role); + + CREATE TABLE IF NOT EXISTS billing_customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + provider_customer_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_billing_customers_user ON billing_customers(user_id); + CREATE INDEX IF NOT EXISTS idx_billing_customers_provider ON billing_customers(provider_customer_id); + """) + + cols = _column_names(conn, "subscriptions") + + # Already migrated — nothing to do + if "provider_subscription_id" in cols and "paddle_customer_id" not in cols: + conn.commit() + return + + # 2. Migrate paddle_customer_id from subscriptions to billing_customers + if "paddle_customer_id" in cols: + conn.execute(""" + INSERT OR IGNORE INTO billing_customers (user_id, provider_customer_id) + SELECT user_id, paddle_customer_id + FROM subscriptions + WHERE paddle_customer_id IS NOT NULL AND paddle_customer_id != '' + GROUP BY user_id + HAVING MAX(created_at) + """) + + # 3. Recreate subscriptions table: + # - Drop paddle_customer_id (moved to billing_customers) + # - Rename paddle_subscription_id -> provider_subscription_id + # - Drop UNIQUE constraint on user_id (allow multiple subs per user) + old_sub_col = "paddle_subscription_id" if "paddle_subscription_id" in cols else "provider_subscription_id" + + conn.executescript(f""" + ALTER TABLE subscriptions RENAME TO _subscriptions_old; + + CREATE TABLE subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free', + status TEXT NOT NULL DEFAULT 'free', + provider_subscription_id TEXT, + current_period_end TEXT, + created_at TEXT NOT NULL, + updated_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id); + CREATE INDEX IF NOT EXISTS idx_subscriptions_provider ON subscriptions(provider_subscription_id); + + INSERT INTO subscriptions (id, user_id, plan, status, provider_subscription_id, + current_period_end, created_at, updated_at) + SELECT id, user_id, plan, status, {old_sub_col}, + current_period_end, created_at, updated_at + FROM _subscriptions_old; + + DROP TABLE _subscriptions_old; + """) + + conn.commit() diff --git a/padelnomics/src/padelnomics/migrations/versions/0012_add_basic_tier_fields.py b/padelnomics/src/padelnomics/migrations/versions/0012_add_basic_tier_fields.py new file mode 100644 index 0000000..a9cb5f9 --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0012_add_basic_tier_fields.py @@ -0,0 +1,42 @@ +""" +Add Basic tier supplier fields and supplier_enquiries table. +New columns on suppliers: services_offered, contact_role, linkedin_url, +instagram_url, youtube_url. +New table: supplier_enquiries. +""" + + +def _column_names(conn, table): + return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + + +def up(conn): + cols = _column_names(conn, "suppliers") + + for col, defn in [ + ("services_offered", "TEXT"), + ("contact_role", "TEXT"), + ("linkedin_url", "TEXT"), + ("instagram_url", "TEXT"), + ("youtube_url", "TEXT"), + ]: + if col not in cols: + conn.execute(f"ALTER TABLE suppliers ADD COLUMN {col} {defn}") + + conn.executescript(""" + CREATE TABLE IF NOT EXISTS supplier_enquiries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + contact_name TEXT NOT NULL, + contact_email TEXT NOT NULL, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_supplier + ON supplier_enquiries(supplier_id); + CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_email + ON supplier_enquiries(contact_email, created_at); + """) + + conn.commit() diff --git a/padelnomics/src/padelnomics/scripts/setup_paddle.py b/padelnomics/src/padelnomics/scripts/setup_paddle.py index 6e50409..53df363 100644 --- a/padelnomics/src/padelnomics/scripts/setup_paddle.py +++ b/padelnomics/src/padelnomics/scripts/setup_paddle.py @@ -38,23 +38,57 @@ if not PADDLE_API_KEY: # Maps our internal key -> product name in Paddle. # The name is used to match existing products on sync. PRODUCTS = [ - # Subscriptions + # Subscriptions — Basic tier (new) { - "key": "supplier_growth", - "name": "Supplier Growth", - "price": 14900, + "key": "supplier_basic_monthly", + "name": "Supplier Basic (Monthly)", + "price": 3900, "currency": CurrencyCode.EUR, "interval": "month", "billing_type": "subscription", }, { - "key": "supplier_pro", - "name": "Supplier Pro", - "price": 39900, + "key": "supplier_basic_yearly", + "name": "Supplier Basic (Yearly)", + "price": 34900, + "currency": CurrencyCode.EUR, + "interval": "year", + "billing_type": "subscription", + }, + # Subscriptions — Growth tier (existing monthly + new yearly) + { + "key": "supplier_growth", + "name": "Supplier Growth", + "price": 19900, "currency": CurrencyCode.EUR, "interval": "month", "billing_type": "subscription", }, + { + "key": "supplier_growth_yearly", + "name": "Supplier Growth (Yearly)", + "price": 179900, + "currency": CurrencyCode.EUR, + "interval": "year", + "billing_type": "subscription", + }, + # Subscriptions — Pro tier (existing monthly + new yearly) + { + "key": "supplier_pro", + "name": "Supplier Pro", + "price": 49900, + "currency": CurrencyCode.EUR, + "interval": "month", + "billing_type": "subscription", + }, + { + "key": "supplier_pro_yearly", + "name": "Supplier Pro (Yearly)", + "price": 449900, + "currency": CurrencyCode.EUR, + "interval": "year", + "billing_type": "subscription", + }, # Boost add-ons (subscriptions) { "key": "boost_logo", @@ -234,7 +268,10 @@ def create(paddle, conn): if spec["billing_type"] == "subscription": from paddle_billing.Entities.Shared import Duration, Interval - price_kwargs["billing_cycle"] = Duration(interval=Interval.Month, frequency=1) + if spec.get("interval") == "year": + price_kwargs["billing_cycle"] = Duration(interval=Interval.Year, frequency=1) + else: + price_kwargs["billing_cycle"] = Duration(interval=Interval.Month, frequency=1) price = paddle.prices.create(CreatePrice(**price_kwargs)) print(f" Price: {spec['key']} = {price.id}") diff --git a/padelnomics/src/padelnomics/suppliers/routes.py b/padelnomics/src/padelnomics/suppliers/routes.py index b87b2e4..8f2946a 100644 --- a/padelnomics/src/padelnomics/suppliers/routes.py +++ b/padelnomics/src/padelnomics/suppliers/routes.py @@ -25,34 +25,55 @@ bp = Blueprint( # ============================================================================= PLAN_FEATURES = { + "supplier_basic": { + "name": "Basic", + "monthly_price": 39, + "yearly_price": 349, + "yearly_monthly_equivalent": 29, + "monthly_credits": 0, + "paddle_key_monthly": "supplier_basic_monthly", + "paddle_key_yearly": "supplier_basic_yearly", + "features": [ + "Verified badge", + "Company logo", + "Full description & tagline", + "Website & contact details shown", + "Services offered checklist", + "Social links (LinkedIn, Instagram, YouTube)", + "Enquiry form on listing page", + ], + }, "supplier_growth": { "name": "Growth", - "price": 149, + "monthly_price": 199, + "yearly_price": 1799, + "yearly_monthly_equivalent": 150, "monthly_credits": 30, + "paddle_key_monthly": "supplier_growth", + "paddle_key_yearly": "supplier_growth_yearly", "features": [ - "Company name & category badge", - "City & country shown", - "Description (3 lines)", - "\"Growth\" badge", - "Priority over free listings", + "Everything in Basic", "30 lead credits/month", + "Lead feed access", + "Priority over Basic listings", ], }, "supplier_pro": { "name": "Pro", - "price": 399, + "monthly_price": 499, + "yearly_price": 4499, + "yearly_monthly_equivalent": 375, "monthly_credits": 100, + "paddle_key_monthly": "supplier_pro", + "paddle_key_yearly": "supplier_pro_yearly", + "includes": ["logo", "highlight", "verified"], "features": [ "Everything in Growth", - "Company logo displayed", - "Full description", - "Website link shown", - "Verified badge", - "Priority placement", - "Highlighted card border", "100 lead credits/month", + "Company logo displayed", + "Highlighted card border", + "Priority placement", ], - "includes": ["logo", "highlight", "verified"], }, } @@ -103,7 +124,29 @@ def _get_supplier_for_user(user_id: int): # ============================================================================= def _supplier_required(f): - """Require authenticated user with a claimed supplier on a paid tier.""" + """Require authenticated user with a claimed supplier on any paid tier (basic, growth, pro).""" + 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 ('basic', '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 + + +def _lead_tier_required(f): + """Require authenticated user with growth or pro tier (lead credit access).""" from functools import wraps @wraps(f) @@ -116,8 +159,8 @@ def _supplier_required(f): (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")) + await flash("Lead access requires a Growth or Pro plan.", "warning") + return redirect(url_for("suppliers.dashboard")) g.supplier = supplier return await f(*args, **kwargs) @@ -198,17 +241,24 @@ 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 + period = data.get("billing_period", "yearly") + if period == "yearly": + plan_price = plan_info["yearly_price"] + plan_price_display = plan_info["yearly_monthly_equivalent"] + billing_label = f"billed annually at €{plan_price}/yr" + else: + plan_price = plan_info["monthly_price"] + plan_price_display = plan_info["monthly_price"] + billing_label = "billed monthly" + + 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: @@ -216,9 +266,12 @@ def _compute_order(data: dict, included_boosts: list) -> dict: return { "plan_name": plan_info["name"], - "plan_price": plan_info["price"], + "plan_price": plan_price, + "plan_price_display": plan_price_display, + "billing_label": billing_label, + "billing_period": period, "boost_monthly": boost_monthly, - "monthly_total": monthly, + "monthly_total": plan_price_display + boost_monthly, "one_time_total": one_time, "credit_pack": credit_pack, } @@ -238,7 +291,14 @@ async def signup_checkout(): accumulated[k] = v plan = accumulated.get("plan", "supplier_growth") - plan_price_id = await get_paddle_price(plan) + plan_info = PLAN_FEATURES.get(plan, {}) + period = accumulated.get("billing_period", "yearly") + price_key = ( + plan_info.get("paddle_key_yearly", plan) + if period == "yearly" + else plan_info.get("paddle_key_monthly", plan) + ) + plan_price_id = await get_paddle_price(price_key) if not plan_price_id: return jsonify({"error": "Invalid plan selected. Run setup_paddle first."}), 400 @@ -246,7 +306,6 @@ async def signup_checkout(): 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): @@ -345,7 +404,7 @@ async def signup_success(): # Supplier Lead Feed # ============================================================================= -async def _get_lead_feed_data(supplier, country="", heat="", timeline="", limit=50): +async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="", 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 = [] @@ -359,6 +418,10 @@ async def _get_lead_feed_data(supplier, country="", heat="", timeline="", limit= if timeline: wheres.append("lr.timeline = ?") params.append(timeline) + if q: + wheres.append("(lr.country LIKE ? OR lr.facility_type LIKE ? OR lr.additional_info LIKE ?)") + like_q = f"%{q}%" + params.extend([like_q, like_q, like_q]) where = " AND ".join(wheres) params.append(limit) @@ -381,7 +444,7 @@ async def _get_lead_feed_data(supplier, country="", heat="", timeline="", limit= @bp.route("/leads") -@_supplier_required +@_lead_tier_required async def lead_feed(): """Lead feed for paying suppliers (standalone page).""" supplier = g.supplier @@ -401,7 +464,7 @@ async def lead_feed(): @bp.route("/leads//unlock", methods=["POST"]) -@_supplier_required +@_lead_tier_required @csrf_protect async def unlock_lead(lead_id: int): """Spend credits to unlock a lead. Returns full-details card via HTMX.""" @@ -442,11 +505,22 @@ async def unlock_lead(lead_id: int): 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"],)) + # Look up linked scenario + scenario_id = None + if full_lead and full_lead.get("user_id"): + scenario = await fetch_one( + "SELECT id FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1", + (full_lead["user_id"],), + ) + if scenario: + scenario_id = scenario["id"] + return await render_template( "suppliers/partials/lead_card_unlocked.html", lead=full_lead, supplier=updated_supplier, credit_cost=result["credit_cost"], + scenario_id=scenario_id, ) @@ -520,6 +594,15 @@ async def dashboard_overview(): (supplier["id"],), ) + # Enquiry count for Basic tier + enquiry_count = 0 + if supplier.get("tier") == "basic": + eq_row = await fetch_one( + "SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?", + (supplier["id"],), + ) + enquiry_count = eq_row["cnt"] if eq_row else 0 + return await render_template( "suppliers/partials/dashboard_overview.html", supplier=supplier, @@ -527,23 +610,38 @@ async def dashboard_overview(): new_leads_count=new_leads_count, recent_activity=recent_activity, active_boosts=active_boosts, + enquiry_count=enquiry_count, ) @bp.route("/dashboard/leads") -@_supplier_required +@_lead_tier_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", "") + q = request.args.get("q", "").strip() - leads, countries = await _get_lead_feed_data(supplier, country, heat, timeline) + leads, countries = await _get_lead_feed_data(supplier, country, heat, timeline, q) # Parse supplier's service area for region matching service_area = [c.strip() for c in (supplier.get("service_area") or "").split(",") if c.strip()] + # Look up scenario IDs for unlocked leads + scenario_ids = {} + unlocked_user_ids = [l["user_id"] for l in leads if l.get("is_unlocked") and l.get("user_id")] + if unlocked_user_ids: + placeholders = ",".join("?" * len(unlocked_user_ids)) + scenarios = await fetch_all( + f"SELECT user_id, id FROM scenarios WHERE user_id IN ({placeholders}) AND deleted_at IS NULL ORDER BY updated_at DESC", + tuple(unlocked_user_ids), + ) + for s in scenarios: + if s["user_id"] not in scenario_ids: + scenario_ids[s["user_id"]] = s["id"] + return await render_template( "suppliers/partials/dashboard_leads.html", leads=leads, @@ -552,7 +650,9 @@ async def dashboard_leads(): current_country=country, current_heat=heat, current_timeline=timeline, + current_q=q, service_area=service_area, + scenario_ids=scenario_ids, ) @@ -578,6 +678,32 @@ async def dashboard_listing(): ) +@bp.route("/dashboard/listing/preview") +@_supplier_required +async def dashboard_listing_preview(): + """HTMX partial — returns just the preview card with form values overlaid.""" + supplier = g.supplier + args = request.args + + # Overlay form values onto supplier dict for preview + preview = dict(supplier) + for field in ("name", "tagline", "short_description", "website"): + if args.get(field) is not None: + preview[field] = args.get(field) + + 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_preview.html", + supplier=preview, + active_boosts=active_boosts, + ) + + @bp.route("/dashboard/listing", methods=["POST"]) @_supplier_required @csrf_protect @@ -606,13 +732,19 @@ async def dashboard_listing_save(): areas = form.getlist("service_area") area_str = ",".join(areas) + # Multi-value services offered + services = form.getlist("services_offered") + services_str = ",".join(services) + 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 = ? + logo_file = ?, + services_offered = ?, contact_role = ?, + linkedin_url = ?, instagram_url = ?, youtube_url = ? WHERE id = ?""", ( form.get("name", supplier["name"]), @@ -628,6 +760,11 @@ async def dashboard_listing_save(): int(form.get("years_in_business", 0) or 0), int(form.get("project_count", 0) or 0), logo_path, + services_str, + form.get("contact_role", ""), + form.get("linkedin_url", ""), + form.get("instagram_url", ""), + form.get("youtube_url", ""), supplier["id"], ), ) diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html index 3c73dd4..71def84 100644 --- a/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html @@ -23,6 +23,7 @@ padding: 10px 1.25rem; font-size: 0.8125rem; color: #64748B; text-decoration: none; transition: all 0.1s; } + .dash-nav a svg { width: 16px; height: 16px; flex-shrink: 0; } .dash-nav a:hover { background: #EFF6FF; color: #1D4ED8; } .dash-nav a.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #1D4ED8; } @@ -64,33 +65,50 @@ hx-target="#dashboard-content" hx-push-url="{{ url_for('suppliers.dashboard', tab='overview') }}" class="{% if active_tab == 'overview' %}active{% endif %}"> + Overview + {% if supplier.tier in ('growth', 'pro') %} + Lead Feed + {% endif %} + My Listing + {% if supplier.tier in ('growth', 'pro') %} + Boost & Upsells + {% endif %} + {% if supplier.tier == 'basic' %} +
+ Basic plan — directory listing + enquiry form. + + Upgrade to Growth for lead access → + +
+ {% endif %} +