feat: email i18n + Resend audience restructuring

Add lang parameter to all enqueue() calls for email internationalization.
Restructure Resend audiences to 3 named audiences (owners, suppliers, waitlist).
Use _t() translation function in all email template handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 01:06:03 +01:00
parent 13c86ebf84
commit 4e8d94de47
6 changed files with 462 additions and 254 deletions

View File

@@ -1,6 +1,7 @@
"""
Auth domain: magic link authentication, user management, decorators.
"""
import secrets
from datetime import datetime, timedelta
from functools import wraps
@@ -47,19 +48,16 @@ async def pull_auth_lang() -> None:
# SQL Queries
# =============================================================================
async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID."""
return await fetch_one(
"SELECT * FROM users WHERE id = ? AND deleted_at IS NULL",
(user_id,)
)
return await fetch_one("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,))
async def get_user_by_email(email: str) -> dict | None:
"""Get user by email."""
return await fetch_one(
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL",
(email.lower(),)
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", (email.lower(),)
)
@@ -67,8 +65,7 @@ async def create_user(email: str) -> int:
"""Create new user, return ID."""
now = datetime.utcnow().isoformat()
return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)",
(email.lower(), now)
"INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now)
)
@@ -87,7 +84,7 @@ async def create_auth_token(user_id: int, token: str, minutes: int = None) -> in
expires = datetime.utcnow() + timedelta(minutes=minutes)
return await execute(
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, token, expires.isoformat())
(user_id, token, expires.isoformat()),
)
@@ -100,15 +97,14 @@ async def get_valid_token(token: str) -> dict | None:
JOIN users u ON u.id = at.user_id
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
""",
(token, datetime.utcnow().isoformat())
(token, datetime.utcnow().isoformat()),
)
async def mark_token_used(token_id: int) -> None:
"""Mark token as used."""
await execute(
"UPDATE auth_tokens SET used_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), token_id)
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id)
)
@@ -116,19 +112,23 @@ async def mark_token_used(token_id: int) -> None:
# Decorators
# =============================================================================
def login_required(f):
"""Require authenticated user."""
@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))
return await f(*args, **kwargs)
return decorated
def role_required(*roles):
"""Require user to have at least one of the given roles."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
@@ -140,7 +140,9 @@ def role_required(*roles):
await flash("You don't have permission to access that page.", "error")
return redirect(url_for("dashboard.index"))
return await f(*args, **kwargs)
return decorated
return decorator
@@ -174,6 +176,7 @@ def subscription_required(
Reads from g.subscription (eager-loaded in load_user) — zero extra queries.
"""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
@@ -191,7 +194,9 @@ def subscription_required(
return redirect(url_for("billing.pricing"))
return await f(*args, **kwargs)
return decorated
return decorator
@@ -199,6 +204,7 @@ def subscription_required(
# Routes
# =============================================================================
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
@@ -231,6 +237,7 @@ async def login():
# Queue email
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
await flash(_t["auth_flash_login_sent"], "success")
@@ -292,6 +299,7 @@ async def signup():
# Queue emails
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
await enqueue("send_welcome", {"email": email, "lang": g.lang})
@@ -397,6 +405,7 @@ async def resend():
await create_auth_token(user["id"], token)
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
# Always show success (don't reveal if email exists)

View File

@@ -1,6 +1,7 @@
"""
Core infrastructure: database, config, email, and shared utilities.
"""
import hashlib
import hmac
import os
@@ -30,6 +31,7 @@ def _env(key: str, default: str) -> str:
# Configuration
# =============================================================================
class Config:
APP_NAME: str = _env("APP_NAME", "Padelnomics")
SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production")
@@ -166,6 +168,7 @@ class transaction:
await self.db.rollback()
return False
# =============================================================================
# Email
# =============================================================================
@@ -181,40 +184,87 @@ EMAIL_ADDRESSES = {
# Input validation helpers
# ──────────────────────────────────────────────────────────────────────────────
_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset(
{
# Germany / Austria / Switzerland common disposables
"byom.de", "trash-mail.de", "spamgourmet.de", "mailnull.com",
"spambog.de", "trashmail.de", "wegwerf-email.de", "spam4.me",
"byom.de",
"trash-mail.de",
"spamgourmet.de",
"mailnull.com",
"spambog.de",
"trashmail.de",
"wegwerf-email.de",
"spam4.me",
"yopmail.de",
# Global well-known disposables
"guerrillamail.com", "guerrillamail.net", "guerrillamail.org",
"guerrillamail.biz", "guerrillamail.de", "guerrillamail.info",
"guerrillamailblock.com", "grr.la", "spam4.me",
"mailinator.com", "mailinator.net", "mailinator.org",
"tempmail.com", "temp-mail.org", "tempmail.net", "tempmail.io",
"10minutemail.com", "10minutemail.net", "10minutemail.org",
"10minemail.com", "10minutemail.de",
"yopmail.com", "yopmail.fr", "yopmail.net",
"sharklasers.com", "guerrillamail.info", "grr.la",
"throwam.com", "throwam.net",
"maildrop.cc", "dispostable.com",
"discard.email", "discardmail.com", "discardmail.de",
"spamgourmet.com", "spamgourmet.net",
"trashmail.at", "trashmail.com", "trashmail.io",
"trashmail.me", "trashmail.net", "trashmail.org",
"trash-mail.at", "trash-mail.com",
"fakeinbox.com", "fakemail.fr", "fakemail.net",
"getnada.com", "getairmail.com",
"bccto.me", "chacuo.net",
"crapmail.org", "crap.email",
"spamherelots.com", "spamhereplease.com",
"throwam.com", "throwam.net",
"spamspot.com", "spamthisplease.com",
"guerrillamail.com",
"guerrillamail.net",
"guerrillamail.org",
"guerrillamail.biz",
"guerrillamail.de",
"guerrillamail.info",
"guerrillamailblock.com",
"grr.la",
"spam4.me",
"mailinator.com",
"mailinator.net",
"mailinator.org",
"tempmail.com",
"temp-mail.org",
"tempmail.net",
"tempmail.io",
"10minutemail.com",
"10minutemail.net",
"10minutemail.org",
"10minemail.com",
"10minutemail.de",
"yopmail.com",
"yopmail.fr",
"yopmail.net",
"sharklasers.com",
"guerrillamail.info",
"grr.la",
"throwam.com",
"throwam.net",
"maildrop.cc",
"dispostable.com",
"discard.email",
"discardmail.com",
"discardmail.de",
"spamgourmet.com",
"spamgourmet.net",
"trashmail.at",
"trashmail.com",
"trashmail.io",
"trashmail.me",
"trashmail.net",
"trashmail.org",
"trash-mail.at",
"trash-mail.com",
"fakeinbox.com",
"fakemail.fr",
"fakemail.net",
"getnada.com",
"getairmail.com",
"bccto.me",
"chacuo.net",
"crapmail.org",
"crap.email",
"spamherelots.com",
"spamhereplease.com",
"throwam.com",
"throwam.net",
"spamspot.com",
"spamthisplease.com",
"filzmail.com",
"mytemp.email", "mynullmail.com",
"mailnesia.com", "mailnull.com",
"no-spam.ws", "noblepioneer.com",
"nospam.ze.tc", "nospam4.us",
"mytemp.email",
"mynullmail.com",
"mailnesia.com",
"mailnull.com",
"no-spam.ws",
"noblepioneer.com",
"nospam.ze.tc",
"nospam4.us",
"owlpic.com",
"pookmail.com",
"poof.email",
@@ -226,7 +276,9 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
"shitmail.me",
"smellfear.com",
"spamavert.com",
"spambog.com", "spambog.net", "spambog.ru",
"spambog.com",
"spambog.net",
"spambog.ru",
"spamgob.com",
"spamherelots.com",
"spamslicer.com",
@@ -237,14 +289,17 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
"throwam.com",
"tilien.com",
"tmailinator.com",
"trashdevil.com", "trashdevil.de",
"trashdevil.com",
"trashdevil.de",
"trbvm.com",
"turual.com",
"uggsrock.com",
"viditag.com",
"vomoto.com",
"vpn.st",
"wegwerfemail.de", "wegwerfemail.net", "wegwerfemail.org",
"wegwerfemail.de",
"wegwerfemail.net",
"wegwerfemail.org",
"wetrainbayarea.com",
"willhackforfood.biz",
"wuzupmail.net",
@@ -255,7 +310,8 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
"yogamaven.com",
"z1p.biz",
"zoemail.org",
})
}
)
def is_disposable_email(email: str) -> bool:
@@ -299,22 +355,26 @@ async def send_email(
resend.api_key = config.RESEND_API_KEY
try:
resend.Emails.send({
resend.Emails.send(
{
"from": from_addr or config.EMAIL_FROM,
"to": to,
"subject": subject,
"html": html,
"text": text or html,
})
}
)
return True
except Exception as e:
print(f"[EMAIL] Error sending to {to}: {e}")
return False
# =============================================================================
# Waitlist
# =============================================================================
async def _get_or_create_resend_audience(name: str) -> str | None:
"""Get cached Resend audience ID, or create one via API. Returns None on failure."""
row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,))
@@ -349,7 +409,9 @@ def _audience_for_blueprint(blueprint: str) -> str:
return _BLUEPRINT_TO_AUDIENCE.get(blueprint, "newsletter")
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
async def capture_waitlist_email(
email: str, intent: str, plan: str = None, email_intent: str = None
) -> bool:
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
Args:
@@ -365,7 +427,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
try:
cursor_result = await execute(
"INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)",
(email, intent, plan, request.remote_addr)
(email, intent, plan, request.remote_addr),
)
is_new = cursor_result > 0
except Exception:
@@ -375,6 +437,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
# Enqueue confirmation email only if new
if is_new:
from .worker import enqueue
email_intent_value = email_intent if email_intent is not None else intent
lang = g.get("lang", "en") if g else "en"
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang})
@@ -394,10 +457,12 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
return is_new
# =============================================================================
# CSRF Protection
# =============================================================================
def get_csrf_token() -> str:
"""Get or create CSRF token for current session."""
if "csrf_token" not in session:
@@ -412,6 +477,7 @@ def validate_csrf_token(token: str) -> bool:
def csrf_protect(f):
"""Decorator to require valid CSRF token for POST requests."""
@wraps(f)
async def decorated(*args, **kwargs):
if request.method == "POST":
@@ -420,12 +486,15 @@ def csrf_protect(f):
if not validate_csrf_token(token):
return {"error": "Invalid CSRF token"}, 403
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Rate Limiting (SQLite-based)
# =============================================================================
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
"""
Check if rate limit exceeded. Returns (is_allowed, info).
@@ -438,13 +507,12 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
# Clean old entries and count recent
await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
(key, window_start.isoformat())
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat())
)
result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
(key, window_start.isoformat())
(key, window_start.isoformat()),
)
count = result["count"] if result else 0
@@ -458,16 +526,14 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
return False, info
# Record this request
await execute(
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.isoformat())
)
await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat()))
return True, info
def rate_limit(limit: int = None, window: int = None, key_func=None):
"""Decorator for rate limiting routes."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
@@ -483,9 +549,12 @@ def rate_limit(limit: int = None, window: int = None, key_func=None):
return response, 429
return await f(*args, **kwargs)
return decorated
return decorator
# =============================================================================
# Request ID Tracking
# =============================================================================
@@ -500,6 +569,7 @@ def get_request_id() -> str:
def setup_request_id(app):
"""Setup request ID middleware."""
@app.before_request
async def set_request_id():
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
@@ -511,6 +581,7 @@ def setup_request_id(app):
response.headers["X-Request-ID"] = get_request_id()
return response
# =============================================================================
# Webhook Signature Verification
# =============================================================================
@@ -526,21 +597,19 @@ def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
# Soft Delete Helpers
# =============================================================================
async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted."""
result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id)
(datetime.utcnow().isoformat(), id),
)
return result > 0
async def restore(table: str, id: int) -> bool:
"""Restore soft-deleted record."""
result = await execute(
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
(id,)
)
result = await execute(f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", (id,))
return result > 0
@@ -554,8 +623,7 @@ async def purge_deleted(table: str, days: int = 30) -> int:
"""Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
(cutoff,)
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
)
@@ -563,11 +631,10 @@ async def purge_deleted(table: str, days: int = 30) -> int:
# Paddle Product Lookup
# =============================================================================
async def get_paddle_price(key: str) -> str | None:
"""Look up a Paddle price ID by product key from the paddle_products table."""
row = await fetch_one(
"SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)
)
row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
return row["paddle_price_id"] if row else None
@@ -581,6 +648,7 @@ async def get_all_paddle_prices() -> dict[str, str]:
# Text Utilities
# =============================================================================
def slugify(text: str, max_length_chars: int = 80) -> str:
"""Convert text to URL-safe slug."""
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
@@ -593,6 +661,7 @@ def slugify(text: str, max_length_chars: int = 80) -> str:
# A/B Testing
# =============================================================================
def _has_functional_consent() -> bool:
"""Return True if the visitor has accepted functional cookies."""
return "functional" in request.cookies.get("cookie_consent", "")
@@ -605,6 +674,7 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
cookie consent. Without consent a random variant is picked per-request
(so the page renders fine and Umami is tagged), but no cookie is set.
"""
def decorator(f):
@wraps(f)
async def wrapper(*args, **kwargs):
@@ -622,7 +692,9 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
if has_consent:
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
return response
return wrapper
return decorator
@@ -646,6 +718,7 @@ def waitlist_gate(template: str, **extra_context):
# POST handling and normal signup code here
...
"""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
@@ -655,5 +728,7 @@ def waitlist_gate(template: str, **extra_context):
ctx[key] = val() if callable(val) else val
return await render_template(template, **ctx)
return await f(*args, **kwargs)
return decorated
return decorator

