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:
54
CHANGELOG.md
54
CHANGELOG.md
@@ -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`;
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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">← All Suppliers</a>
|
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">← 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 }} · {{ supplier.country_code or '-' }}</p>
|
<p class="text-sm text-slate mt-1">{{ supplier.slug }} · {{ 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 %}
|
||||||
|
|||||||
@@ -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">← All Suppliers</a>
|
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">← 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 %}
|
||||||
|
|||||||
@@ -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', ?)""",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">✓</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 %}
|
||||||
@@ -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 ✓</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 →</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 }}">«</a>
|
<a href="?q={{ q }}&country={{ country }}&category={{ category }}®ion={{ region }}&page={{ page - 1 }}">«</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 }}®ion={{ 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">…</span>
|
<span style="border:none">…</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
<a href="?q={{ q }}&country={{ country }}&category={{ category }}&page={{ page + 1 }}">»</a>
|
<a href="?q={{ q }}&country={{ country }}&category={{ category }}®ion={{ region }}&page={{ page + 1 }}">»</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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">← 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 ✓</span>
|
<span class="sp-hero-badge sp-hero-badge--verified">Verified ✓</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if supplier.tier != 'free' %}
|
</div>
|
||||||
<span class="sp-badge sp-badge--tier">{{ supplier.tier | title }}</span>
|
{% if supplier.tagline %}
|
||||||
|
<p class="sp-hero-tagline">{{ supplier.tagline }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 →
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{% set desc = supplier.long_description or supplier.description %}
|
{# ── 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 →
|
||||||
|
</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 →</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 →
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 →
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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 ✓</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>
|
||||||
|
|||||||
@@ -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 ✓</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>
|
||||||
@@ -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">—</div>
|
<div class="ov-stat__value">—</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">—</div>
|
<div class="ov-stat__value">—</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 →</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 %}
|
||||||
|
|||||||
@@ -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">€{{ plan.price }} <span>/mo</span></div>
|
<div class="price-yearly">
|
||||||
|
<div class="price">€{{ plan.yearly_monthly_equivalent }} <span>/mo</span></div>
|
||||||
|
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">billed at €{{ plan.yearly_price }}/yr</div>
|
||||||
|
</div>
|
||||||
|
<div class="price-monthly">
|
||||||
|
<div class="price">€{{ 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>
|
||||||
|
|||||||
@@ -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>€{{ order.plan_price }}/mo</span>
|
<span>
|
||||||
|
{% if order.billing_period == 'yearly' %}
|
||||||
|
€{{ order.plan_price }}/yr
|
||||||
|
{% else %}
|
||||||
|
€{{ 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>€{{ order.monthly_total }}/mo</span>
|
<span>€{{ 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>
|
||||||
|
|||||||
@@ -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> <{contact_email}></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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user