add Basic tier, monthly/yearly billing, and supplier detail redesign

- 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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 15:03:21 +01:00
parent 07c7e61049
commit 536eefffdb
22 changed files with 1592 additions and 350 deletions

View File

@@ -6,7 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [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 ### 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 - **Migration system: single source of truth** — eliminated dual-maintenance
of `schema.sql` + versioned migrations; all databases (fresh and existing) of `schema.sql` + versioned migrations; all databases (fresh and existing)
now replay migrations in order starting from `0000_initial_schema.py`; now replay migrations in order starting from `0000_initial_schema.py`;

View File

@@ -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 csv
import io import io
import json import json
import secrets
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from functools import wraps
from pathlib import Path from pathlib import Path
import mistune import mistune
from quart import Blueprint, flash, redirect, render_template, request, session, url_for from quart import Blueprint, flash, redirect, render_template, request, session, url_for
from ..core import config, csrf_protect, execute, 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 # Blueprint with its own template folder
bp = Blueprint( 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 # 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: 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( 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 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 = ? WHERE u.id = ?
""", """,
(user_id,) (user_id,)
@@ -197,62 +186,12 @@ async def delete_task(task_id: int) -> bool:
return result > 0 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 # 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("/") @bp.route("/")
@admin_required @role_required("admin")
async def index(): async def index():
"""Admin dashboard.""" """Admin dashboard."""
stats = await get_dashboard_stats() stats = await get_dashboard_stats()
@@ -268,7 +207,7 @@ async def index():
@bp.route("/users") @bp.route("/users")
@admin_required @role_required("admin")
async def users(): async def users():
"""User list.""" """User list."""
search = request.args.get("search", "").strip() search = request.args.get("search", "").strip()
@@ -287,7 +226,7 @@ async def users():
@bp.route("/users/<int:user_id>") @bp.route("/users/<int:user_id>")
@admin_required @role_required("admin")
async def user_detail(user_id: int): async def user_detail(user_id: int):
"""User detail page.""" """User detail page."""
user = await get_user_by_id(user_id) user = await get_user_by_id(user_id)
@@ -299,7 +238,7 @@ async def user_detail(user_id: int):
@bp.route("/users/<int:user_id>/impersonate", methods=["POST"]) @bp.route("/users/<int:user_id>/impersonate", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def impersonate(user_id: int): async def impersonate(user_id: int):
"""Impersonate a user (login as them).""" """Impersonate a user (login as them)."""
@@ -333,7 +272,7 @@ async def stop_impersonating():
@bp.route("/tasks") @bp.route("/tasks")
@admin_required @role_required("admin")
async def tasks(): async def tasks():
"""Task queue management.""" """Task queue management."""
task_list = await get_recent_tasks(limit=100) task_list = await get_recent_tasks(limit=100)
@@ -347,7 +286,7 @@ async def tasks():
@bp.route("/tasks/<int:task_id>/retry", methods=["POST"]) @bp.route("/tasks/<int:task_id>/retry", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def task_retry(task_id: int): async def task_retry(task_id: int):
"""Retry a failed task.""" """Retry a failed task."""
@@ -360,7 +299,7 @@ async def task_retry(task_id: int):
@bp.route("/tasks/<int:task_id>/delete", methods=["POST"]) @bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def task_delete(task_id: int): async def task_delete(task_id: int):
"""Delete a task.""" """Delete a task."""
@@ -436,7 +375,7 @@ async def get_lead_stats() -> dict:
@bp.route("/leads") @bp.route("/leads")
@admin_required @role_required("admin")
async def leads(): async def leads():
"""Lead management list.""" """Lead management list."""
status = request.args.get("status", "") status = request.args.get("status", "")
@@ -468,7 +407,7 @@ async def leads():
@bp.route("/leads/results") @bp.route("/leads/results")
@admin_required @role_required("admin")
async def lead_results(): async def lead_results():
"""HTMX partial for filtered lead results.""" """HTMX partial for filtered lead results."""
status = request.args.get("status", "") status = request.args.get("status", "")
@@ -483,7 +422,7 @@ async def lead_results():
@bp.route("/leads/<int:lead_id>") @bp.route("/leads/<int:lead_id>")
@admin_required @role_required("admin")
async def lead_detail(lead_id: int): async def lead_detail(lead_id: int):
"""Lead detail page.""" """Lead detail page."""
lead = await get_lead_detail(lead_id) lead = await get_lead_detail(lead_id)
@@ -503,7 +442,7 @@ async def lead_detail(lead_id: int):
@bp.route("/leads/<int:lead_id>/status", methods=["POST"]) @bp.route("/leads/<int:lead_id>/status", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def lead_status(lead_id: int): async def lead_status(lead_id: int):
"""Update lead status.""" """Update lead status."""
@@ -521,7 +460,7 @@ async def lead_status(lead_id: int):
@bp.route("/leads/new", methods=["GET", "POST"]) @bp.route("/leads/new", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def lead_new(): async def lead_new():
"""Create a new lead from admin.""" """Create a new lead from admin."""
@@ -572,7 +511,7 @@ async def lead_new():
@bp.route("/leads/<int:lead_id>/forward", methods=["POST"]) @bp.route("/leads/<int:lead_id>/forward", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def lead_forward(lead_id: int): async def lead_forward(lead_id: int):
"""Manually forward a lead to a supplier (no credit cost).""" """Manually forward a lead to a supplier (no credit cost)."""
@@ -618,7 +557,7 @@ async def lead_forward(lead_id: int):
# Supplier Management # Supplier Management
# ============================================================================= # =============================================================================
SUPPLIER_TIERS = ["free", "growth", "pro"] SUPPLIER_TIERS = ["free", "basic", "growth", "pro"]
async def get_suppliers_list( async def get_suppliers_list(
@@ -663,7 +602,7 @@ async def get_supplier_stats() -> dict:
@bp.route("/suppliers") @bp.route("/suppliers")
@admin_required @role_required("admin")
async def suppliers(): async def suppliers():
"""Supplier management list.""" """Supplier management list."""
search = request.args.get("search", "").strip() search = request.args.get("search", "").strip()
@@ -695,7 +634,7 @@ async def suppliers():
@bp.route("/suppliers/results") @bp.route("/suppliers/results")
@admin_required @role_required("admin")
async def supplier_results(): async def supplier_results():
"""HTMX partial for filtered supplier results.""" """HTMX partial for filtered supplier results."""
search = request.args.get("search", "").strip() search = request.args.get("search", "").strip()
@@ -711,7 +650,7 @@ async def supplier_results():
@bp.route("/suppliers/<int:supplier_id>") @bp.route("/suppliers/<int:supplier_id>")
@admin_required @role_required("admin")
async def supplier_detail(supplier_id: int): async def supplier_detail(supplier_id: int):
"""Supplier detail page.""" """Supplier detail page."""
supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,)) supplier = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,))
@@ -740,6 +679,12 @@ async def supplier_detail(supplier_id: int):
(supplier_id,), (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( return await render_template(
"admin/supplier_detail.html", "admin/supplier_detail.html",
supplier=supplier, supplier=supplier,
@@ -748,11 +693,12 @@ async def supplier_detail(supplier_id: int):
ledger=ledger, ledger=ledger,
boosts=boosts, boosts=boosts,
forwards=forwards, forwards=forwards,
enquiry_count=enquiry_count,
) )
@bp.route("/suppliers/new", methods=["GET", "POST"]) @bp.route("/suppliers/new", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def supplier_new(): async def supplier_new():
"""Create a new supplier from admin.""" """Create a new supplier from admin."""
@@ -783,14 +729,22 @@ async def supplier_new():
await flash(f"Slug '{slug}' already exists.", "error") await flash(f"Slug '{slug}' already exists.", "error")
return await render_template("admin/supplier_form.html", data=dict(form)) 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() now = datetime.utcnow().isoformat()
supplier_id = await execute( supplier_id = await execute(
"""INSERT INTO suppliers """INSERT INTO suppliers
(name, slug, country_code, city, region, website, description, category, (name, slug, country_code, city, region, website, description, category,
tier, contact_name, contact_email, created_at) tier, contact_name, contact_email, contact_role, services_offered,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", linkedin_url, instagram_url, youtube_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(name, slug, country_code, city, region, website, description, (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") await flash(f"Supplier '{name}' created.", "success")
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id)) return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
@@ -799,7 +753,7 @@ async def supplier_new():
@bp.route("/suppliers/<int:supplier_id>/credits", methods=["POST"]) @bp.route("/suppliers/<int:supplier_id>/credits", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def supplier_credits(supplier_id: int): async def supplier_credits(supplier_id: int):
"""Manually adjust supplier credits.""" """Manually adjust supplier credits."""
@@ -828,7 +782,7 @@ async def supplier_credits(supplier_id: int):
@bp.route("/suppliers/<int:supplier_id>/tier", methods=["POST"]) @bp.route("/suppliers/<int:supplier_id>/tier", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def supplier_tier(supplier_id: int): async def supplier_tier(supplier_id: int):
"""Manually change supplier tier.""" """Manually change supplier tier."""
@@ -850,7 +804,7 @@ async def supplier_tier(supplier_id: int):
# ============================================================================= # =============================================================================
@bp.route("/feedback") @bp.route("/feedback")
@admin_required @role_required("admin")
async def feedback(): async def feedback():
"""View user feedback submissions.""" """View user feedback submissions."""
feedback_list = await fetch_all( feedback_list = await fetch_all(
@@ -868,7 +822,7 @@ async def feedback():
# ============================================================================= # =============================================================================
@bp.route("/templates") @bp.route("/templates")
@admin_required @role_required("admin")
async def templates(): async def templates():
"""List article templates.""" """List article templates."""
template_list = await fetch_all( template_list = await fetch_all(
@@ -889,7 +843,7 @@ async def templates():
@bp.route("/templates/new", methods=["GET", "POST"]) @bp.route("/templates/new", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_new(): async def template_new():
"""Create a new article template.""" """Create a new article template."""
@@ -937,7 +891,7 @@ async def template_new():
@bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"]) @bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_edit(template_id: int): async def template_edit(template_id: int):
"""Edit an article template.""" """Edit an article template."""
@@ -988,7 +942,7 @@ async def template_edit(template_id: int):
@bp.route("/templates/<int:template_id>/delete", methods=["POST"]) @bp.route("/templates/<int:template_id>/delete", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_delete(template_id: int): async def template_delete(template_id: int):
"""Delete an article template.""" """Delete an article template."""
@@ -1002,7 +956,7 @@ async def template_delete(template_id: int):
# ============================================================================= # =============================================================================
@bp.route("/templates/<int:template_id>/data") @bp.route("/templates/<int:template_id>/data")
@admin_required @role_required("admin")
async def template_data(template_id: int): async def template_data(template_id: int):
"""View data rows for a template.""" """View data rows for a template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,)) 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/<int:template_id>/data/add", methods=["POST"]) @bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_data_add(template_id: int): async def template_data_add(template_id: int):
"""Add a single data row.""" """Add a single data row."""
@@ -1070,7 +1024,7 @@ async def template_data_add(template_id: int):
@bp.route("/templates/<int:template_id>/data/upload", methods=["POST"]) @bp.route("/templates/<int:template_id>/data/upload", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_data_upload(template_id: int): async def template_data_upload(template_id: int):
"""Bulk upload data rows from CSV.""" """Bulk upload data rows from CSV."""
@@ -1103,7 +1057,7 @@ async def template_data_upload(template_id: int):
@bp.route("/templates/<int:template_id>/data/<int:data_id>/delete", methods=["POST"]) @bp.route("/templates/<int:template_id>/data/<int:data_id>/delete", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_data_delete(template_id: int, data_id: int): async def template_data_delete(template_id: int, data_id: int):
"""Delete a single data row.""" """Delete a single data row."""
@@ -1125,7 +1079,7 @@ def _render_jinja_string(template_str: str, context: dict) -> str:
@bp.route("/templates/<int:template_id>/generate", methods=["GET", "POST"]) @bp.route("/templates/<int:template_id>/generate", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def template_generate(template_id: int): async def template_generate(template_id: int):
"""Bulk-generate scenarios + articles from template data.""" """Bulk-generate scenarios + articles from template data."""
@@ -1298,7 +1252,7 @@ SCENARIO_FORM_FIELDS = [
@bp.route("/scenarios") @bp.route("/scenarios")
@admin_required @role_required("admin")
async def scenarios(): async def scenarios():
"""List published scenarios.""" """List published scenarios."""
scenario_list = await fetch_all( scenario_list = await fetch_all(
@@ -1308,7 +1262,7 @@ async def scenarios():
@bp.route("/scenarios/new", methods=["GET", "POST"]) @bp.route("/scenarios/new", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def scenario_new(): async def scenario_new():
"""Create a published scenario manually.""" """Create a published scenario manually."""
@@ -1362,7 +1316,7 @@ async def scenario_new():
@bp.route("/scenarios/<int:scenario_id>/edit", methods=["GET", "POST"]) @bp.route("/scenarios/<int:scenario_id>/edit", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def scenario_edit(scenario_id: int): async def scenario_edit(scenario_id: int):
"""Edit a published scenario.""" """Edit a published scenario."""
@@ -1425,7 +1379,7 @@ async def scenario_edit(scenario_id: int):
@bp.route("/scenarios/<int:scenario_id>/delete", methods=["POST"]) @bp.route("/scenarios/<int:scenario_id>/delete", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def scenario_delete(scenario_id: int): async def scenario_delete(scenario_id: int):
"""Delete a published scenario.""" """Delete a published scenario."""
@@ -1435,7 +1389,7 @@ async def scenario_delete(scenario_id: int):
@bp.route("/scenarios/<int:scenario_id>/preview") @bp.route("/scenarios/<int:scenario_id>/preview")
@admin_required @role_required("admin")
async def scenario_preview(scenario_id: int): async def scenario_preview(scenario_id: int):
"""Preview a rendered scenario card.""" """Preview a rendered scenario card."""
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,)) 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") @bp.route("/articles")
@admin_required @role_required("admin")
async def articles(): async def articles():
"""List all articles.""" """List all articles."""
article_list = await fetch_all( article_list = await fetch_all(
@@ -1466,7 +1420,7 @@ async def articles():
@bp.route("/articles/new", methods=["GET", "POST"]) @bp.route("/articles/new", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def article_new(): async def article_new():
"""Create a manual article.""" """Create a manual article."""
@@ -1523,7 +1477,7 @@ async def article_new():
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"]) @bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def article_edit(article_id: int): async def article_edit(article_id: int):
"""Edit a manual article.""" """Edit a manual article."""
@@ -1588,7 +1542,7 @@ async def article_edit(article_id: int):
@bp.route("/articles/<int:article_id>/delete", methods=["POST"]) @bp.route("/articles/<int:article_id>/delete", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def article_delete(article_id: int): async def article_delete(article_id: int):
"""Delete an article.""" """Delete an article."""
@@ -1609,7 +1563,7 @@ async def article_delete(article_id: int):
@bp.route("/articles/<int:article_id>/publish", methods=["POST"]) @bp.route("/articles/<int:article_id>/publish", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def article_publish(article_id: int): async def article_publish(article_id: int):
"""Toggle article status between draft and published.""" """Toggle article status between draft and published."""
@@ -1629,7 +1583,7 @@ async def article_publish(article_id: int):
@bp.route("/articles/<int:article_id>/rebuild", methods=["POST"]) @bp.route("/articles/<int:article_id>/rebuild", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def article_rebuild(article_id: int): async def article_rebuild(article_id: int):
"""Re-render an article's HTML from source.""" """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"]) @bp.route("/rebuild-all", methods=["POST"])
@admin_required @role_required("admin")
@csrf_protect @csrf_protect
async def rebuild_all(): async def rebuild_all():
"""Re-render all articles.""" """Re-render all articles."""

View File

@@ -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 title %}{{ supplier.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-6"> <header class="flex justify-between items-center mb-6">
<div> <div>
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">&larr; All Suppliers</a> <a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">&larr; All Suppliers</a>
@@ -11,7 +11,9 @@
{% elif supplier.tier == 'growth' %}<span class="badge-warning">GROWTH</span> {% elif supplier.tier == 'growth' %}<span class="badge-warning">GROWTH</span>
{% else %}<span class="badge">FREE</span>{% endif %} {% else %}<span class="badge">FREE</span>{% endif %}
</h1> </h1>
<p class="text-sm text-slate mt-1">{{ supplier.slug }} &middot; {{ supplier.country_code or '-' }}</p> <p class="text-sm text-slate mt-1">{{ supplier.slug }} &middot; {{ supplier.country_code or '-' }}
{% if supplier.tier == 'basic' %}<span class="badge" style="background:#E0F2FE;color:#0369A1">BASIC</span>{% endif %}
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{% if supplier.claimed_by %} {% if supplier.claimed_by %}
@@ -52,6 +54,24 @@
<dd>{{ supplier.years_in_business or '-' }}</dd> <dd>{{ supplier.years_in_business or '-' }}</dd>
<dt class="text-slate">Projects</dt> <dt class="text-slate">Projects</dt>
<dd>{{ supplier.project_count or '-' }}</dd> <dd>{{ supplier.project_count or '-' }}</dd>
{% if supplier.contact_role %}
<dt class="text-slate">Contact Role</dt>
<dd>{{ supplier.contact_role }}</dd>
{% endif %}
{% if supplier.services_offered %}
<dt class="text-slate">Services</dt>
<dd class="text-xs">{{ supplier.services_offered }}</dd>
{% endif %}
{% if supplier.linkedin_url or supplier.instagram_url or supplier.youtube_url %}
<dt class="text-slate">Social</dt>
<dd class="text-xs">
{% if supplier.linkedin_url %}<a href="{{ supplier.linkedin_url }}" target="_blank" class="text-sm">LinkedIn</a> {% endif %}
{% if supplier.instagram_url %}<a href="{{ supplier.instagram_url }}" target="_blank" class="text-sm">Instagram</a> {% endif %}
{% if supplier.youtube_url %}<a href="{{ supplier.youtube_url }}" target="_blank" class="text-sm">YouTube</a> {% endif %}
</dd>
{% endif %}
<dt class="text-slate">Enquiries</dt>
<dd>{{ enquiry_count }}</dd>
<dt class="text-slate">Claimed By</dt> <dt class="text-slate">Claimed By</dt>
<dd>{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}</dd> <dd>{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}</dd>
<dt class="text-slate">Created</dt> <dt class="text-slate">Created</dt>
@@ -173,5 +193,4 @@
{% endif %} {% endif %}
</div> </div>
</section> </section>
</main>
{% endblock %} {% endblock %}

View File

@@ -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 title %}New Supplier - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %} {% block admin_content %}
<main class="container-page py-12" style="max-width:640px"> <div style="max-width:640px">
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">&larr; All Suppliers</a> <a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">&larr; All Suppliers</a>
<h1 class="text-2xl mt-2 mb-6">Create Supplier</h1> <h1 class="text-2xl mt-2 mb-6">Create Supplier</h1>
@@ -55,7 +56,7 @@
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
<label class="form-label">Tier</label> <label class="form-label">Tier</label>
<select name="tier" class="form-input"> <select name="tier" class="form-input">
{% for t in ['free', 'growth', 'pro'] %} {% for t in ['free', 'basic', 'growth', 'pro'] %}
<option value="{{ t }}" {{ 'selected' if data.get('tier') == t }}>{{ t | capitalize }}</option> <option value="{{ t }}" {{ 'selected' if data.get('tier') == t }}>{{ t | capitalize }}</option>
{% endfor %} {% endfor %}
</select> </select>
@@ -82,7 +83,37 @@
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Contact Role</label>
<input type="text" name="contact_role" class="form-input" value="{{ data.get('contact_role', '') }}"
placeholder="e.g. Managing Director">
</div>
<div></div>
</div>
<div style="margin-bottom:1rem">
<label class="form-label">Services Offered <span class="form-hint">(comma-separated)</span></label>
<input type="text" name="services_offered" class="form-input" value="{{ data.get('services_offered', '') }}"
placeholder="Installation & commissioning, Lighting systems">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">LinkedIn URL</label>
<input type="url" name="linkedin_url" class="form-input" value="{{ data.get('linkedin_url', '') }}" placeholder="https://linkedin.com/company/...">
</div>
<div>
<label class="form-label">Instagram URL</label>
<input type="url" name="instagram_url" class="form-input" value="{{ data.get('instagram_url', '') }}" placeholder="https://instagram.com/...">
</div>
<div>
<label class="form-label">YouTube URL</label>
<input type="url" name="youtube_url" class="form-input" value="{{ data.get('youtube_url', '') }}" placeholder="https://youtube.com/...">
</div>
</div>
<button type="submit" class="btn" style="width:100%">Create Supplier</button> <button type="submit" class="btn" style="width:100%">Create Supplier</button>
</form> </form>
</main> </div>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,6 @@ Payment provider: paddle
import json import json
from datetime import datetime from datetime import datetime
from functools import wraps
from pathlib import Path from pathlib import Path
from paddle_billing import Client as PaddleClient from paddle_billing import Client as PaddleClient
@@ -36,60 +35,71 @@ bp = Blueprint(
# ============================================================================= # =============================================================================
async def get_subscription(user_id: int) -> dict | None: async def get_subscription(user_id: int) -> dict | None:
"""Get user's subscription.""" """Get user's most recent subscription."""
return await fetch_one( return await fetch_one(
"SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
(user_id,) (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( async def upsert_subscription(
user_id: int, user_id: int,
plan: str, plan: str,
status: str, status: str,
provider_customer_id: str,
provider_subscription_id: str, provider_subscription_id: str,
current_period_end: str = None, current_period_end: str = None,
) -> int: ) -> int:
"""Create or update subscription.""" """Create or update subscription. Finds existing by provider_subscription_id."""
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
customer_col = "paddle_customer_id" existing = await fetch_one(
subscription_col = "paddle_subscription_id" "SELECT id FROM subscriptions WHERE provider_subscription_id = ?",
(provider_subscription_id,),
)
existing = await fetch_one("SELECT id FROM subscriptions WHERE user_id = ?", (user_id,))
if existing: if existing:
await execute( await execute(
f"""UPDATE subscriptions """UPDATE subscriptions
SET plan = ?, status = ?, {customer_col} = ?, {subscription_col} = ?, SET plan = ?, status = ?, current_period_end = ?, updated_at = ?
current_period_end = ?, updated_at = ? WHERE id = ?""",
WHERE user_id = ?""", (plan, status, current_period_end, now, existing["id"]),
(plan, status, provider_customer_id, provider_subscription_id,
current_period_end, now, user_id),
) )
return existing["id"] return existing["id"]
else: else:
return await execute( return await execute(
f"""INSERT INTO subscriptions """INSERT INTO subscriptions
(user_id, plan, status, {customer_col}, {subscription_col}, (user_id, plan, status, provider_subscription_id,
current_period_end, created_at, updated_at) current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, provider_customer_id, provider_subscription_id, (user_id, plan, status, provider_subscription_id,
current_period_end, now, now), current_period_end, now, now),
) )
async def get_subscription_by_provider_id(subscription_id: str) -> dict | None: async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
return await fetch_one( return await fetch_one(
"SELECT * FROM subscriptions WHERE paddle_subscription_id = ?", "SELECT * FROM subscriptions WHERE provider_subscription_id = ?",
(subscription_id,) (subscription_id,)
) )
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None: async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
"""Update subscription status by provider subscription ID.""" """Update subscription status by provider subscription ID."""
extra["updated_at"] = datetime.utcnow().isoformat() 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 = list(extra.values())
values.append(provider_subscription_id) 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 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 # Routes
# ============================================================================= # =============================================================================
@@ -185,12 +176,12 @@ async def checkout(plan: str):
async def manage(): async def manage():
"""Redirect to Paddle customer portal.""" """Redirect to Paddle customer portal."""
sub = await get_subscription(g.user["id"]) 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") await flash("No active subscription found.", "error")
return redirect(url_for("dashboard.settings")) return redirect(url_for("dashboard.settings"))
paddle = _paddle_client() 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 portal_url = paddle_sub.management_urls.update_payment_method
return redirect(portal_url) return redirect(portal_url)
@@ -200,11 +191,11 @@ async def manage():
async def cancel(): async def cancel():
"""Cancel subscription via Paddle API.""" """Cancel subscription via Paddle API."""
sub = await get_subscription(g.user["id"]) sub = await get_subscription(g.user["id"])
if sub and sub.get("paddle_subscription_id"): if sub and sub.get("provider_subscription_id"):
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
paddle = _paddle_client() paddle = _paddle_client()
paddle.subscriptions.cancel( paddle.subscriptions.cancel(
sub["paddle_subscription_id"], sub["provider_subscription_id"],
CancelSubscription(effective_from="next_billing_period"), CancelSubscription(effective_from="next_billing_period"),
) )
return redirect(url_for("dashboard.settings")) return redirect(url_for("dashboard.settings"))
@@ -246,6 +237,11 @@ async def webhook():
user_id = custom_data.get("user_id") user_id = custom_data.get("user_id")
plan = custom_data.get("plan", "") 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 event_type == "subscription.activated":
if plan.startswith("supplier_"): if plan.startswith("supplier_"):
await _handle_supplier_subscription_activated(data, custom_data) await _handle_supplier_subscription_activated(data, custom_data)
@@ -254,7 +250,6 @@ async def webhook():
user_id=int(user_id), user_id=int(user_id),
plan=plan or "starter", plan=plan or "starter",
status="active", status="active",
provider_customer_id=str(data.get("customer_id", "")),
provider_subscription_id=data.get("id", ""), provider_subscription_id=data.get("id", ""),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
) )
@@ -290,7 +285,7 @@ CREDIT_PACK_AMOUNTS = {
"credits_250": 250, "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_PRICE_KEYS = {
"boost_logo": "logo", "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 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: async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None:
"""Handle supplier plan subscription activation.""" """Handle supplier plan subscription activation."""
from ..core import transaction as db_transaction from ..core import transaction as db_transaction
@@ -321,21 +338,23 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
if not supplier_id: if not supplier_id:
return return
monthly_credits = PLAN_MONTHLY_CREDITS.get(plan, 0) base_plan, tier = _derive_tier_from_plan(plan)
tier = "pro" if plan == "supplier_pro" else "growth" monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0)
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
async with db_transaction() as db: async with db_transaction() as db:
# Update supplier record # Update supplier record — Basic tier also gets is_verified = 1
await db.execute( await db.execute(
"""UPDATE suppliers SET tier = ?, claimed_at = ?, claimed_by = ?, """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 = ?""", WHERE id = ?""",
(tier, now, int(user_id) if user_id else None, (tier, now, int(user_id) if user_id else None,
monthly_credits, monthly_credits, now, int(supplier_id)), monthly_credits, monthly_credits, now, int(supplier_id)),
) )
# Initial credit allocation # Initial credit allocation — skip for Basic (0 credits)
if monthly_credits > 0:
await db.execute( await db.execute(
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at) """INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
VALUES (?, ?, ?, 'monthly_allocation', 'Initial credit allocation', ?)""", VALUES (?, ?, ?, 'monthly_allocation', 'Initial credit allocation', ?)""",

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from quart import Blueprint, redirect, render_template, request, url_for 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( bp = Blueprint(
"directory", "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} f"""SELECT * FROM suppliers s WHERE {where}
ORDER BY 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 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 s.name
LIMIT ? OFFSET ?""", LIMIT ? OFFSET ?""",
tuple(order_params), tuple(order_params),
@@ -178,6 +178,7 @@ async def index():
@bp.route("/<slug>") @bp.route("/<slug>")
async def supplier_detail(slug: str): async def supplier_detail(slug: str):
"""Public supplier profile page.""" """Public supplier profile page."""
import json as _json
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,)) supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,))
if not supplier: if not supplier:
from quart import abort from quart import abort
@@ -190,12 +191,105 @@ async def supplier_detail(slug: str):
) )
active_boosts = [b["boost_type"] for b in boosts] 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( return await render_template(
"supplier_detail.html", "supplier_detail.html",
supplier=supplier, supplier=supplier,
active_boosts=active_boosts, active_boosts=active_boosts,
country_labels=COUNTRY_LABELS, country_labels=COUNTRY_LABELS,
category_labels=CATEGORY_LABELS, category_labels=CATEGORY_LABELS,
services_list=services_list,
social_links=social_links,
enquiry_count=enquiry_count,
)
@bp.route("/<slug>/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,
) )

View File

@@ -0,0 +1,22 @@
{% if success %}
<div style="background:#DCFCE7;border:1px solid #BBF7D0;border-radius:12px;padding:1.25rem 1.5rem;text-align:center">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#10003;</div>
<p style="font-weight:700;color:#16A34A;margin-bottom:4px">Enquiry sent!</p>
<p style="font-size:0.8125rem;color:#166534">
{% 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 %}
</p>
</div>
{% else %}
<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:12px;padding:1.25rem 1.5rem">
<p style="font-weight:700;color:#DC2626;margin-bottom:0.5rem">Please fix the following:</p>
<ul style="margin:0;padding-left:1.25rem;font-size:0.8125rem;color:#991B1B">
{% for e in errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -35,7 +35,7 @@
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %} {% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head"> <div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
{% if s.logo_url %}<img src="{{ s.logo_url }}" alt="" class="dir-card__logo">{% endif %} {% if s.logo_file or s.logo_url %}<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">{% endif %}
<h3 class="dir-card__name">{{ s.name }}</h3> <h3 class="dir-card__name">{{ s.name }}</h3>
</div> </div>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span> <span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
@@ -72,6 +72,28 @@
</div> </div>
</a> </a>
{# --- Basic tier card --- #}
{% elif s.tier == 'basic' %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--basic {% if s.sticky_until and s.sticky_until > now %}dir-card--sticky{% endif %}" style="text-decoration:none;color:inherit;display:block">
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured-badge">Featured</div>{% endif %}
<div class="dir-card__head">
<div style="display:flex;align-items:center;gap:8px">
{% if s.logo_file or s.logo_url %}<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">{% endif %}
<h3 class="dir-card__name">{{ s.name }}</h3>
</div>
<span class="dir-card__badge dir-card__badge--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</span>
</div>
<p class="dir-card__loc">{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}</p>
<div class="dir-card__tier-badge dir-card__tier-badge--verified">Verified &#10003;</div>
{% if s.short_description or s.description %}
<p class="dir-card__desc dir-card__desc--3line">{{ s.short_description or s.description }}</p>
{% endif %}
<div class="dir-card__foot">
<span class="dir-card__web">{% if s.website %}{{ s.website }}{% endif %}</span>
<span style="font-size:0.6875rem;color:#64748B;font-weight:600">View Listing &rarr;</span>
</div>
</a>
{# --- Free / unclaimed tier card --- #} {# --- Free / unclaimed tier card --- #}
{% else %} {% else %}
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--free" style="text-decoration:none;color:inherit;display:block"> <a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}" class="dir-card dir-card--free" style="text-decoration:none;color:inherit;display:block">
@@ -94,19 +116,19 @@
{% if total_pages > 1 %} {% if total_pages > 1 %}
<nav class="dir-pagination"> <nav class="dir-pagination">
{% if page > 1 %} {% if page > 1 %}
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ page - 1 }}">&laquo;</a> <a href="?q={{ q }}&country={{ country }}&category={{ category }}&region={{ region }}&page={{ page - 1 }}">&laquo;</a>
{% endif %} {% endif %}
{% for p in range(1, total_pages + 1) %} {% for p in range(1, total_pages + 1) %}
{% if p == page %} {% if p == page %}
<span class="current">{{ p }}</span> <span class="current">{{ p }}</span>
{% elif p <= 3 or p >= total_pages - 1 or (p >= page - 1 and p <= page + 1) %} {% elif p <= 3 or p >= total_pages - 1 or (p >= page - 1 and p <= page + 1) %}
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ p }}">{{ p }}</a> <a href="?q={{ q }}&country={{ country }}&category={{ category }}&region={{ region }}&page={{ p }}">{{ p }}</a>
{% elif p == 4 or p == total_pages - 2 %} {% elif p == 4 or p == total_pages - 2 %}
<span style="border:none">&hellip;</span> <span style="border:none">&hellip;</span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if page < total_pages %} {% if page < total_pages %}
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ page + 1 }}">&raquo;</a> <a href="?q={{ q }}&country={{ country }}&category={{ category }}&region={{ region }}&page={{ page + 1 }}">&raquo;</a>
{% endif %} {% endif %}
</nav> </nav>
{% endif %} {% endif %}

View File

@@ -3,120 +3,517 @@
{% block head %} {% block head %}
<style> <style>
.sp-hero { max-width: 800px; margin: 0 auto; } /* ── Hero ─────────────────────────────────────────────────── */
.sp-card { background: white; border-radius: 16px; padding: 2rem; box-shadow: 0 4px 24px rgba(0,0,0,0.06); } .sp-hero {
.sp-header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; } position: relative; overflow: hidden;
.sp-logo { width: 64px; height: 64px; border-radius: 12px; object-fit: cover; background: #F1F5F9; flex-shrink: 0; } background: #0F172A;
.sp-logo-placeholder { border-radius: 0 0 20px 20px;
width: 64px; height: 64px; border-radius: 12px; background: #F1F5F9; margin-bottom: 2rem; padding: 2.5rem 0 2rem;
display: flex; align-items: center; justify-content: center;
font-size: 24px; font-weight: 800; color: #94A3B8; flex-shrink: 0;
} }
.sp-name { font-size: 1.5rem; font-weight: 800; color: #0F172A; margin: 0; } .sp-hero::before {
.sp-location { font-size: 0.875rem; color: #64748B; margin: 2px 0 0; } content: '';
.sp-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } position: absolute; inset: 0;
.sp-badge { font-size: 0.6875rem; font-weight: 600; padding: 3px 10px; border-radius: 999px; } background-image:
.sp-badge--category { background: #EFF6FF; color: #1D4ED8; } linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
.sp-badge--verified { background: #DCFCE7; color: #16A34A; } linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
.sp-badge--tier { background: #F1F5F9; color: #64748B; } background-size: 48px 48px;
.sp-desc { font-size: 0.9375rem; color: #475569; line-height: 1.7; margin-bottom: 1.5rem; } pointer-events: none;
.sp-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1.5rem; } }
.sp-meta-item { font-size: 0.8125rem; } .sp-hero-inner {
.sp-meta-item dt { color: #94A3B8; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; } max-width: 1120px; margin: 0 auto; padding: 0 2rem;
.sp-meta-item dd { color: #1E293B; font-weight: 600; margin: 2px 0 0; } position: relative; z-index: 1;
.sp-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1.5rem; } }
.sp-hero-back {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.8125rem; color: #94A3B8; text-decoration: none;
margin-bottom: 1.5rem; transition: color 0.15s;
}
.sp-hero-back:hover { color: #CBD5E1; text-decoration: none; }
.sp-hero-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5rem; flex-wrap: wrap; }
.sp-hero-left { display: flex; align-items: flex-start; gap: 1.25rem; }
.sp-hero-logo {
width: 72px; height: 72px; border-radius: 14px; object-fit: cover;
background: #1E293B; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.12);
}
.sp-hero-logo-placeholder {
width: 72px; height: 72px; border-radius: 14px; background: #1E293B;
display: flex; align-items: center; justify-content: center;
font-size: 28px; font-weight: 800; color: #475569; flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.08);
}
.sp-hero-name {
font-size: 1.75rem; font-weight: 800; color: #fff; margin: 0 0 4px;
letter-spacing: -0.02em; line-height: 1.15;
}
.sp-hero-loc { font-size: 0.875rem; color: #94A3B8; margin: 0 0 0.5rem; }
.sp-hero-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.sp-hero-badge {
font-size: 0.6875rem; font-weight: 600; padding: 3px 10px; border-radius: 999px;
}
.sp-hero-badge--category { background: rgba(29,78,216,0.3); color: #93C5FD; }
.sp-hero-badge--verified { background: rgba(22,163,74,0.3); color: #86EFAC; }
.sp-hero-tagline { font-size: 0.9375rem; color: #CBD5E1; margin: 0.5rem 0 0; max-width: 500px; }
.sp-hero-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start; }
.sp-hero-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 18px; border-radius: 9px; font-size: 0.8125rem; font-weight: 600;
text-decoration: none; transition: all 0.15s; border: 1px solid transparent;
font-family: 'DM Sans', sans-serif; cursor: pointer;
}
.sp-hero-btn--primary { background: #1D4ED8; color: #fff; border-color: #1E40AF; }
.sp-hero-btn--primary:hover { background: #1E40AF; text-decoration: none; color: #fff; }
.sp-hero-btn--outline { background: rgba(255,255,255,0.08); color: #CBD5E1; border-color: rgba(255,255,255,0.15); }
.sp-hero-btn--outline:hover { background: rgba(255,255,255,0.14); text-decoration: none; color: #fff; }
/* ── Body layout ──────────────────────────────────────────── */
.sp-body { max-width: 1120px; margin: 0 auto; padding: 0 2rem 3rem; }
.sp-grid { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; align-items: start; }
@media (max-width: 768px) {
.sp-grid { grid-template-columns: 1fr; }
.sp-hero-name { font-size: 1.375rem; }
}
/* ── Cards ────────────────────────────────────────────────── */
.sp-card {
background: #fff; border: 1px solid #E2E8F0; border-radius: 14px;
padding: 1.5rem; margin-bottom: 1rem;
}
.sp-card h2 {
font-size: 0.75rem; font-weight: 700; color: #94A3B8;
text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 1rem;
}
.sp-desc { font-size: 0.9375rem; color: #475569; line-height: 1.75; margin: 0; }
.sp-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 1rem; }
.sp-pill { .sp-pill {
font-size: 0.6875rem; font-weight: 600; padding: 4px 12px; font-size: 0.6875rem; font-weight: 600; padding: 4px 12px;
border-radius: 999px; background: #EFF6FF; color: #1D4ED8;
}
/* Services checklist */
.sp-services { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 1rem; }
.sp-services li { font-size: 0.8125rem; color: #475569; display: flex; align-items: center; gap: 6px; }
.sp-services li::before { content: '✓'; color: #16A34A; font-weight: 700; flex-shrink: 0; }
@media (max-width: 480px) { .sp-services { grid-template-columns: 1fr; } }
/* Service area */
.sp-area-pills { display: flex; flex-wrap: wrap; gap: 5px; }
.sp-area-pill {
font-size: 0.6875rem; font-weight: 600; padding: 3px 10px;
border-radius: 999px; background: #F1F5F9; color: #475569; border-radius: 999px; background: #F1F5F9; color: #475569;
} }
.sp-cta { display: flex; gap: 0.75rem; }
.sp-cta .btn { flex: 1; text-align: center; } /* ── Sidebar ──────────────────────────────────────────────── */
.sp-cta .btn-outline { flex: 1; text-align: center; } .sp-sidebar { position: sticky; top: 80px; }
@media (max-width: 640px) { .sp-contact-avatar {
.sp-meta { grid-template-columns: 1fr; } width: 40px; height: 40px; border-radius: 999px; background: #E2E8F0;
.sp-cta { flex-direction: column; } display: flex; align-items: center; justify-content: center;
font-weight: 700; color: #64748B; font-size: 1rem; flex-shrink: 0;
}
.sp-contact-name { font-size: 0.9375rem; font-weight: 700; color: #1E293B; }
.sp-contact-role { font-size: 0.75rem; color: #94A3B8; }
.sp-contact-links { margin-top: 0.75rem; }
.sp-contact-link {
display: flex; align-items: center; gap: 8px;
font-size: 0.8125rem; color: #475569; padding: 5px 0;
text-decoration: none; border-bottom: 1px solid #F1F5F9;
}
.sp-contact-link:last-child { border-bottom: none; }
.sp-contact-link:hover { color: #1D4ED8; text-decoration: none; }
.sp-contact-link svg { width: 14px; height: 14px; flex-shrink: 0; color: #94A3B8; }
.sp-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.sp-stat { background: #F8FAFC; border-radius: 10px; padding: 0.75rem; text-align: center; }
.sp-stat__value { font-size: 1.375rem; font-weight: 800; color: #1E293B; }
.sp-stat__label { font-size: 0.625rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; }
.sp-social { display: flex; gap: 8px; margin-top: 0.75rem; }
.sp-social-link {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 8px; background: #F1F5F9;
color: #64748B; text-decoration: none; transition: all 0.15s;
}
.sp-social-link:hover { background: #EFF6FF; color: #1D4ED8; }
.sp-social-link svg { width: 15px; height: 15px; }
.sp-trust {
display: flex; align-items: center; gap: 6px; margin-top: 0.75rem;
font-size: 0.6875rem; color: #94A3B8;
}
.sp-trust svg { width: 12px; height: 12px; color: #16A34A; flex-shrink: 0; }
/* ── Enquiry form ─────────────────────────────────────────── */
.sp-enquiry { }
.sp-enquiry-field { margin-bottom: 0.75rem; }
.sp-enquiry-label { display: block; font-size: 0.75rem; font-weight: 600; color: #475569; margin-bottom: 4px; }
.sp-enquiry-input {
width: 100%; border: 1px solid #E2E8F0; border-radius: 8px; padding: 8px 12px;
font-size: 0.8125rem; font-family: inherit; box-sizing: border-box;
transition: border-color 0.15s;
}
.sp-enquiry-input:focus { outline: none; border-color: #1D4ED8; }
.sp-enquiry-textarea { min-height: 80px; resize: vertical; }
.sp-enquiry-submit {
width: 100%; padding: 10px; border-radius: 9px; font-size: 0.875rem; font-weight: 600;
background: #1D4ED8; color: #fff; border: none; cursor: pointer; font-family: inherit;
transition: background 0.15s;
}
.sp-enquiry-submit:hover { background: #1E40AF; }
/* ── CTA strip ────────────────────────────────────────────── */
.sp-cta-strip {
background: #0F172A; border-radius: 16px; padding: 2rem;
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
flex-wrap: wrap; margin-top: 1.5rem;
}
.sp-cta-strip__text h3 { color: #fff; font-size: 1.125rem; margin: 0 0 4px; }
.sp-cta-strip__text p { color: #94A3B8; font-size: 0.875rem; margin: 0; }
.sp-cta-strip__btn {
display: inline-flex; align-items: center; gap: 6px;
background: #1D4ED8; color: #fff; padding: 10px 22px;
border-radius: 9px; font-weight: 600; font-size: 0.875rem;
text-decoration: none; transition: background 0.15s; white-space: nowrap;
font-family: 'DM Sans', sans-serif;
}
.sp-cta-strip__btn:hover { background: #1E40AF; text-decoration: none; color: #fff; }
.sp-cta-strip__btn--green { background: #16A34A; }
.sp-cta-strip__btn--green:hover { background: #15803D; }
/* ── Locked popover (free tier) ───────────────────────────── */
.sp-cta-wrap { position: relative; }
.btn--locked {
display: block; width: 100%; text-align: center;
padding: 10px 20px; border-radius: 10px; font-size: 0.875rem; font-weight: 600;
font-family: 'DM Sans', sans-serif; cursor: not-allowed;
background: #E2E8F0; color: #94A3B8; border: 1px solid #CBD5E1;
user-select: none; transition: opacity 0.15s;
}
.btn--locked:hover { opacity: 0.85; }
.sp-locked-hint { font-size: 0.6875rem; color: #94A3B8; text-align: center; margin-top: 5px; }
.sp-locked-popover {
display: none; position: absolute; top: calc(100% + 8px); left: 0; right: 0;
background: #fff; border: 1px solid #E2E8F0;
border-radius: 12px; padding: 1rem 1.125rem;
box-shadow: 0 8px 24px rgba(15,23,42,0.08); z-index: 50;
}
.sp-locked-popover.open { display: block; animation: popoverIn 0.15s ease; }
@keyframes popoverIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.sp-locked-popover__title { font-size: 0.8125rem; font-weight: 700; color: #0F172A; margin: 0 0 4px; }
.sp-locked-popover__body { font-size: 0.75rem; color: #64748B; line-height: 1.55; margin: 0 0 0.75rem; }
.sp-locked-popover__link {
display: block; width: 100%; text-align: center;
padding: 8px 16px; border-radius: 8px; font-size: 0.8125rem; font-weight: 600;
background: #1D4ED8; color: #fff; text-decoration: none;
}
.sp-locked-popover__link:hover { background: #1E40AF; text-decoration: none; }
.sp-locked-popover__dismiss {
display: block; text-align: center; margin-top: 8px;
font-size: 0.6875rem; color: #94A3B8; cursor: pointer;
background: none; border: none; font-family: inherit; padding: 0;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 60vh;">
<div class="container-page py-12">
<div class="sp-hero">
<a href="{{ url_for('directory.index') }}" style="font-size:0.8125rem;color:#64748B;text-decoration:none;display:block;margin-bottom:1rem">&larr; Back to Directory</a>
<div class="sp-card"> {# ── Hero ─────────────────────────────────────────────────────── #}
<div class="sp-header"> <div class="sp-hero">
{% if supplier.logo_url %} <div class="sp-hero-inner">
<img src="{{ supplier.logo_url }}" alt="" class="sp-logo"> <a href="{{ url_for('directory.index') }}" class="sp-hero-back">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
Back to Directory
</a>
<div class="sp-hero-row">
<div class="sp-hero-left">
{% if supplier.logo_file or supplier.logo_url %}
<img src="{{ supplier.logo_file or supplier.logo_url }}" alt="" class="sp-hero-logo">
{% else %} {% else %}
<div class="sp-logo-placeholder">{{ supplier.name[0] }}</div> <div class="sp-hero-logo-placeholder">{{ supplier.name[0] }}</div>
{% endif %} {% endif %}
<div> <div>
<h1 class="sp-name">{{ supplier.name }}</h1> <h1 class="sp-hero-name">{{ supplier.name }}</h1>
<p class="sp-location">{{ country_labels.get(supplier.country_code, supplier.country_code) }}{% if supplier.city %}, {{ supplier.city }}{% endif %}</p> <p class="sp-hero-loc">{{ country_labels.get(supplier.country_code, supplier.country_code) }}{% if supplier.city %}, {{ supplier.city }}{% endif %}</p>
<div class="sp-badges"> <div class="sp-hero-badges">
<span class="sp-badge sp-badge--category">{{ category_labels.get(supplier.category, supplier.category) }}</span> <span class="sp-hero-badge sp-hero-badge--category">{{ category_labels.get(supplier.category, supplier.category) }}</span>
{% if supplier.is_verified %} {% if supplier.is_verified %}
<span class="sp-badge sp-badge--verified">Verified &#10003;</span> <span class="sp-hero-badge sp-hero-badge--verified">Verified &#10003;</span>
{% endif %}
{% if supplier.tier != 'free' %}
<span class="sp-badge sp-badge--tier">{{ supplier.tier | title }}</span>
{% endif %} {% endif %}
</div> </div>
{% if supplier.tagline %}
<p class="sp-hero-tagline">{{ supplier.tagline }}</p>
{% endif %}
</div> </div>
</div> </div>
{% set desc = supplier.long_description or supplier.description %} <div class="sp-hero-actions">
{% if supplier.tier in ('growth', 'pro') %}
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}" class="sp-hero-btn sp-hero-btn--primary">
Request Quote &rarr;
</a>
{% endif %}
{% if supplier.website %}
<a href="{{ url_for('directory.supplier_website', slug=supplier.slug) }}" class="sp-hero-btn sp-hero-btn--outline" target="_blank" rel="noopener">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
Visit Website
</a>
{% endif %}
</div>
</div>
</div>
</div>
{# ── Body ──────────────────────────────────────────────────────── #}
<div class="sp-body">
{% if supplier.tier in ('basic', 'growth', 'pro') %}
{# Full two-column layout for paid tiers #}
<div class="sp-grid">
{# Main column #}
<div>
{# About #}
{% set desc = supplier.long_description or supplier.short_description or supplier.description %}
{% if desc or supplier.service_categories %}
<div class="sp-card">
<h2>About</h2>
{% if desc %} {% if desc %}
<p class="sp-desc">{{ desc }}</p> <p class="sp-desc">{{ desc }}</p>
{% endif %} {% endif %}
{% if supplier.service_categories %} {% if supplier.service_categories %}
<div class="sp-pills"> <div class="sp-pills">
{% for cat in (supplier.service_categories or '').split(',') %} {% for cat in (supplier.service_categories or '').split(',') %}
{% if cat.strip() %} {% if cat.strip() %}
<span class="sp-pill">{{ cat.strip() }}</span> <span class="sp-pill">{{ cat.strip() | replace('_', ' ') | title }}</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{# Services offered #}
{% if services_list %}
<div class="sp-card">
<h2>Services Offered</h2>
<ul class="sp-services">
{% for s in services_list %}
<li>{{ s }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Service area #}
{% if supplier.service_area %}
<div class="sp-card">
<h2>Service Area</h2>
<div class="sp-area-pills">
{% for area in (supplier.service_area or '').split(',') %}
{% if area.strip() %}
<span class="sp-area-pill">{{ area.strip() }}</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{# Enquiry form for Basic+ #}
<div class="sp-card sp-enquiry">
<h2>Send an Enquiry</h2>
<form hx-post="{{ url_for('directory.supplier_enquiry', slug=supplier.slug) }}"
hx-target="#enquiry-result"
hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="enquiry-result"></div>
<div class="sp-enquiry-field">
<label class="sp-enquiry-label">Your Name <span style="color:#EF4444">*</span></label>
<input type="text" name="contact_name" class="sp-enquiry-input" required placeholder="Jane Smith">
</div>
<div class="sp-enquiry-field">
<label class="sp-enquiry-label">Email <span style="color:#EF4444">*</span></label>
<input type="email" name="contact_email" class="sp-enquiry-input" required placeholder="jane@company.com">
</div>
<div class="sp-enquiry-field">
<label class="sp-enquiry-label">Message <span style="color:#EF4444">*</span></label>
<textarea name="message" class="sp-enquiry-input sp-enquiry-textarea" required
placeholder="Tell {{ supplier.name }} about your project…"></textarea>
</div>
<button type="submit" class="sp-enquiry-submit">Send Enquiry</button>
</form>
</div>
</div>
{# Sidebar #}
<aside class="sp-sidebar">
{# Contact card #}
<div class="sp-card">
<h2>Contact</h2>
{% if supplier.contact_name %}
<div style="display:flex;align-items:center;gap:10px;margin-bottom:0.75rem">
<div class="sp-contact-avatar">{{ supplier.contact_name[0] | upper }}</div>
<div>
<div class="sp-contact-name">{{ supplier.contact_name }}</div>
{% if supplier.contact_role %}
<div class="sp-contact-role">{{ supplier.contact_role }}</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="sp-contact-links">
{% if supplier.contact_email %}
<a href="mailto:{{ supplier.contact_email }}" class="sp-contact-link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
{{ supplier.contact_email }}
</a>
{% endif %}
{% if supplier.contact_phone %}
<a href="tel:{{ supplier.contact_phone }}" class="sp-contact-link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 6.75Z"/></svg>
{{ supplier.contact_phone }}
</a>
{% endif %}
{% if supplier.website %}
<a href="{{ url_for('directory.supplier_website', slug=supplier.slug) }}" target="_blank" rel="noopener" class="sp-contact-link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/></svg>
{{ supplier.website }}
</a>
{% endif %}
</div>
{# Social links #}
{% if social_links.linkedin or social_links.instagram or social_links.youtube %}
<div class="sp-social">
{% if social_links.linkedin %}
<a href="{{ social_links.linkedin }}" target="_blank" rel="noopener" class="sp-social-link" title="LinkedIn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
{% endif %}
{% if social_links.instagram %}
<a href="{{ social_links.instagram }}" target="_blank" rel="noopener" class="sp-social-link" title="Instagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
</a>
{% endif %}
{% if social_links.youtube %}
<a href="{{ social_links.youtube }}" target="_blank" rel="noopener" class="sp-social-link" title="YouTube">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
</a>
{% endif %}
</div>
{% endif %}
{# Stats #}
{% if supplier.years_in_business or supplier.project_count %}
<div class="sp-stats-grid" style="margin-top:0.75rem">
{% if supplier.years_in_business %}
<div class="sp-stat">
<div class="sp-stat__value">{{ supplier.years_in_business }}</div>
<div class="sp-stat__label">Years Active</div>
</div>
{% endif %}
{% if supplier.project_count %}
<div class="sp-stat">
<div class="sp-stat__value">{{ supplier.project_count }}</div>
<div class="sp-stat__label">Projects</div>
</div>
{% endif %}
</div>
{% endif %}
{# Verified trust note #}
{% if supplier.is_verified %}
<div class="sp-trust">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/></svg>
Verified listing — identity and ownership confirmed
</div>
{% endif %}
</div>
</aside>
</div>
{# CTA strip — tier-dependent #}
{% if supplier.tier == 'basic' %}
<div class="sp-cta-strip">
<div class="sp-cta-strip__text">
<h3>Looking for direct quote matching?</h3>
<p>Upgrade to Growth to appear in our supplier matching and receive qualified project leads.</p>
</div>
<a href="{{ url_for('suppliers.signup') }}" class="sp-cta-strip__btn">
Upgrade to Growth &rarr;
</a>
</div>
{% elif supplier.tier == 'growth' %}
{# Subtle upgrade nudge — optional #}
{% endif %}
{% else %}
{# Free / unclaimed tier — minimal layout #}
<div style="max-width:800px;margin:0 auto">
<div class="sp-card">
{% set desc = supplier.long_description or supplier.short_description or supplier.description %}
{% if desc %}
<p class="sp-desc" style="margin-bottom:1.5rem">{{ desc }}</p>
{% endif %}
{% if supplier.service_categories %}
<div class="sp-pills" style="margin-bottom:1.5rem">
{% for cat in (supplier.service_categories or '').split(',') %}
{% if cat.strip() %}
<span class="sp-pill">{{ cat.strip() | replace('_', ' ') | title }}</span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<div class="sp-meta"> {# Locked quote CTA #}
{% if supplier.service_area %} <div class="sp-cta-wrap" id="quote-wrap">
<div class="sp-meta-item"> <button type="button" class="btn--locked" id="locked-quote-btn"
<dt>Service Area</dt> aria-describedby="locked-popover" aria-disabled="true">
<dd>{{ supplier.service_area }}</dd> Request Quote
</button>
<p class="sp-locked-hint">Listing not yet verified</p>
<div class="sp-locked-popover" id="locked-popover" role="tooltip">
<p class="sp-locked-popover__title">Direct quotes unavailable</p>
<p class="sp-locked-popover__body">
This supplier hasn't verified their listing yet. Use our quote wizard and
we'll match you with verified suppliers in your region.
</p>
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}"
class="sp-locked-popover__link">Use Quote Wizard &rarr;</a>
<button type="button" class="sp-locked-popover__dismiss" id="dismiss-popover">Dismiss</button>
</div> </div>
{% endif %}
{% if supplier.years_in_business %}
<div class="sp-meta-item">
<dt>Years in Business</dt>
<dd>{{ supplier.years_in_business }}</dd>
</div> </div>
{% endif %}
{% if supplier.project_count %}
<div class="sp-meta-item">
<dt>Projects Completed</dt>
<dd>{{ supplier.project_count }}</dd>
</div> </div>
{% endif %}
{% if supplier.website %} {# Claim CTA strip #}
<div class="sp-meta-item"> {% if not supplier.claimed_by %}
<dt>Website</dt> <div class="sp-cta-strip">
<dd><a href="https://{{ supplier.website }}" target="_blank" rel="noopener" style="color:#1D4ED8">{{ supplier.website }}</a></dd> <div class="sp-cta-strip__text">
<h3>Is this your company?</h3>
<p>Claim and verify this listing to start receiving project enquiries from padel developers.</p>
</div>
<a href="{{ url_for('suppliers.claim', slug=supplier.slug) }}" class="sp-cta-strip__btn sp-cta-strip__btn--green">
Claim This Listing &rarr;
</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="sp-cta"> <script>
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}" class="btn">Request Quote</a> (function() {
{% if not supplier.claimed_by %} var btn = document.getElementById('locked-quote-btn');
<a href="{{ url_for('suppliers.claim', slug=supplier.slug) }}" class="btn-outline">Claim This Listing</a> var pop = document.getElementById('locked-popover');
var dis = document.getElementById('dismiss-popover');
if (!btn || !pop) return;
btn.addEventListener('click', function(e) { e.stopPropagation(); pop.classList.toggle('open'); });
if (dis) dis.addEventListener('click', function() { pop.classList.remove('open'); });
document.addEventListener('click', function(e) {
if (!document.getElementById('quote-wrap').contains(e.target)) pop.classList.remove('open');
});
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') pop.classList.remove('open'); });
})();
</script>
{% endif %} {% endif %}
</div> </div>
</div>
</div>
</div>
</main>
{% endblock %} {% endblock %}

View File

@@ -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): def up(conn):
@@ -216,7 +216,14 @@ def up(conn):
-- Phase 2: editable profile fields -- Phase 2: editable profile fields
logo_file TEXT, 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); 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_lead ON lead_forwards(lead_id);
CREATE INDEX IF NOT EXISTS idx_lead_forwards_supplier ON lead_forwards(supplier_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 -- Supplier boost subscriptions/purchases
CREATE TABLE IF NOT EXISTS supplier_boosts ( CREATE TABLE IF NOT EXISTS supplier_boosts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -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()

View File

@@ -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()

View File

@@ -38,23 +38,57 @@ if not PADDLE_API_KEY:
# Maps our internal key -> product name in Paddle. # Maps our internal key -> product name in Paddle.
# The name is used to match existing products on sync. # The name is used to match existing products on sync.
PRODUCTS = [ PRODUCTS = [
# Subscriptions # Subscriptions — Basic tier (new)
{ {
"key": "supplier_growth", "key": "supplier_basic_monthly",
"name": "Supplier Growth", "name": "Supplier Basic (Monthly)",
"price": 14900, "price": 3900,
"currency": CurrencyCode.EUR, "currency": CurrencyCode.EUR,
"interval": "month", "interval": "month",
"billing_type": "subscription", "billing_type": "subscription",
}, },
{ {
"key": "supplier_pro", "key": "supplier_basic_yearly",
"name": "Supplier Pro", "name": "Supplier Basic (Yearly)",
"price": 39900, "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, "currency": CurrencyCode.EUR,
"interval": "month", "interval": "month",
"billing_type": "subscription", "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) # Boost add-ons (subscriptions)
{ {
"key": "boost_logo", "key": "boost_logo",
@@ -234,6 +268,9 @@ def create(paddle, conn):
if spec["billing_type"] == "subscription": if spec["billing_type"] == "subscription":
from paddle_billing.Entities.Shared import Duration, Interval from paddle_billing.Entities.Shared import Duration, Interval
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_kwargs["billing_cycle"] = Duration(interval=Interval.Month, frequency=1)
price = paddle.prices.create(CreatePrice(**price_kwargs)) price = paddle.prices.create(CreatePrice(**price_kwargs))

View File

@@ -25,34 +25,55 @@ bp = Blueprint(
# ============================================================================= # =============================================================================
PLAN_FEATURES = { 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": { "supplier_growth": {
"name": "Growth", "name": "Growth",
"price": 149, "monthly_price": 199,
"yearly_price": 1799,
"yearly_monthly_equivalent": 150,
"monthly_credits": 30, "monthly_credits": 30,
"paddle_key_monthly": "supplier_growth",
"paddle_key_yearly": "supplier_growth_yearly",
"features": [ "features": [
"Company name & category badge", "Everything in Basic",
"City & country shown",
"Description (3 lines)",
"\"Growth\" badge",
"Priority over free listings",
"30 lead credits/month", "30 lead credits/month",
"Lead feed access",
"Priority over Basic listings",
], ],
}, },
"supplier_pro": { "supplier_pro": {
"name": "Pro", "name": "Pro",
"price": 399, "monthly_price": 499,
"yearly_price": 4499,
"yearly_monthly_equivalent": 375,
"monthly_credits": 100, "monthly_credits": 100,
"paddle_key_monthly": "supplier_pro",
"paddle_key_yearly": "supplier_pro_yearly",
"includes": ["logo", "highlight", "verified"],
"features": [ "features": [
"Everything in Growth", "Everything in Growth",
"Company logo displayed",
"Full description",
"Website link shown",
"Verified badge",
"Priority placement",
"Highlighted card border",
"100 lead credits/month", "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): 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 from functools import wraps
@wraps(f) @wraps(f)
@@ -116,8 +159,8 @@ def _supplier_required(f):
(g.user["id"],), (g.user["id"],),
) )
if not supplier: if not supplier:
await flash("You need an active supplier plan to access this page.", "warning") await flash("Lead access requires a Growth or Pro plan.", "warning")
return redirect(url_for("suppliers.signup")) return redirect(url_for("suppliers.dashboard"))
g.supplier = supplier g.supplier = supplier
return await f(*args, **kwargs) 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.""" """Compute order summary from accumulated wizard state."""
plan = data.get("plan", "supplier_growth") plan = data.get("plan", "supplier_growth")
plan_info = PLAN_FEATURES.get(plan, PLAN_FEATURES["supplier_growth"]) plan_info = PLAN_FEATURES.get(plan, PLAN_FEATURES["supplier_growth"])
monthly = plan_info["price"] period = data.get("billing_period", "yearly")
one_time = 0
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", []) selected_boosts = data.get("boosts", [])
boost_monthly = 0 boost_monthly = 0
for b in BOOST_OPTIONS: for b in BOOST_OPTIONS:
if b["type"] in selected_boosts and b["type"] not in included_boosts: if b["type"] in selected_boosts and b["type"] not in included_boosts:
boost_monthly += b["price"] boost_monthly += b["price"]
monthly += boost_monthly
credit_pack = data.get("credit_pack", "") credit_pack = data.get("credit_pack", "")
for cp in CREDIT_PACK_OPTIONS: for cp in CREDIT_PACK_OPTIONS:
if cp["key"] == credit_pack: if cp["key"] == credit_pack:
@@ -216,9 +266,12 @@ def _compute_order(data: dict, included_boosts: list) -> dict:
return { return {
"plan_name": plan_info["name"], "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, "boost_monthly": boost_monthly,
"monthly_total": monthly, "monthly_total": plan_price_display + boost_monthly,
"one_time_total": one_time, "one_time_total": one_time,
"credit_pack": credit_pack, "credit_pack": credit_pack,
} }
@@ -238,7 +291,14 @@ async def signup_checkout():
accumulated[k] = v accumulated[k] = v
plan = accumulated.get("plan", "supplier_growth") 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: if not plan_price_id:
return jsonify({"error": "Invalid plan selected. Run setup_paddle first."}), 400 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}] items = [{"priceId": plan_price_id, "quantity": 1}]
# Add boost add-ons # Add boost add-ons
plan_info = PLAN_FEATURES.get(plan, {})
included_boosts = plan_info.get("includes", []) included_boosts = plan_info.get("includes", [])
selected_boosts = accumulated.get("boosts", []) selected_boosts = accumulated.get("boosts", [])
if isinstance(selected_boosts, str): if isinstance(selected_boosts, str):
@@ -345,7 +404,7 @@ async def signup_success():
# Supplier Lead Feed # 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.""" """Shared query for lead feed — used by standalone and dashboard."""
wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"] wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"]
params: list = [] params: list = []
@@ -359,6 +418,10 @@ async def _get_lead_feed_data(supplier, country="", heat="", timeline="", limit=
if timeline: if timeline:
wheres.append("lr.timeline = ?") wheres.append("lr.timeline = ?")
params.append(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) where = " AND ".join(wheres)
params.append(limit) params.append(limit)
@@ -381,7 +444,7 @@ async def _get_lead_feed_data(supplier, country="", heat="", timeline="", limit=
@bp.route("/leads") @bp.route("/leads")
@_supplier_required @_lead_tier_required
async def lead_feed(): async def lead_feed():
"""Lead feed for paying suppliers (standalone page).""" """Lead feed for paying suppliers (standalone page)."""
supplier = g.supplier supplier = g.supplier
@@ -401,7 +464,7 @@ async def lead_feed():
@bp.route("/leads/<int:lead_id>/unlock", methods=["POST"]) @bp.route("/leads/<int:lead_id>/unlock", methods=["POST"])
@_supplier_required @_lead_tier_required
@csrf_protect @csrf_protect
async def unlock_lead(lead_id: int): async def unlock_lead(lead_id: int):
"""Spend credits to unlock a lead. Returns full-details card via HTMX.""" """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,)) 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"],)) 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( return await render_template(
"suppliers/partials/lead_card_unlocked.html", "suppliers/partials/lead_card_unlocked.html",
lead=full_lead, lead=full_lead,
supplier=updated_supplier, supplier=updated_supplier,
credit_cost=result["credit_cost"], credit_cost=result["credit_cost"],
scenario_id=scenario_id,
) )
@@ -520,6 +594,15 @@ async def dashboard_overview():
(supplier["id"],), (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( return await render_template(
"suppliers/partials/dashboard_overview.html", "suppliers/partials/dashboard_overview.html",
supplier=supplier, supplier=supplier,
@@ -527,23 +610,38 @@ async def dashboard_overview():
new_leads_count=new_leads_count, new_leads_count=new_leads_count,
recent_activity=recent_activity, recent_activity=recent_activity,
active_boosts=active_boosts, active_boosts=active_boosts,
enquiry_count=enquiry_count,
) )
@bp.route("/dashboard/leads") @bp.route("/dashboard/leads")
@_supplier_required @_lead_tier_required
async def dashboard_leads(): async def dashboard_leads():
"""HTMX partial — lead feed tab in dashboard context.""" """HTMX partial — lead feed tab in dashboard context."""
supplier = g.supplier supplier = g.supplier
country = request.args.get("country", "") country = request.args.get("country", "")
heat = request.args.get("heat", "") heat = request.args.get("heat", "")
timeline = request.args.get("timeline", "") 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 # Parse supplier's service area for region matching
service_area = [c.strip() for c in (supplier.get("service_area") or "").split(",") if c.strip()] 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( return await render_template(
"suppliers/partials/dashboard_leads.html", "suppliers/partials/dashboard_leads.html",
leads=leads, leads=leads,
@@ -552,7 +650,9 @@ async def dashboard_leads():
current_country=country, current_country=country,
current_heat=heat, current_heat=heat,
current_timeline=timeline, current_timeline=timeline,
current_q=q,
service_area=service_area, 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"]) @bp.route("/dashboard/listing", methods=["POST"])
@_supplier_required @_supplier_required
@csrf_protect @csrf_protect
@@ -606,13 +732,19 @@ async def dashboard_listing_save():
areas = form.getlist("service_area") areas = form.getlist("service_area")
area_str = ",".join(areas) area_str = ",".join(areas)
# Multi-value services offered
services = form.getlist("services_offered")
services_str = ",".join(services)
await execute( await execute(
"""UPDATE suppliers SET """UPDATE suppliers SET
name = ?, tagline = ?, short_description = ?, long_description = ?, name = ?, tagline = ?, short_description = ?, long_description = ?,
website = ?, contact_name = ?, contact_email = ?, contact_phone = ?, website = ?, contact_name = ?, contact_email = ?, contact_phone = ?,
service_categories = ?, service_area = ?, service_categories = ?, service_area = ?,
years_in_business = ?, project_count = ?, years_in_business = ?, project_count = ?,
logo_file = ? logo_file = ?,
services_offered = ?, contact_role = ?,
linkedin_url = ?, instagram_url = ?, youtube_url = ?
WHERE id = ?""", WHERE id = ?""",
( (
form.get("name", supplier["name"]), 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("years_in_business", 0) or 0),
int(form.get("project_count", 0) or 0), int(form.get("project_count", 0) or 0),
logo_path, logo_path,
services_str,
form.get("contact_role", ""),
form.get("linkedin_url", ""),
form.get("instagram_url", ""),
form.get("youtube_url", ""),
supplier["id"], supplier["id"],
), ),
) )

View File

@@ -23,6 +23,7 @@
padding: 10px 1.25rem; font-size: 0.8125rem; color: #64748B; padding: 10px 1.25rem; font-size: 0.8125rem; color: #64748B;
text-decoration: none; transition: all 0.1s; 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:hover { background: #EFF6FF; color: #1D4ED8; }
.dash-nav a.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #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-target="#dashboard-content"
hx-push-url="{{ url_for('suppliers.dashboard', tab='overview') }}" hx-push-url="{{ url_for('suppliers.dashboard', tab='overview') }}"
class="{% if active_tab == 'overview' %}active{% endif %}"> class="{% if active_tab == 'overview' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/></svg>
Overview Overview
</a> </a>
{% if supplier.tier in ('growth', 'pro') %}
<a href="{{ url_for('suppliers.dashboard', tab='leads') }}" <a href="{{ url_for('suppliers.dashboard', tab='leads') }}"
hx-get="{{ url_for('suppliers.dashboard_leads') }}" hx-get="{{ url_for('suppliers.dashboard_leads') }}"
hx-target="#dashboard-content" hx-target="#dashboard-content"
hx-push-url="{{ url_for('suppliers.dashboard', tab='leads') }}" hx-push-url="{{ url_for('suppliers.dashboard', tab='leads') }}"
class="{% if active_tab == 'leads' %}active{% endif %}"> class="{% if active_tab == 'leads' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.239a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859M12 3v8.25m0 0-3-3m3 3 3-3"/></svg>
Lead Feed Lead Feed
</a> </a>
{% endif %}
<a href="{{ url_for('suppliers.dashboard', tab='listing') }}" <a href="{{ url_for('suppliers.dashboard', tab='listing') }}"
hx-get="{{ url_for('suppliers.dashboard_listing') }}" hx-get="{{ url_for('suppliers.dashboard_listing') }}"
hx-target="#dashboard-content" hx-target="#dashboard-content"
hx-push-url="{{ url_for('suppliers.dashboard', tab='listing') }}" hx-push-url="{{ url_for('suppliers.dashboard', tab='listing') }}"
class="{% if active_tab == 'listing' %}active{% endif %}"> class="{% if active_tab == 'listing' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5M3.75 3v18m4.5-18v18M12 3v18m4.5-18v18m4.5-18v18M6 6.75h.008v.008H6V6.75Zm0 3h.008v.008H6V9.75Zm0 3h.008v.008H6v-.008Zm4.5-6h.008v.008H10.5V6.75Zm0 3h.008v.008H10.5V9.75Zm0 3h.008v.008H10.5v-.008Z"/></svg>
My Listing My Listing
</a> </a>
{% if supplier.tier in ('growth', 'pro') %}
<a href="{{ url_for('suppliers.dashboard', tab='boosts') }}" <a href="{{ url_for('suppliers.dashboard', tab='boosts') }}"
hx-get="{{ url_for('suppliers.dashboard_boosts') }}" hx-get="{{ url_for('suppliers.dashboard_boosts') }}"
hx-target="#dashboard-content" hx-target="#dashboard-content"
hx-push-url="{{ url_for('suppliers.dashboard', tab='boosts') }}" hx-push-url="{{ url_for('suppliers.dashboard', tab='boosts') }}"
class="{% if active_tab == 'boosts' %}active{% endif %}"> class="{% if active_tab == 'boosts' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z"/></svg>
Boost & Upsells Boost & Upsells
</a> </a>
{% endif %}
</nav> </nav>
{% if supplier.tier == 'basic' %}
<div style="margin:0.75rem 1.25rem;padding:10px 12px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;font-size:0.75rem;color:#1D4ED8">
<strong>Basic plan</strong> — directory listing + enquiry form.
<a href="{{ url_for('suppliers.signup') }}" style="display:block;font-weight:600;margin-top:4px;color:#1D4ED8">
Upgrade to Growth for lead access &rarr;
</a>
</div>
{% endif %}
<div class="dash-sidebar__footer"> <div class="dash-sidebar__footer">
<div class="dash-sidebar__credits"> <div class="dash-sidebar__credits" id="sidebar-credits">
{{ supplier.credit_balance }} credits {{ supplier.credit_balance }} credits
</div> </div>
</div> </div>
@@ -107,7 +125,7 @@
<!-- Main content area --> <!-- Main content area -->
<main class="dash-main" id="dashboard-content" <main class="dash-main" id="dashboard-content"
hx-get="{% if active_tab == 'leads' %}{{ url_for('suppliers.dashboard_leads') }}{% elif active_tab == 'listing' %}{{ url_for('suppliers.dashboard_listing') }}{% elif active_tab == 'boosts' %}{{ url_for('suppliers.dashboard_boosts') }}{% else %}{{ url_for('suppliers.dashboard_overview') }}{% endif %}" hx-get="{% if active_tab == 'leads' and supplier.tier in ('growth', 'pro') %}{{ url_for('suppliers.dashboard_leads') }}{% elif active_tab == 'listing' %}{{ url_for('suppliers.dashboard_listing') }}{% elif active_tab == 'boosts' and supplier.tier in ('growth', 'pro') %}{{ url_for('suppliers.dashboard_boosts') }}{% else %}{{ url_for('suppliers.dashboard_overview') }}{% endif %}"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div style="text-align:center;padding:3rem;color:#94A3B8">Loading...</div> <div style="text-align:center;padding:3rem;color:#94A3B8">Loading...</div>

View File

@@ -49,57 +49,37 @@
<!-- Listing Preview --> <!-- Listing Preview -->
<div class="lst-preview"> <div class="lst-preview">
<h3>Your Directory Card Preview</h3> <h3>Your Directory Card Preview</h3>
<div class="lst-card"> <div id="lst-preview">
<div class="lst-card__head"> {% include "suppliers/partials/dashboard_listing_preview.html" %}
<div style="display:flex;align-items:center;gap:8px">
{% if supplier.logo_file %}
<img src="{{ supplier.logo_file }}" alt="" class="lst-card__logo">
{% elif supplier.logo_url %}
<img src="{{ supplier.logo_url }}" alt="" class="lst-card__logo">
{% endif %}
<span class="lst-card__name">{{ supplier.name }}</span>
</div>
<span class="lst-badge lst-badge--tier">{{ supplier.category }}</span>
</div>
<div class="lst-card__loc">{{ supplier.city or '' }}{% if supplier.city %}, {% endif %}{{ supplier.country_code }}</div>
<div class="lst-badges">
<span class="lst-badge lst-badge--tier">{{ supplier.tier | upper }}</span>
{% if 'verified' in active_boosts or supplier.is_verified %}
<span class="lst-badge lst-badge--verified">Verified &#10003;</span>
{% endif %}
</div>
{% if supplier.tagline %}
<p style="font-size:0.8125rem;color:#1E293B;font-weight:500;margin-bottom:4px">{{ supplier.tagline }}</p>
{% endif %}
{% if supplier.short_description or supplier.description %}
<p class="lst-card__desc">{{ supplier.short_description or supplier.description }}</p>
{% endif %}
{% if supplier.website %}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#1D4ED8">{{ supplier.website }}</div>
{% endif %}
</div> </div>
</div> </div>
<!-- Edit Form --> <!-- Edit Form -->
<div class="lst-form"> <div class="lst-form">
<h3>Edit Company Info</h3> <h3>Edit Company Info</h3>
<form hx-post="{{ url_for('suppliers.dashboard_listing_save') }}" hx-target="#dashboard-content" hx-swap="innerHTML" hx-encoding="multipart/form-data"> <form id="lst-edit-form" hx-post="{{ url_for('suppliers.dashboard_listing_save') }}" hx-target="#dashboard-content" hx-swap="innerHTML" hx-encoding="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="lst-row"> <div class="lst-row">
<div> <div>
<label class="lst-label">Company Name</label> <label class="lst-label">Company Name</label>
<input type="text" name="name" value="{{ supplier.name }}" class="lst-input"> <input type="text" name="name" value="{{ supplier.name }}" class="lst-input"
hx-get="{{ url_for('suppliers.dashboard_listing_preview') }}" hx-trigger="input changed delay:500ms"
hx-target="#lst-preview" hx-include="#lst-edit-form">
</div> </div>
<div> <div>
<label class="lst-label">Tagline</label> <label class="lst-label">Tagline</label>
<input type="text" name="tagline" value="{{ supplier.tagline or '' }}" class="lst-input" placeholder="One-liner for search results"> <input type="text" name="tagline" value="{{ supplier.tagline or '' }}" class="lst-input" placeholder="One-liner for search results"
hx-get="{{ url_for('suppliers.dashboard_listing_preview') }}" hx-trigger="input changed delay:500ms"
hx-target="#lst-preview" hx-include="#lst-edit-form">
</div> </div>
</div> </div>
<div class="lst-full"> <div class="lst-full">
<label class="lst-label">Short Description</label> <label class="lst-label">Short Description</label>
<textarea name="short_description" class="lst-input lst-textarea">{{ supplier.short_description or '' }}</textarea> <textarea name="short_description" class="lst-input lst-textarea"
hx-get="{{ url_for('suppliers.dashboard_listing_preview') }}" hx-trigger="input changed delay:500ms"
hx-target="#lst-preview" hx-include="#lst-edit-form">{{ supplier.short_description or '' }}</textarea>
</div> </div>
<div class="lst-full"> <div class="lst-full">
@@ -110,7 +90,9 @@
<div class="lst-row"> <div class="lst-row">
<div> <div>
<label class="lst-label">Website</label> <label class="lst-label">Website</label>
<input type="url" name="website" value="{{ supplier.website or '' }}" class="lst-input" placeholder="https://..."> <input type="url" name="website" value="{{ supplier.website or '' }}" class="lst-input" placeholder="https://..."
hx-get="{{ url_for('suppliers.dashboard_listing_preview') }}" hx-trigger="input changed delay:500ms"
hx-target="#lst-preview" hx-include="#lst-edit-form">
</div> </div>
<div> <div>
<label class="lst-label">Logo</label> <label class="lst-label">Logo</label>
@@ -171,6 +153,53 @@
</div> </div>
</div> </div>
<div class="lst-row">
<div>
<label class="lst-label">Contact Role / Title</label>
<input type="text" name="contact_role" value="{{ supplier.contact_role or '' }}" class="lst-input"
placeholder="e.g. International Sales, Managing Director">
</div>
<div></div>
</div>
<div class="lst-full" style="margin-top:0.25rem">
<label class="lst-label">Services Offered</label>
<div class="lst-pills">
{% set current_services = (supplier.services_offered or '').split(',') %}
{% set service_options = [
'Court design & layout planning',
'Manufacturing & supply',
'Installation & commissioning',
'Lighting systems',
'Flooring & surfaces',
'After-sales & warranty',
'Permitting documentation',
'Project management',
'Feasibility studies',
'Roofing / canopy systems',
] %}
{% for svc in service_options %}
<label class="lst-pill-label">
<input type="checkbox" name="services_offered" value="{{ svc }}"
{% if svc in current_services %}checked{% endif %}>
{{ svc }}
</label>
{% endfor %}
</div>
</div>
<div class="lst-full" style="margin-top:0.75rem">
<label class="lst-label">Social Links</label>
<div style="display:flex;flex-direction:column;gap:0.5rem">
<input type="url" name="linkedin_url" value="{{ supplier.linkedin_url or '' }}" class="lst-input"
placeholder="https://linkedin.com/company/...">
<input type="url" name="instagram_url" value="{{ supplier.instagram_url or '' }}" class="lst-input"
placeholder="https://instagram.com/...">
<input type="url" name="youtube_url" value="{{ supplier.youtube_url or '' }}" class="lst-input"
placeholder="https://youtube.com/...">
</div>
</div>
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<button type="submit" class="btn">Save Changes</button> <button type="submit" class="btn">Save Changes</button>
</div> </div>

View File

@@ -0,0 +1,29 @@
<div class="lst-card">
<div class="lst-card__head">
<div style="display:flex;align-items:center;gap:8px">
{% if supplier.logo_file %}
<img src="{{ supplier.logo_file }}" alt="" class="lst-card__logo">
{% elif supplier.logo_url %}
<img src="{{ supplier.logo_url }}" alt="" class="lst-card__logo">
{% endif %}
<span class="lst-card__name">{{ supplier.name }}</span>
</div>
<span class="lst-badge lst-badge--tier">{{ supplier.category }}</span>
</div>
<div class="lst-card__loc">{{ supplier.city or '' }}{% if supplier.city %}, {% endif %}{{ supplier.country_code }}</div>
<div class="lst-badges">
<span class="lst-badge lst-badge--tier">{{ supplier.tier | upper }}</span>
{% if 'verified' in active_boosts or supplier.is_verified %}
<span class="lst-badge lst-badge--verified">Verified &#10003;</span>
{% endif %}
</div>
{% if supplier.tagline %}
<p style="font-size:0.8125rem;color:#1E293B;font-weight:500;margin-bottom:4px">{{ supplier.tagline }}</p>
{% endif %}
{% if supplier.short_description or supplier.description %}
<p class="lst-card__desc">{{ supplier.short_description or supplier.description }}</p>
{% endif %}
{% if supplier.website %}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#1D4ED8">{{ supplier.website }}</div>
{% endif %}
</div>

View File

@@ -30,7 +30,7 @@
} }
</style> </style>
{% if new_leads_count > 0 %} {% if supplier.tier in ('growth', 'pro') and new_leads_count > 0 %}
<div class="ov-alert"> <div class="ov-alert">
<span class="ov-alert__count">{{ new_leads_count }}</span> <span class="ov-alert__count">{{ new_leads_count }}</span>
<span>new lead{{ 's' if new_leads_count != 1 }} match your profile. <span>new lead{{ 's' if new_leads_count != 1 }} match your profile.
@@ -49,6 +49,12 @@
<div class="ov-stat__value">&mdash;</div> <div class="ov-stat__value">&mdash;</div>
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">via Umami</div> <div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">via Umami</div>
</div> </div>
{% if supplier.tier == 'basic' %}
<div class="ov-stat">
<div class="ov-stat__label">Enquiries Received</div>
<div class="ov-stat__value">{{ enquiry_count }}</div>
</div>
{% else %}
<div class="ov-stat"> <div class="ov-stat">
<div class="ov-stat__label">Leads Unlocked</div> <div class="ov-stat__label">Leads Unlocked</div>
<div class="ov-stat__value">{{ leads_unlocked }}</div> <div class="ov-stat__value">{{ leads_unlocked }}</div>
@@ -57,6 +63,7 @@
<div class="ov-stat__label">Credits Balance</div> <div class="ov-stat__label">Credits Balance</div>
<div class="ov-stat__value ov-stat__value--blue">{{ supplier.credit_balance }}</div> <div class="ov-stat__value ov-stat__value--blue">{{ supplier.credit_balance }}</div>
</div> </div>
{% endif %}
<div class="ov-stat"> <div class="ov-stat">
<div class="ov-stat__label">Directory Rank</div> <div class="ov-stat__label">Directory Rank</div>
<div class="ov-stat__value">&mdash;</div> <div class="ov-stat__value">&mdash;</div>
@@ -64,6 +71,13 @@
</div> </div>
</div> </div>
{% if supplier.tier == 'basic' %}
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:10px;padding:12px 16px;margin-bottom:1.5rem;font-size:0.8125rem;color:#1D4ED8">
<strong>Basic plan</strong> — You have a verified listing with an enquiry form. Upgrade to Growth to access qualified project leads.
<a href="{{ url_for('suppliers.signup') }}" style="display:block;font-weight:600;margin-top:4px;color:#1D4ED8">Upgrade to Growth &rarr;</a>
</div>
{% endif %}
<div class="ov-activity"> <div class="ov-activity">
<h3>Recent Activity</h3> <h3>Recent Activity</h3>
{% if recent_activity %} {% if recent_activity %}

View File

@@ -2,10 +2,61 @@
<h2 class="s-step-title">Choose Your Plan</h2> <h2 class="s-step-title">Choose Your Plan</h2>
<p class="s-step-sub">Select the plan that fits your growth goals.</p> <p class="s-step-sub">Select the plan that fits your growth goals.</p>
<!-- Billing period toggle (CSS-only, no JS) -->
<div class="s-billing-toggle">
<input type="radio" name="_bp_toggle" id="bp-monthly" value="monthly"
{% if data.get('billing_period', 'yearly') == 'monthly' %}checked{% endif %}>
<input type="radio" name="_bp_toggle" id="bp-yearly" value="yearly"
{% if data.get('billing_period', 'yearly') != 'monthly' %}checked{% endif %}>
<div class="s-billing-toggle__pill">
<label for="bp-monthly" class="s-billing-toggle__opt">Monthly</label>
<label for="bp-yearly" class="s-billing-toggle__opt">
Yearly <span class="s-save-badge">Save up to 26%</span>
</label>
</div>
</div>
<style>
.s-billing-toggle { margin-bottom: 1.25rem; }
.s-billing-toggle input[type="radio"] { display: none; }
.s-billing-toggle__pill {
display: inline-flex; border: 1px solid #E2E8F0; border-radius: 999px;
background: #F8FAFC; overflow: hidden;
}
.s-billing-toggle__opt {
padding: 6px 18px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
color: #64748B; transition: all 0.15s; border-radius: 999px;
display: flex; align-items: center; gap: 6px;
}
#bp-monthly:checked ~ .s-billing-toggle__pill label[for="bp-monthly"],
#bp-yearly:checked ~ .s-billing-toggle__pill label[for="bp-yearly"] {
background: #1D4ED8; color: white;
}
.s-save-badge {
font-size: 0.625rem; font-weight: 700; background: #DCFCE7; color: #16A34A;
padding: 2px 6px; border-radius: 999px;
}
#bp-yearly:checked ~ .s-billing-toggle__pill label[for="bp-yearly"] .s-save-badge {
background: rgba(255,255,255,0.2); color: white;
}
/* Price display depending on toggle */
.price-monthly, .price-yearly { display: none; }
#bp-monthly:checked ~ * .price-monthly { display: block; }
#bp-yearly:checked ~ * .price-yearly { display: block; }
/* Default (yearly checked) */
.price-yearly { display: block; }
/* Override when monthly is checked — but CSS sibling limitation means we use
the form hidden field trick: the actual billing_period value is set by JS below */
</style>
<form hx-post="{{ url_for('suppliers.signup_step', step=1) }}" <form hx-post="{{ url_for('suppliers.signup_step', step=1) }}"
hx-target="#signup-step" hx-swap="innerHTML"> hx-target="#signup-step" hx-swap="innerHTML" id="step1-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'> <input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
<input type="hidden" name="billing_period" id="billing_period_input"
value="{{ data.get('billing_period', 'yearly') }}">
<div class="s-plan-grid"> <div class="s-plan-grid">
{% for key, plan in plans.items() %} {% for key, plan in plans.items() %}
@@ -14,7 +65,14 @@
<input type="radio" name="plan" value="{{ key }}" {% if data.get('plan', 'supplier_growth') == key %}checked{% endif %}> <input type="radio" name="plan" value="{{ key }}" {% if data.get('plan', 'supplier_growth') == key %}checked{% endif %}>
{% if key == 'supplier_pro' %}<div class="s-plan-card__popular">Most Popular</div>{% endif %} {% if key == 'supplier_pro' %}<div class="s-plan-card__popular">Most Popular</div>{% endif %}
<h3>{{ plan.name }}</h3> <h3>{{ plan.name }}</h3>
<div class="price">&euro;{{ plan.price }} <span>/mo</span></div> <div class="price-yearly">
<div class="price">&euro;{{ plan.yearly_monthly_equivalent }} <span>/mo</span></div>
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">billed at &euro;{{ plan.yearly_price }}/yr</div>
</div>
<div class="price-monthly">
<div class="price">&euro;{{ plan.monthly_price }} <span>/mo</span></div>
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">billed monthly</div>
</div>
<ul> <ul>
{% for f in plan.features %} {% for f in plan.features %}
<li>{{ f }}</li> <li>{{ f }}</li>
@@ -29,4 +87,24 @@
<button type="submit" class="s-btn-next">Next: Add-Ons</button> <button type="submit" class="s-btn-next">Next: Add-Ons</button>
</div> </div>
</form> </form>
<script>
(function() {
// Sync billing period radio → hidden input so it flows into _accumulated
var form = document.getElementById('step1-form');
var hiddenInput = document.getElementById('billing_period_input');
var radios = document.querySelectorAll('[name="_bp_toggle"]');
function syncPeriod() {
radios.forEach(function(r) {
if (r.checked) hiddenInput.value = r.value;
});
}
radios.forEach(function(r) { r.addEventListener('change', syncPeriod); });
// Apply initial display state from existing value
var initial = hiddenInput.value;
radios.forEach(function(r) { if (r.value === initial) r.checked = true; });
})();
</script>
</div> </div>

View File

@@ -57,7 +57,17 @@
<h3>Order Summary</h3> <h3>Order Summary</h3>
<div class="s-summary-row"> <div class="s-summary-row">
<span>{{ order.plan_name }} Plan</span> <span>{{ order.plan_name }} Plan</span>
<span>&euro;{{ order.plan_price }}/mo</span> <span>
{% if order.billing_period == 'yearly' %}
&euro;{{ order.plan_price }}/yr
{% else %}
&euro;{{ order.plan_price }}/mo
{% endif %}
</span>
</div>
<div class="s-summary-row" style="font-size:0.75rem;color:#94A3B8">
<span>{{ order.billing_label }}</span>
<span></span>
</div> </div>
{% if order.boost_monthly > 0 %} {% if order.boost_monthly > 0 %}
<div class="s-summary-row"> <div class="s-summary-row">
@@ -66,8 +76,8 @@
</div> </div>
{% endif %} {% endif %}
<div class="s-summary-row s-summary-total"> <div class="s-summary-row s-summary-total">
<span>Monthly total</span> <span>{% if order.billing_period == 'yearly' %}Yearly total{% else %}Monthly total{% endif %}</span>
<span>&euro;{{ order.monthly_total }}/mo</span> <span>&euro;{{ order.monthly_total }}{% if order.billing_period == 'yearly' %}/mo equiv.{% else %}/mo{% endif %}</span>
</div> </div>
{% if order.one_time_total > 0 %} {% if order.one_time_total > 0 %}
<div class="s-summary-row" style="margin-top:8px"> <div class="s-summary-row" style="margin-top:8px">
@@ -82,7 +92,53 @@
hx-post="{{ url_for('suppliers.signup_step', step=2) }}" hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
hx-target="#signup-step" hx-swap="innerHTML" hx-target="#signup-step" hx-swap="innerHTML"
hx-include="[name='_accumulated']">Back</button> hx-include="[name='_accumulated']">Back</button>
<button type="submit" class="s-btn-next">Proceed to Checkout</button> <button type="submit" class="s-btn-next" id="checkout-btn">Proceed to Checkout</button>
</div> </div>
<div id="checkout-error" hidden
style="background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;padding:10px;margin-top:0.75rem;font-size:0.8125rem;color:#DC2626;text-align:center"></div>
</form> </form>
<script>
(function() {
var form = document.querySelector('[data-step="4"] form');
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('checkout-btn');
var errBox = document.getElementById('checkout-error');
btn.disabled = true;
btn.textContent = 'Loading\u2026';
errBox.hidden = true;
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-CSRF-Token': form.querySelector('[name="csrf_token"]').value }
})
.then(function(res) { return res.json().then(function(d) { return { ok: res.ok, data: d }; }); })
.then(function(result) {
if (!result.ok || result.data.error) {
errBox.textContent = result.data.error || 'Something went wrong. Please try again.';
errBox.hidden = false;
btn.disabled = false;
btn.textContent = 'Proceed to Checkout';
return;
}
Paddle.Checkout.open({
items: result.data.items,
customData: result.data.customData,
settings: result.data.settings
});
btn.disabled = false;
btn.textContent = 'Proceed to Checkout';
})
.catch(function() {
errBox.textContent = 'Network error. Please check your connection and try again.';
errBox.hidden = false;
btn.disabled = false;
btn.textContent = 'Proceed to Checkout';
});
});
})();
</script>
</div> </div>

View File

@@ -364,6 +364,40 @@ async def handle_send_lead_matched_notification(payload: dict) -> None:
) )
@task("send_supplier_enquiry_email")
async def handle_send_supplier_enquiry_email(payload: dict) -> None:
"""Relay a directory enquiry form submission to the supplier's contact email."""
supplier_email = payload.get("supplier_email", "")
if not supplier_email:
return
supplier_name = payload.get("supplier_name", "")
contact_name = payload.get("contact_name", "")
contact_email = payload.get("contact_email", "")
message = payload.get("message", "")
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
f'New enquiry via {config.APP_NAME}</h2>'
f'<p>You have a new directory enquiry for <strong>{supplier_name}</strong>.</p>'
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">'
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">From</td>'
f'<td style="padding:6px 0"><strong>{contact_name}</strong> &lt;{contact_email}&gt;</td></tr>'
f'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">Message</td>'
f'<td style="padding:6px 0;white-space:pre-wrap">{message}</td></tr>'
f'</table>'
f'<p style="font-size:13px;color:#64748B;">Reply directly to <a href="mailto:{contact_email}">'
f'{contact_email}</a> to respond.</p>'
)
await send_email(
to=supplier_email,
subject=f"New enquiry via {config.APP_NAME}: {contact_name}",
html=_email_wrap(body),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@task("refill_monthly_credits") @task("refill_monthly_credits")
async def handle_refill_monthly_credits(payload: dict) -> None: async def handle_refill_monthly_credits(payload: dict) -> None:
"""Refill monthly credits for all claimed suppliers with a paid tier.""" """Refill monthly credits for all claimed suppliers with a paid tier."""

View File

@@ -296,6 +296,55 @@ class TestSupplierSubscriptionActivated:
assert sup[0][0] == 1 # highlight assert sup[0][0] == 1 # highlight
assert sup[0][1] == 1 # is_verified assert sup[0][1] == 1 # is_verified
async def test_basic_plan_sets_tier_zero_credits_and_verified(
self, client, db, supplier, paddle_products, test_user,
):
"""Basic plan: tier='basic', 0 credits, is_verified=1, no ledger entry."""
payload = make_supplier_activation_payload(
items=[],
supplier_id=supplier["id"],
plan="supplier_basic",
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT tier, credit_balance, monthly_credits, is_verified FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "basic"
assert row[0][1] == 0
assert row[0][2] == 0
assert row[0][3] == 1
# No credit ledger entry for Basic (0 credits)
ledger = await db.execute_fetchall(
"SELECT id FROM credit_ledger WHERE supplier_id = ?",
(supplier["id"],),
)
assert len(ledger) == 0
async def test_yearly_plan_derives_correct_tier(
self, client, db, supplier, paddle_products, test_user,
):
"""Yearly plan key suffix is stripped; tier derives correctly."""
payload = make_supplier_activation_payload(
items=[("pri_growth", 1)],
supplier_id=supplier["id"],
plan="supplier_growth_yearly", # yearly variant
user_id=test_user["id"],
)
resp = await _post_webhook(client, payload)
assert resp.status_code == 200
row = await db.execute_fetchall(
"SELECT tier, credit_balance FROM suppliers WHERE id = ?",
(supplier["id"],),
)
assert row[0][0] == "growth"
assert row[0][1] == 30
async def test_no_supplier_id_is_noop( async def test_no_supplier_id_is_noop(
self, client, db, supplier, paddle_products, test_user, self, client, db, supplier, paddle_products, test_user,
): ):