View File

@@ -1,6 +1,7 @@
"""
Supplier directory: public, searchable listing of padel court suppliers.
"""
from datetime import UTC, datetime
from pathlib import Path
@@ -17,17 +18,39 @@ bp = Blueprint(
)
COUNTRY_LABELS = {
"DE": "Germany", "ES": "Spain", "IT": "Italy", "FR": "France",
"PT": "Portugal", "GB": "United Kingdom", "NL": "Netherlands",
"BE": "Belgium", "SE": "Sweden", "DK": "Denmark", "FI": "Finland",
"NO": "Norway", "AT": "Austria", "SI": "Slovenia", "IS": "Iceland",
"CH": "Switzerland", "EE": "Estonia",
"US": "United States", "CA": "Canada",
"MX": "Mexico", "BR": "Brazil", "AR": "Argentina",
"AE": "UAE", "SA": "Saudi Arabia", "TR": "Turkey",
"CN": "China", "IN": "India", "SG": "Singapore",
"ID": "Indonesia", "TH": "Thailand", "AU": "Australia",
"ZA": "South Africa", "EG": "Egypt",
"DE": "Germany",
"ES": "Spain",
"IT": "Italy",
"FR": "France",
"PT": "Portugal",
"GB": "United Kingdom",
"NL": "Netherlands",
"BE": "Belgium",
"SE": "Sweden",
"DK": "Denmark",
"FI": "Finland",
"NO": "Norway",
"AT": "Austria",
"SI": "Slovenia",
"IS": "Iceland",
"CH": "Switzerland",
"EE": "Estonia",
"US": "United States",
"CA": "Canada",
"MX": "Mexico",
"BR": "Brazil",
"AR": "Argentina",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
"CN": "China",
"IN": "India",
"SG": "Singapore",
"ID": "Indonesia",
"TH": "Thailand",
"AU": "Australia",
"ZA": "South Africa",
"EG": "Egypt",
}
CATEGORY_LABELS = {
@@ -75,9 +98,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
terms = [t for t in q.split() if t]
if terms:
fts_q = " ".join(t + "*" for t in terms)
wheres.append(
"s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)"
)
wheres.append("s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)")
params.append(fts_q)
if country:
@@ -127,6 +148,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
tuple(supplier_ids),
)
import json
for row in color_rows:
meta = {}
if row["metadata"]:
@@ -170,14 +192,11 @@ async def index():
)
category_counts = await fetch_all(
"SELECT category, COUNT(*) as cnt FROM suppliers"
" GROUP BY category ORDER BY cnt DESC"
"SELECT category, COUNT(*) as cnt FROM suppliers GROUP BY category ORDER BY cnt DESC"
)
total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
total_countries = await fetch_one(
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
)
total_countries = await fetch_one("SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers")
return await render_template(
"directory.html",
@@ -195,6 +214,7 @@ async def supplier_detail(slug: str):
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,))
if not supplier:
from quart import abort
abort(404)
# Get active boosts
@@ -206,7 +226,9 @@ async def supplier_detail(slug: str):
# 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 []
services_list = (
[s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else []
)
# Build social links dict
social_links = {
@@ -250,6 +272,7 @@ async def supplier_enquiry(slug: str):
)
if not supplier:
from quart import abort
abort(404)
form = await request.form
@@ -294,7 +317,10 @@ async def supplier_enquiry(slug: str):
# Enqueue email to supplier
if supplier.get("contact_email"):
from ..worker import enqueue
await enqueue("send_supplier_enquiry_email", {
await enqueue(
"send_supplier_enquiry_email",
{
"supplier_id": supplier["id"],
"supplier_name": supplier["name"],
"supplier_email": supplier["contact_email"],
@@ -302,7 +328,8 @@ async def supplier_enquiry(slug: str):
"contact_email": contact_email,
"message": message,
"lang": g.get("lang", "en"),
})
},
)
return await render_template(
"partials/enquiry_result.html",
@@ -317,6 +344,7 @@ async def supplier_website(slug: str):
supplier = await fetch_one("SELECT website FROM suppliers WHERE slug = ?", (slug,))
if not supplier or not supplier["website"]:
from quart import abort
abort(404)
url = supplier["website"]
if not url.startswith("http"):

View File

@@ -1,6 +1,7 @@
"""
Leads domain: capture interest in court suppliers and financing.
"""
import json
import secrets
from datetime import datetime
@@ -41,6 +42,7 @@ bp = Blueprint(
# Heat Score Calculation
# =============================================================================
def calculate_heat_score(form: dict) -> str:
"""Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'."""
score = 0
@@ -83,6 +85,7 @@ def calculate_heat_score(form: dict) -> str:
# Routes
# =============================================================================
@bp.route("/suppliers", methods=["GET", "POST"])
@login_required
@csrf_protect
@@ -183,7 +186,11 @@ def _get_quote_steps(lang: str) -> list:
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
{"n": 8, "title": t["q8_heading"], "required": ["services_needed"]},
{"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]},
{
"n": 9,
"title": t["q9_heading"],
"required": ["contact_name", "contact_email", "contact_phone"],
},
]
@@ -235,7 +242,9 @@ async def quote_step(step):
if errors:
return await render_template(
f"partials/quote_step_{step}.html",
data=accumulated, step=step, steps=steps,
data=accumulated,
step=step,
steps=steps,
errors=errors,
)
# Return next step
@@ -244,7 +253,9 @@ async def quote_step(step):
next_step = len(steps)
return await render_template(
f"partials/quote_step_{next_step}.html",
data=accumulated, step=next_step, steps=steps,
data=accumulated,
step=next_step,
steps=steps,
errors=[],
)
@@ -252,7 +263,9 @@ async def quote_step(step):
accumulated = _parse_accumulated(request.args)
return await render_template(
f"partials/quote_step_{step}.html",
data=accumulated, step=step, steps=steps,
data=accumulated,
step=step,
steps=steps,
errors=[],
)
@@ -296,7 +309,9 @@ async def quote_request():
if field_errors:
if is_json:
return jsonify({"ok": False, "errors": field_errors}), 422
form_data = {k: v for k, v in form.items() if not k.startswith("_") and k != "csrf_token"}
form_data = {
k: v for k, v in form.items() if not k.startswith("_") and k != "csrf_token"
}
form_data["services_needed"] = services
return await render_template(
"quote_request.html",
@@ -310,6 +325,7 @@ async def quote_request():
# Compute credit cost from heat tier
from ..credits import HEAT_CREDIT_COSTS
credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
services_json = json.dumps(services) if services else None
@@ -318,10 +334,7 @@ async def quote_request():
contact_email = form.get("contact_email", "").strip().lower()
# Logged-in user with matching email → skip verification
is_verified_user = (
g.user is not None
and g.user["email"].lower() == contact_email
)
is_verified_user = g.user is not None and g.user["email"].lower() == contact_email
status = "new" if is_verified_user else "pending_verification"
lead_id = await execute(
@@ -370,6 +383,7 @@ async def quote_request():
if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY:
try:
import resend
resend.api_key = config.RESEND_API_KEY
resend.Contacts.remove(
audience_id=config.RESEND_AUDIENCE_PLANNER,
@@ -423,13 +437,14 @@ async def quote_request():
token = secrets.token_urlsafe(32)
await create_auth_token(new_user_id, token, minutes=60)
lead_token_row = await fetch_one(
"SELECT token FROM lead_requests WHERE id = ?", (lead_id,)
)
lead_token_row = await fetch_one("SELECT token FROM lead_requests WHERE id = ?", (lead_id,))
lead_token = lead_token_row["token"]
from ..worker import enqueue
await enqueue("send_quote_verification", {
await enqueue(
"send_quote_verification",
{
"email": contact_email,
"token": token,
"lead_id": lead_id,
@@ -439,7 +454,8 @@ async def quote_request():
"facility_type": form.get("facility_type", ""),
"court_count": form.get("court_count", ""),
"country": form.get("country", ""),
})
},
)
if is_json:
return jsonify({"ok": True, "pending_verification": True})
@@ -465,7 +481,9 @@ async def quote_request():
start_step = 2 # skip project step, already filled
return await render_template(
"quote_request.html",
data=data, step=start_step, steps=_get_quote_steps(g.get("lang", "en")),
data=data,
step=start_step,
steps=_get_quote_steps(g.get("lang", "en")),
)
@@ -500,6 +518,7 @@ async def verify_quote():
# Compute credit cost and activate lead
from ..credits import compute_credit_cost
credit_cost = compute_credit_cost(dict(lead))
now = datetime.utcnow().isoformat()
await execute(
@@ -535,6 +554,7 @@ async def verify_quote():
# Send welcome email
from ..worker import enqueue
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
return await render_template(

View File

@@ -86,10 +86,34 @@ PLAN_FEATURES = {
}
BOOST_OPTIONS = [
{"key": "boost_logo", "type": "logo", "name_key": "sd_boost_logo_name", "price": 29, "desc_key": "sd_boost_logo_desc"},
{"key": "boost_highlight", "type": "highlight", "name_key": "sd_boost_highlight_name", "price": 39, "desc_key": "sd_boost_highlight_desc"},
{"key": "boost_verified", "type": "verified", "name_key": "sd_boost_verified_name", "price": 49, "desc_key": "sd_boost_verified_desc"},
{"key": "boost_card_color", "type": "card_color", "name_key": "sd_boost_card_color_name", "price": 19, "desc_key": "sd_boost_card_color_desc"},
{
"key": "boost_logo",
"type": "logo",
"name_key": "sd_boost_logo_name",
"price": 29,
"desc_key": "sd_boost_logo_desc",
},
{
"key": "boost_highlight",
"type": "highlight",
"name_key": "sd_boost_highlight_name",
"price": 39,
"desc_key": "sd_boost_highlight_desc",
},
{
"key": "boost_verified",
"type": "verified",
"name_key": "sd_boost_verified_name",
"price": 49,
"desc_key": "sd_boost_verified_desc",
},
{
"key": "boost_card_color",
"type": "card_color",
"name_key": "sd_boost_card_color_name",
"price": 19,
"desc_key": "sd_boost_card_color_desc",
},
]
CREDIT_PACK_OPTIONS = [
@@ -100,14 +124,51 @@ CREDIT_PACK_OPTIONS = [
]
SERVICE_CATEGORIES = [
"manufacturer", "turnkey", "consultant", "hall_builder",
"turf", "lighting", "software", "industry_body", "franchise",
"manufacturer",
"turnkey",
"consultant",
"hall_builder",
"turf",
"lighting",
"software",
"industry_body",
"franchise",
]
COUNTRIES = [
"DE", "ES", "IT", "FR", "PT", "GB", "NL", "BE", "SE", "DK", "FI",
"NO", "AT", "SI", "IS", "CH", "EE", "US", "CA", "MX", "BR", "AR",
"AE", "SA", "TR", "CN", "IN", "SG", "ID", "TH", "AU", "ZA", "EG",
"DE",
"ES",
"IT",
"FR",
"PT",
"GB",
"NL",
"BE",
"SE",
"DK",
"FI",
"NO",
"AT",
"SI",
"IS",
"CH",
"EE",
"US",
"CA",
"MX",
"BR",
"AR",
"AE",
"SA",
"TR",
"CN",
"IN",
"SG",
"ID",
"TH",
"AU",
"ZA",
"EG",
]
@@ -122,15 +183,14 @@ def _parse_accumulated(form_or_args):
def _get_supplier_for_user(user_id: int):
"""Get the supplier record claimed by a user."""
return fetch_one(
"SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,)
)
return fetch_one("SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,))
# =============================================================================
# Auth decorator
# =============================================================================
def _supplier_required(f):
"""Require authenticated user with a claimed supplier on any paid tier (basic, growth, pro)."""
from functools import wraps
@@ -181,6 +241,7 @@ def _lead_tier_required(f):
# Signup Wizard
# =============================================================================
@bp.route("/signup")
@waitlist_gate(
"suppliers/waitlist.html",
@@ -370,6 +431,7 @@ async def signup_checkout():
return jsonify({"error": "Email is required."}), 400
from ..auth.routes import create_user, get_user_by_email
user = await get_user_by_email(email)
if not user:
user_id = await create_user(email)
@@ -413,13 +475,15 @@ async def signup_checkout():
"plan": plan,
}
return jsonify({
return jsonify(
{
"items": items,
"customData": custom_data,
"settings": {
"successUrl": f"{config.BASE_URL}/suppliers/signup/success",
},
})
}
)
@bp.route("/claim/<slug>")
@@ -445,6 +509,7 @@ async def signup_success():
# Supplier Lead Feed
# =============================================================================
async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="", limit=50):
"""Shared query for lead feed — used by standalone and dashboard."""
wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"]
@@ -538,12 +603,16 @@ async def unlock_lead(token: str):
# Enqueue lead forward email
from ..worker import enqueue
lang = g.get("lang", "en")
await enqueue("send_lead_forward_email", {
await enqueue(
"send_lead_forward_email",
{
"lead_id": lead_id,
"supplier_id": supplier["id"],
"lang": lang,
})
},
)
# Notify entrepreneur on first unlock
lead = result["lead"]
@@ -577,6 +646,7 @@ async def unlock_lead(token: str):
# Supplier Dashboard
# =============================================================================
@bp.route("/dashboard")
@_supplier_required
async def dashboard():
@@ -680,7 +750,9 @@ async def dashboard_leads():
# Look up scenario IDs for unlocked leads
scenario_ids = {}
unlocked_user_ids = [lead["user_id"] for lead in leads if lead.get("is_unlocked") and lead.get("user_id")]
unlocked_user_ids = [
lead["user_id"] for lead in leads if lead.get("is_unlocked") and lead.get("user_id")
]
if unlocked_user_ids:
placeholders = ",".join("?" * len(unlocked_user_ids))
scenarios = await fetch_all(

View File

@@ -1,6 +1,7 @@
"""
Background task worker - SQLite-based queue (no Redis needed).
"""
import asyncio
import json
import traceback
@@ -90,15 +91,17 @@ def _email_button(url: str, label: str) -> str:
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
f'{label}</a></td></tr></table>'
f"{label}</a></td></tr></table>"
)
def task(name: str):
"""Decorator to register a task handler."""
def decorator(f):
HANDLERS[name] = f
return f
return decorator
@@ -106,6 +109,7 @@ def task(name: str):
# Task Queue Operations
# =============================================================================
async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int:
"""Add a task to the queue."""
return await execute(
@@ -118,7 +122,7 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None)
json.dumps(payload or {}),
(run_at or datetime.utcnow()).isoformat(),
datetime.utcnow().isoformat(),
)
),
)
@@ -132,7 +136,7 @@ async def get_pending_tasks(limit: int = 10) -> list[dict]:
ORDER BY run_at ASC
LIMIT ?
""",
(now, limit)
(now, limit),
)
@@ -140,7 +144,7 @@ async def mark_complete(task_id: int) -> None:
"""Mark task as completed."""
await execute(
"UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), task_id)
(datetime.utcnow().isoformat(), task_id),
)
@@ -159,12 +163,11 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
SET status = 'pending', error = ?, retries = ?, run_at = ?
WHERE id = ?
""",
(error, retries + 1, run_at.isoformat(), task_id)
(error, retries + 1, run_at.isoformat(), task_id),
)
else:
await execute(
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?",
(error, task_id)
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", (error, task_id)
)
@@ -172,6 +175,7 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
# Built-in Task Handlers
# =============================================================================
@task("send_email")
async def handle_send_email(payload: dict) -> None:
"""Send an email."""
@@ -228,7 +232,9 @@ async def handle_send_quote_verification(payload: dict) -> None:
print(f" {link}")
print(f"{'=' * 60}\n")
first_name = payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
first_name = (
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
)
project_desc = ""
parts = []
if payload.get("court_count"):
@@ -322,10 +328,7 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
@task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens."""
await execute(
"DELETE FROM auth_tokens WHERE expires_at < ?",
(datetime.utcnow().isoformat(),)
)
await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (datetime.utcnow().isoformat(),))
@task("cleanup_rate_limits")
@@ -360,7 +363,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
(t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
(t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
(t("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: {budget}"),
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: \u20ac{budget}"),
(t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
(t("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
@@ -565,8 +568,7 @@ async def handle_cleanup_tasks(payload: dict) -> None:
"""Clean up completed/failed tasks older than 7 days."""
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
await execute(
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?",
(cutoff,)
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,)
)
@@ -574,6 +576,7 @@ async def handle_cleanup_tasks(payload: dict) -> None:
# Worker Loop
# =============================================================================
async def process_task(task: dict) -> None:
"""Process a single task."""
task_name = task["task_name"]
@@ -632,6 +635,7 @@ async def run_scheduler() -> None:
# Monthly credit refill — run on the 1st of each month
from datetime import datetime
today = datetime.utcnow()
this_month = f"{today.year}-{today.month:02d}"
if today.day == 1 and last_credit_refill != this_month: