Merge branch 'worktree-extraction-overhaul'
# Conflicts: # transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql # transform/sqlmesh_padelnomics/models/staging/stg_playtomic_venues.sql
This commit is contained in:
@@ -231,7 +231,7 @@ async def login():
|
||||
|
||||
# Queue email
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
|
||||
|
||||
await flash(_t["auth_flash_login_sent"], "success")
|
||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||
@@ -292,8 +292,8 @@ async def signup():
|
||||
|
||||
# Queue emails
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||
await enqueue("send_welcome", {"email": email})
|
||||
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
|
||||
await enqueue("send_welcome", {"email": email, "lang": g.lang})
|
||||
|
||||
await flash(_t["auth_flash_signup_sent"], "success")
|
||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||
@@ -397,7 +397,7 @@ async def resend():
|
||||
await create_auth_token(user["id"], token)
|
||||
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
|
||||
|
||||
# Always show success (don't reveal if email exists)
|
||||
await flash(_t["auth_flash_resend_sent"], "success")
|
||||
|
||||
@@ -334,6 +334,21 @@ async def _get_or_create_resend_audience(name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_BLUEPRINT_TO_AUDIENCE = {
|
||||
"suppliers": "suppliers",
|
||||
"planner": "leads",
|
||||
"leads": "leads",
|
||||
"auth": "newsletter",
|
||||
"content": "newsletter",
|
||||
"public": "newsletter",
|
||||
}
|
||||
|
||||
|
||||
def _audience_for_blueprint(blueprint: str) -> str:
|
||||
"""Map blueprint name to one of 3 Resend audiences (free plan limit)."""
|
||||
return _BLUEPRINT_TO_AUDIENCE.get(blueprint, "newsletter")
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -361,12 +376,14 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
|
||||
if is_new:
|
||||
from .worker import enqueue
|
||||
email_intent_value = email_intent if email_intent is not None else intent
|
||||
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
|
||||
lang = g.get("lang", "en") if g else "en"
|
||||
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang})
|
||||
|
||||
# Add to Resend audience (silent fail - not critical)
|
||||
# 3 named audiences: suppliers, leads, newsletter (free plan limit = 3)
|
||||
if config.RESEND_API_KEY:
|
||||
blueprint = request.blueprints[0] if request.blueprints else "default"
|
||||
audience_name = f"waitlist-{blueprint}"
|
||||
blueprint = request.blueprints[0] if request.blueprints else "public"
|
||||
audience_name = _audience_for_blueprint(blueprint)
|
||||
audience_id = await _get_or_create_resend_audience(audience_name)
|
||||
if audience_id:
|
||||
try:
|
||||
|
||||
@@ -301,6 +301,7 @@ async def supplier_enquiry(slug: str):
|
||||
"contact_name": contact_name,
|
||||
"contact_email": contact_email,
|
||||
"message": message,
|
||||
"lang": g.get("lang", "en"),
|
||||
})
|
||||
|
||||
return await render_template(
|
||||
|
||||
@@ -535,7 +535,7 @@ async def verify_quote():
|
||||
|
||||
# Send welcome email
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_welcome", {"email": contact_email})
|
||||
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
|
||||
|
||||
return await render_template(
|
||||
"quote_submitted.html",
|
||||
|
||||
@@ -1536,5 +1536,83 @@
|
||||
"bp_lbl_debt": "Schulden",
|
||||
"bp_lbl_cumulative": "Kumulativ",
|
||||
|
||||
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io"
|
||||
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io",
|
||||
|
||||
"email_magic_link_heading": "Bei {app_name} anmelden",
|
||||
"email_magic_link_body": "Klicke auf den Button unten, um dich anzumelden. Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab.",
|
||||
"email_magic_link_btn": "Anmelden",
|
||||
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
|
||||
"email_magic_link_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
|
||||
"email_magic_link_subject": "Bei {app_name} anmelden",
|
||||
|
||||
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
|
||||
"email_quote_verify_greeting": "Hallo {first_name},",
|
||||
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage{project_desc}. Klicke auf den Button unten, um deine E-Mail zu best\u00e4tigen und deine Anfrage zu aktivieren. Dabei wird auch dein {app_name}-Konto erstellt, damit du dein Projekt verfolgen kannst.",
|
||||
"email_quote_verify_btn": "Best\u00e4tigen & Angebot aktivieren",
|
||||
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
|
||||
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
|
||||
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
|
||||
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
|
||||
|
||||
"email_welcome_heading": "Willkommen bei {app_name}!",
|
||||
"email_welcome_body": "Danke f\u00fcr deine Anmeldung. Du kannst jetzt mit der Planung deines Padel-Gesch\u00e4fts loslegen.",
|
||||
"email_welcome_btn": "Zum Dashboard",
|
||||
"email_welcome_subject": "Willkommen bei {app_name}",
|
||||
|
||||
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
|
||||
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen die ultimative Anbieter-Plattform f\u00fcr Padel-Unternehmer.",
|
||||
"email_waitlist_supplier_perks": "Du erf\u00e4hrst als Erster, wenn wir starten. Wir senden dir fr\u00fchen Zugang, exklusive Launch-Preise und Onboarding-Unterst\u00fctzung.",
|
||||
"email_waitlist_supplier_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
|
||||
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
|
||||
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
|
||||
"email_waitlist_supplier_subject": "Du stehst auf der Liste \u2014 {app_name} {plan_name} startet bald",
|
||||
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
|
||||
"email_waitlist_general_body": "Danke, dass du dich auf die Warteliste eingetragen hast. Wir bereiten den Start der ultimativen Planungsplattform f\u00fcr Padel-Unternehmer vor.",
|
||||
"email_waitlist_general_perks_intro": "Du bist unter den Ersten, die Zugang erhalten. Wir senden dir:",
|
||||
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang zur gesamten Plattform",
|
||||
"email_waitlist_general_perk_2": "Exklusive Launch-Boni",
|
||||
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
|
||||
"email_waitlist_general_outro": "Wir melden uns bald.",
|
||||
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 {app_name} startet bald",
|
||||
|
||||
"email_lead_forward_heading": "Neues Projekt-Lead",
|
||||
"email_lead_forward_subheading": "Ein neues Padel-Projekt passt zu deinen Leistungen.",
|
||||
"email_lead_forward_section_brief": "Projektbeschreibung",
|
||||
"email_lead_forward_section_contact": "Kontakt",
|
||||
"email_lead_forward_lbl_facility": "Anlage",
|
||||
"email_lead_forward_lbl_courts": "Pl\u00e4tze",
|
||||
"email_lead_forward_lbl_location": "Standort",
|
||||
"email_lead_forward_lbl_timeline": "Zeitplan",
|
||||
"email_lead_forward_lbl_phase": "Phase",
|
||||
"email_lead_forward_lbl_services": "Leistungen",
|
||||
"email_lead_forward_lbl_additional": "Zus\u00e4tzlich",
|
||||
"email_lead_forward_lbl_name": "Name",
|
||||
"email_lead_forward_lbl_email": "E-Mail",
|
||||
"email_lead_forward_lbl_phone": "Telefon",
|
||||
"email_lead_forward_lbl_company": "Unternehmen",
|
||||
"email_lead_forward_lbl_role": "Rolle",
|
||||
"email_lead_forward_btn": "Im Lead-Feed ansehen",
|
||||
|
||||
"email_lead_matched_heading": "Ein Anbieter pr\u00fcft dein Projekt",
|
||||
"email_lead_matched_greeting": "Hallo {first_name},",
|
||||
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und wird sich direkt bei dir melden.",
|
||||
"email_lead_matched_context": "Du hast eine Angebotsanfrage f\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
|
||||
"email_lead_matched_btn": "Zum Dashboard",
|
||||
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
|
||||
"email_lead_matched_subject": "Ein Anbieter pr\u00fcft dein Padel-Projekt",
|
||||
|
||||
"email_enquiry_heading": "Neue Anfrage \u00fcber {app_name}",
|
||||
"email_enquiry_body": "Du hast eine neue Verzeichnisanfrage f\u00fcr <strong>{supplier_name}</strong>.",
|
||||
"email_enquiry_lbl_from": "Von",
|
||||
"email_enquiry_lbl_message": "Nachricht",
|
||||
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\">{contact_email}</a>.",
|
||||
"email_enquiry_subject": "Neue Anfrage \u00fcber {app_name}: {contact_name}",
|
||||
|
||||
"email_business_plan_heading": "Dein Businessplan ist fertig",
|
||||
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
|
||||
"email_business_plan_btn": "PDF herunterladen",
|
||||
"email_business_plan_subject": "Dein Padel-Businessplan ist fertig",
|
||||
|
||||
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
|
||||
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast."
|
||||
}
|
||||
|
||||
@@ -1536,5 +1536,83 @@
|
||||
"bp_lbl_debt": "Debt",
|
||||
"bp_lbl_cumulative": "Cumulative",
|
||||
|
||||
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io"
|
||||
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io",
|
||||
|
||||
"email_magic_link_heading": "Sign in to {app_name}",
|
||||
"email_magic_link_body": "Click the button below to sign in. This link expires in {expiry_minutes} minutes.",
|
||||
"email_magic_link_btn": "Sign In",
|
||||
"email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
|
||||
"email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"email_magic_link_subject": "Sign in to {app_name}",
|
||||
|
||||
"email_quote_verify_heading": "Verify your email to get supplier quotes",
|
||||
"email_quote_verify_greeting": "Hi {first_name},",
|
||||
"email_quote_verify_body": "Thanks for requesting quotes{project_desc}. Click the button below to verify your email and activate your quote request. This will also create your {app_name} account so you can track your project.",
|
||||
"email_quote_verify_btn": "Verify & Activate Quote",
|
||||
"email_quote_verify_expires": "This link expires in 60 minutes.",
|
||||
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
|
||||
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"email_quote_verify_subject": "Verify your email to get supplier quotes",
|
||||
|
||||
"email_welcome_heading": "Welcome to {app_name}!",
|
||||
"email_welcome_body": "Thanks for signing up. You're all set to start planning your padel business.",
|
||||
"email_welcome_btn": "Go to Dashboard",
|
||||
"email_welcome_subject": "Welcome to {app_name}",
|
||||
|
||||
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
|
||||
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building the ultimate supplier platform for padel entrepreneurs.",
|
||||
"email_waitlist_supplier_perks": "You'll be among the first to know when we launch. We'll send you early access, exclusive launch pricing, and onboarding support.",
|
||||
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
|
||||
"email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility",
|
||||
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
|
||||
"email_waitlist_supplier_subject": "You're on the list \u2014 {app_name} {plan_name} is launching soon",
|
||||
"email_waitlist_general_heading": "You're on the Waitlist",
|
||||
"email_waitlist_general_body": "Thanks for joining the waitlist. We're preparing to launch the ultimate planning platform for padel entrepreneurs.",
|
||||
"email_waitlist_general_perks_intro": "You'll be among the first to get access when we open. We'll send you:",
|
||||
"email_waitlist_general_perk_1": "Early access to the full platform",
|
||||
"email_waitlist_general_perk_2": "Exclusive launch bonuses",
|
||||
"email_waitlist_general_perk_3": "Priority onboarding and support",
|
||||
"email_waitlist_general_outro": "We'll be in touch soon.",
|
||||
"email_waitlist_general_subject": "You're on the list \u2014 {app_name} is launching soon",
|
||||
|
||||
"email_lead_forward_heading": "New Project Lead",
|
||||
"email_lead_forward_subheading": "A new padel project matches your services.",
|
||||
"email_lead_forward_section_brief": "Project Brief",
|
||||
"email_lead_forward_section_contact": "Contact",
|
||||
"email_lead_forward_lbl_facility": "Facility",
|
||||
"email_lead_forward_lbl_courts": "Courts",
|
||||
"email_lead_forward_lbl_location": "Location",
|
||||
"email_lead_forward_lbl_timeline": "Timeline",
|
||||
"email_lead_forward_lbl_phase": "Phase",
|
||||
"email_lead_forward_lbl_services": "Services",
|
||||
"email_lead_forward_lbl_additional": "Additional",
|
||||
"email_lead_forward_lbl_name": "Name",
|
||||
"email_lead_forward_lbl_email": "Email",
|
||||
"email_lead_forward_lbl_phone": "Phone",
|
||||
"email_lead_forward_lbl_company": "Company",
|
||||
"email_lead_forward_lbl_role": "Role",
|
||||
"email_lead_forward_btn": "View in Lead Feed",
|
||||
|
||||
"email_lead_matched_heading": "A supplier is reviewing your project",
|
||||
"email_lead_matched_greeting": "Hi {first_name},",
|
||||
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and will reach out to you directly.",
|
||||
"email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.",
|
||||
"email_lead_matched_btn": "View Your Dashboard",
|
||||
"email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.",
|
||||
"email_lead_matched_subject": "A supplier is reviewing your padel project",
|
||||
|
||||
"email_enquiry_heading": "New enquiry via {app_name}",
|
||||
"email_enquiry_body": "You have a new directory enquiry for <strong>{supplier_name}</strong>.",
|
||||
"email_enquiry_lbl_from": "From",
|
||||
"email_enquiry_lbl_message": "Message",
|
||||
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\">{contact_email}</a> to respond.",
|
||||
"email_enquiry_subject": "New enquiry via {app_name}: {contact_name}",
|
||||
|
||||
"email_business_plan_heading": "Your Business Plan is Ready",
|
||||
"email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.",
|
||||
"email_business_plan_btn": "Download PDF",
|
||||
"email_business_plan_subject": "Your Padel Business Plan PDF is Ready",
|
||||
|
||||
"email_footer_tagline": "The padel business planning platform",
|
||||
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Planner domain: padel court financial planner + scenario management.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
@@ -44,6 +45,7 @@ COUNTRY_PRESETS = {
|
||||
# SQL Queries
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def count_scenarios(user_id: int) -> int:
|
||||
row = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
|
||||
@@ -70,6 +72,7 @@ async def get_scenarios(user_id: int) -> list[dict]:
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def form_to_state(form) -> dict:
|
||||
"""Convert Quart ImmutableMultiDict form data to state dict."""
|
||||
data: dict = {}
|
||||
@@ -88,16 +91,37 @@ def form_to_state(form) -> dict:
|
||||
def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"""Add display-only derived fields to calc result dict (mutates d in-place)."""
|
||||
t = get_translations(lang)
|
||||
month_keys = ["jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec"]
|
||||
month_keys = [
|
||||
"jan",
|
||||
"feb",
|
||||
"mar",
|
||||
"apr",
|
||||
"may",
|
||||
"jun",
|
||||
"jul",
|
||||
"aug",
|
||||
"sep",
|
||||
"oct",
|
||||
"nov",
|
||||
"dec",
|
||||
]
|
||||
|
||||
d["irr_ok"] = math.isfinite(d.get("irr", 0))
|
||||
|
||||
# Chart data — full Chart.js 4.x config objects, embedded as JSON in partials
|
||||
_PALETTE = [
|
||||
"#1D4ED8", "#16A34A", "#D97706", "#EF4444", "#8B5CF6",
|
||||
"#EC4899", "#06B6D4", "#84CC16", "#F97316", "#475569",
|
||||
"#0EA5E9", "#A78BFA",
|
||||
"#1D4ED8",
|
||||
"#16A34A",
|
||||
"#D97706",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#EC4899",
|
||||
"#06B6D4",
|
||||
"#84CC16",
|
||||
"#F97316",
|
||||
"#475569",
|
||||
"#0EA5E9",
|
||||
"#A78BFA",
|
||||
]
|
||||
_cap_items = sorted(
|
||||
[i for i in d["capexItems"] if i["amount"] > 0],
|
||||
@@ -108,17 +132,26 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"type": "doughnut",
|
||||
"data": {
|
||||
"labels": [i["name"] for i in _cap_items],
|
||||
"datasets": [{
|
||||
"data": [i["amount"] for i in _cap_items],
|
||||
"backgroundColor": [_PALETTE[i % len(_PALETTE)] for i in range(len(_cap_items))],
|
||||
"borderWidth": 0,
|
||||
}],
|
||||
"datasets": [
|
||||
{
|
||||
"data": [i["amount"] for i in _cap_items],
|
||||
"backgroundColor": [
|
||||
_PALETTE[i % len(_PALETTE)] for i in range(len(_cap_items))
|
||||
],
|
||||
"borderWidth": 0,
|
||||
}
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"cutout": "60%",
|
||||
"plugins": {"legend": {"position": "right", "labels": {"boxWidth": 10, "font": {"size": 12}, "padding": 8}}},
|
||||
"plugins": {
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"labels": {"boxWidth": 10, "font": {"size": 12}, "padding": 8},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -153,35 +186,60 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": True, "labels": {"boxWidth": 12, "font": {"size": 10}}}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
|
||||
"plugins": {
|
||||
"legend": {"display": True, "labels": {"boxWidth": 12, "font": {"size": 10}}}
|
||||
},
|
||||
"scales": {
|
||||
"y": {"ticks": {"font": {"size": 10}}},
|
||||
"x": {"ticks": {"font": {"size": 9}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_pl_values = [
|
||||
round(d["courtRevMonth"]),
|
||||
-round(d["feeDeduction"]),
|
||||
round(d["racketRev"] + d["ballMargin"] + d["membershipRev"]
|
||||
+ d["fbRev"] + d["coachingRev"] + d["retailRev"]),
|
||||
round(
|
||||
d["racketRev"]
|
||||
+ d["ballMargin"]
|
||||
+ d["membershipRev"]
|
||||
+ d["fbRev"]
|
||||
+ d["coachingRev"]
|
||||
+ d["retailRev"]
|
||||
),
|
||||
-round(d["opex"]),
|
||||
-round(d["monthlyPayment"]),
|
||||
]
|
||||
d["pl_chart"] = {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"], t["chart_opex"], t["chart_debt"]],
|
||||
"datasets": [{
|
||||
"data": _pl_values,
|
||||
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _pl_values],
|
||||
"borderRadius": 4,
|
||||
}],
|
||||
"labels": [
|
||||
t["chart_court_rev"],
|
||||
t["chart_fees"],
|
||||
t["chart_ancillary"],
|
||||
t["chart_opex"],
|
||||
t["chart_debt"],
|
||||
],
|
||||
"datasets": [
|
||||
{
|
||||
"data": _pl_values,
|
||||
"backgroundColor": [
|
||||
"rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)"
|
||||
for v in _pl_values
|
||||
],
|
||||
"borderRadius": 4,
|
||||
}
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"indexAxis": "y",
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"x": {"ticks": {"font": {"size": 9}}}, "y": {"ticks": {"font": {"size": 10}}}},
|
||||
"scales": {
|
||||
"x": {"ticks": {"font": {"size": 9}}},
|
||||
"y": {"ticks": {"font": {"size": 10}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -190,17 +248,25 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]],
|
||||
"datasets": [{
|
||||
"data": _cf_values,
|
||||
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _cf_values],
|
||||
"borderRadius": 2,
|
||||
}],
|
||||
"datasets": [
|
||||
{
|
||||
"data": _cf_values,
|
||||
"backgroundColor": [
|
||||
"rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)"
|
||||
for v in _cf_values
|
||||
],
|
||||
"borderRadius": 2,
|
||||
}
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
|
||||
"scales": {
|
||||
"y": {"ticks": {"font": {"size": 10}}},
|
||||
"x": {"ticks": {"font": {"size": 9}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -208,21 +274,26 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" for m in d["months"]],
|
||||
"datasets": [{
|
||||
"data": [round(m["cum"]) for m in d["months"]],
|
||||
"borderColor": "#1D4ED8",
|
||||
"backgroundColor": "rgba(29,78,216,0.08)",
|
||||
"fill": True,
|
||||
"tension": 0.3,
|
||||
"pointRadius": 0,
|
||||
"borderWidth": 2,
|
||||
}],
|
||||
"datasets": [
|
||||
{
|
||||
"data": [round(m["cum"]) for m in d["months"]],
|
||||
"borderColor": "#1D4ED8",
|
||||
"backgroundColor": "rgba(29,78,216,0.08)",
|
||||
"fill": True,
|
||||
"tension": 0.3,
|
||||
"pointRadius": 0,
|
||||
"borderWidth": 2,
|
||||
}
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
|
||||
"scales": {
|
||||
"y": {"ticks": {"font": {"size": 10}}},
|
||||
"x": {"ticks": {"font": {"size": 9}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -231,17 +302,25 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [f"Y{x['year']}" for x in d["dscr"]],
|
||||
"datasets": [{
|
||||
"data": _dscr_values,
|
||||
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 1.2 else "rgba(239,68,68,0.7)" for v in _dscr_values],
|
||||
"borderRadius": 4,
|
||||
}],
|
||||
"datasets": [
|
||||
{
|
||||
"data": _dscr_values,
|
||||
"backgroundColor": [
|
||||
"rgba(22,163,74,0.7)" if v >= 1.2 else "rgba(239,68,68,0.7)"
|
||||
for v in _dscr_values
|
||||
],
|
||||
"borderRadius": 4,
|
||||
}
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
|
||||
"scales": {
|
||||
"y": {"ticks": {"font": {"size": 10}}, "min": 0},
|
||||
"x": {"ticks": {"font": {"size": 10}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -249,17 +328,22 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [t[f"month_{k}"] for k in month_keys],
|
||||
"datasets": [{
|
||||
"data": [v * 100 for v in s["season"]],
|
||||
"backgroundColor": "rgba(29,78,216,0.6)",
|
||||
"borderRadius": 3,
|
||||
}],
|
||||
"datasets": [
|
||||
{
|
||||
"data": [v * 100 for v in s["season"]],
|
||||
"backgroundColor": "rgba(29,78,216,0.6)",
|
||||
"borderRadius": 3,
|
||||
}
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
|
||||
"scales": {
|
||||
"y": {"ticks": {"font": {"size": 10}}, "min": 0},
|
||||
"x": {"ticks": {"font": {"size": 10}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -273,25 +357,35 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
)
|
||||
utils = [15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70]
|
||||
ancillary_per_court = (
|
||||
s["membershipRevPerCourt"] + s["fbRevPerCourt"]
|
||||
+ s["coachingRevPerCourt"] + s["retailRevPerCourt"]
|
||||
s["membershipRevPerCourt"]
|
||||
+ s["fbRevPerCourt"]
|
||||
+ s["coachingRevPerCourt"]
|
||||
+ s["retailRevPerCourt"]
|
||||
)
|
||||
sens_rows = []
|
||||
for u in utils:
|
||||
booked = d["availHoursMonth"] * (u / 100)
|
||||
rev = booked * rev_per_hr + d["totalCourts"] * ancillary_per_court * (u / max(s["utilTarget"], 1))
|
||||
rev = booked * rev_per_hr + d["totalCourts"] * ancillary_per_court * (
|
||||
u / max(s["utilTarget"], 1)
|
||||
)
|
||||
ncf = rev - d["opex"] - d["monthlyPayment"]
|
||||
annual = ncf * (12 if is_in else 6)
|
||||
ebitda = rev - d["opex"]
|
||||
dscr = (ebitda * (12 if is_in else 6)) / d["annualDebtService"] if d["annualDebtService"] > 0 else 999
|
||||
sens_rows.append({
|
||||
"util": u,
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"annual": round(annual),
|
||||
"dscr": min(dscr, 99),
|
||||
"is_target": u == s["utilTarget"],
|
||||
})
|
||||
dscr = (
|
||||
(ebitda * (12 if is_in else 6)) / d["annualDebtService"]
|
||||
if d["annualDebtService"] > 0
|
||||
else 999
|
||||
)
|
||||
sens_rows.append(
|
||||
{
|
||||
"util": u,
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"annual": round(annual),
|
||||
"dscr": min(dscr, 99),
|
||||
"is_target": u == s["utilTarget"],
|
||||
}
|
||||
)
|
||||
d["sens_rows"] = sens_rows
|
||||
|
||||
prices = [-20, -10, -5, 0, 5, 10, 15, 20]
|
||||
@@ -301,18 +395,23 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
booked = d["bookedHoursMonth"]
|
||||
rev = (
|
||||
booked * adj_rate * (1 - s["bookingFee"] / 100)
|
||||
+ booked * ((s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"]))
|
||||
+ booked
|
||||
* (
|
||||
(s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
|
||||
)
|
||||
+ d["totalCourts"] * ancillary_per_court
|
||||
)
|
||||
ncf = rev - d["opex"] - d["monthlyPayment"]
|
||||
price_rows.append({
|
||||
"delta": delta,
|
||||
"adj_rate": round(adj_rate),
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"is_base": delta == 0,
|
||||
})
|
||||
price_rows.append(
|
||||
{
|
||||
"delta": delta,
|
||||
"adj_rate": round(adj_rate),
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"is_base": delta == 0,
|
||||
}
|
||||
)
|
||||
d["price_rows"] = price_rows
|
||||
|
||||
|
||||
@@ -320,6 +419,7 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
async def index():
|
||||
scenario_count = 0
|
||||
@@ -420,14 +520,18 @@ async def save_scenario():
|
||||
# Add to Resend nurture audience on first scenario save
|
||||
if is_first_save:
|
||||
from ..core import config as _config
|
||||
|
||||
if _config.RESEND_AUDIENCE_PLANNER and _config.RESEND_API_KEY:
|
||||
try:
|
||||
import resend
|
||||
|
||||
resend.api_key = _config.RESEND_API_KEY
|
||||
resend.Contacts.create({
|
||||
"audience_id": _config.RESEND_AUDIENCE_PLANNER,
|
||||
"email": g.user["email"],
|
||||
})
|
||||
resend.Contacts.create(
|
||||
{
|
||||
"audience_id": _config.RESEND_AUDIENCE_PLANNER,
|
||||
"email": g.user["email"],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
|
||||
|
||||
@@ -445,7 +549,14 @@ async def get_scenario(scenario_id: int):
|
||||
)
|
||||
if not row:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return jsonify({"id": row["id"], "name": row["name"], "state_json": row["state_json"], "location": row["location"]})
|
||||
return jsonify(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"state_json": row["state_json"],
|
||||
"location": row["location"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/scenarios/<int:scenario_id>", methods=["DELETE"])
|
||||
@@ -482,6 +593,7 @@ async def set_default(scenario_id: int):
|
||||
# Business Plan PDF Export
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/export")
|
||||
@login_required
|
||||
@waitlist_gate("export_waitlist.html")
|
||||
@@ -526,17 +638,19 @@ async def export_checkout():
|
||||
if not price_id:
|
||||
return jsonify({"error": "Product not configured. Contact support."}), 500
|
||||
|
||||
return jsonify({
|
||||
"items": [{"priceId": price_id, "quantity": 1}],
|
||||
"customData": {
|
||||
"user_id": str(g.user["id"]),
|
||||
"scenario_id": str(scenario_id),
|
||||
"language": language,
|
||||
},
|
||||
"settings": {
|
||||
"successUrl": f"{config.BASE_URL}/planner/export/success",
|
||||
},
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"items": [{"priceId": price_id, "quantity": 1}],
|
||||
"customData": {
|
||||
"user_id": str(g.user["id"]),
|
||||
"scenario_id": str(scenario_id),
|
||||
"language": language,
|
||||
},
|
||||
"settings": {
|
||||
"successUrl": f"{config.BASE_URL}/planner/export/success",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/export/success")
|
||||
@@ -569,6 +683,7 @@ async def export_download(token: str):
|
||||
|
||||
# Serve the PDF file
|
||||
from pathlib import Path
|
||||
|
||||
file_path = Path(export["file_path"])
|
||||
if not file_path.exists():
|
||||
return jsonify({"error": "PDF file not found."}), 404
|
||||
@@ -587,6 +702,7 @@ async def export_download(token: str):
|
||||
# DuckDB analytics integration — market data for planner pre-fill
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/api/market-data")
|
||||
async def market_data():
|
||||
"""Return per-city planner defaults from DuckDB serving layer.
|
||||
@@ -614,27 +730,26 @@ async def market_data():
|
||||
# Map DuckDB snake_case columns → DEFAULTS camelCase keys.
|
||||
# Only include fields that exist in the row and have non-null values.
|
||||
col_map: dict[str, str] = {
|
||||
"rate_peak": "ratePeak",
|
||||
"rate_off_peak": "rateOffPeak",
|
||||
"court_cost_dbl": "courtCostDbl",
|
||||
"court_cost_sgl": "courtCostSgl",
|
||||
"rent_sqm": "rentSqm",
|
||||
"insurance": "insurance",
|
||||
"electricity": "electricity",
|
||||
"maintenance": "maintenance",
|
||||
"marketing": "marketing",
|
||||
"rate_peak": "ratePeak",
|
||||
"rate_off_peak": "rateOffPeak",
|
||||
"avg_utilisation_pct": "utilTarget",
|
||||
"courts_typical": "dblCourts",
|
||||
}
|
||||
|
||||
overrides: dict = {}
|
||||
for col, key in col_map.items():
|
||||
val = row.get(col)
|
||||
if val is not None:
|
||||
overrides[key] = round(float(val))
|
||||
overrides[key] = round(float(val), 2)
|
||||
|
||||
# Include data quality metadata so frontend can show confidence indicator
|
||||
if row.get("data_confidence") is not None:
|
||||
overrides["_dataConfidence"] = round(float(row["data_confidence"]), 2)
|
||||
if row.get("data_source"):
|
||||
overrides["_dataSource"] = row["data_source"]
|
||||
if row.get("country_code"):
|
||||
overrides["_countryCode"] = row["country_code"]
|
||||
if row.get("price_currency"):
|
||||
overrides["_currency"] = row["price_currency"]
|
||||
|
||||
return jsonify(overrides), 200
|
||||
|
||||
@@ -538,15 +538,17 @@ 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", {
|
||||
"lead_id": lead_id,
|
||||
"supplier_id": supplier["id"],
|
||||
"lang": lang,
|
||||
})
|
||||
|
||||
# Notify entrepreneur on first unlock
|
||||
lead = result["lead"]
|
||||
if lead.get("unlock_count", 0) <= 1:
|
||||
await enqueue("send_lead_matched_notification", {"lead_id": lead_id})
|
||||
await enqueue("send_lead_matched_notification", {"lead_id": lead_id, "lang": lang})
|
||||
|
||||
# Return full details card
|
||||
full_lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||
|
||||
@@ -7,17 +7,30 @@ import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .core import EMAIL_ADDRESSES, config, execute, fetch_all, fetch_one, init_db, send_email
|
||||
from .i18n import get_translations
|
||||
|
||||
# Task handlers registry
|
||||
HANDLERS: dict[str, callable] = {}
|
||||
|
||||
|
||||
def _email_wrap(body: str) -> str:
|
||||
def _t(key: str, lang: str = "en", **kwargs) -> str:
|
||||
"""Look up an email translation key, interpolating {placeholders}.
|
||||
|
||||
Falls back to English if key is missing for the requested language.
|
||||
"""
|
||||
translations = get_translations(lang)
|
||||
raw = translations.get(key, get_translations("en").get(key, key))
|
||||
return raw.format(**kwargs) if kwargs else raw
|
||||
|
||||
|
||||
def _email_wrap(body: str, lang: str = "en") -> str:
|
||||
"""Wrap email body in a branded layout with inline CSS."""
|
||||
year = datetime.utcnow().year
|
||||
tagline = _t("email_footer_tagline", lang)
|
||||
copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME)
|
||||
return f"""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -56,10 +69,10 @@ def _email_wrap(body: str) -> str:
|
||||
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
|
||||
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">{config.APP_NAME}</a>
|
||||
·
|
||||
The padel business planning platform
|
||||
{tagline}
|
||||
</p>
|
||||
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
|
||||
© {year} {config.APP_NAME}. You received this email because you have an account or submitted a request.
|
||||
{copyright_text}
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
@@ -174,6 +187,7 @@ async def handle_send_email(payload: dict) -> None:
|
||||
@task("send_magic_link")
|
||||
async def handle_send_magic_link(payload: dict) -> None:
|
||||
"""Send magic link email."""
|
||||
lang = payload.get("lang", "en")
|
||||
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
|
||||
|
||||
if config.DEBUG:
|
||||
@@ -183,19 +197,18 @@ async def handle_send_magic_link(payload: dict) -> None:
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Sign in to {config.APP_NAME}</h2>'
|
||||
f"<p>Click the button below to sign in. This link expires in "
|
||||
f"{config.MAGIC_LINK_EXPIRY_MINUTES} minutes.</p>"
|
||||
f"{_email_button(link, 'Sign In')}"
|
||||
f'<p style="font-size:13px;color:#94A3B8;">If the button doesn\'t work, copy and paste this URL into your browser:</p>'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=config.MAGIC_LINK_EXPIRY_MINUTES)}</p>'
|
||||
f'{_email_button(link, _t("email_magic_link_btn", lang))}'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">If you didn\'t request this, you can safely ignore this email.</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_ignore", lang)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=payload["email"],
|
||||
subject=f"Sign in to {config.APP_NAME}",
|
||||
html=_email_wrap(body),
|
||||
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
)
|
||||
|
||||
@@ -228,22 +241,20 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
||||
project_desc = f" for your {' '.join(parts)} project"
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Verify your email to get supplier quotes</h2>'
|
||||
f"<p>Hi {first_name},</p>"
|
||||
f"<p>Thanks for requesting quotes{project_desc}. "
|
||||
f"Click the button below to verify your email and activate your quote request. "
|
||||
f"This will also create your {config.APP_NAME} account so you can track your project.</p>"
|
||||
f"{_email_button(link, 'Verify & Activate Quote')}"
|
||||
f'<p style="font-size:13px;color:#94A3B8;">This link expires in 60 minutes.</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">If the button doesn\'t work, copy and paste this URL into your browser:</p>'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_quote_verify_heading", lang)}</h2>'
|
||||
f'<p>{_t("email_quote_verify_greeting", lang, first_name=first_name)}</p>'
|
||||
f'<p>{_t("email_quote_verify_body", lang, project_desc=project_desc, app_name=config.APP_NAME)}</p>'
|
||||
f'{_email_button(link, _t("email_quote_verify_btn", lang))}'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_expires", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_fallback", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">If you didn\'t request this, you can safely ignore this email.</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_ignore", lang)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=payload["email"],
|
||||
subject="Verify your email to get supplier quotes",
|
||||
html=_email_wrap(body),
|
||||
subject=_t("email_quote_verify_subject", lang),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
)
|
||||
|
||||
@@ -251,16 +262,17 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
||||
@task("send_welcome")
|
||||
async def handle_send_welcome(payload: dict) -> None:
|
||||
"""Send welcome email to new user."""
|
||||
lang = payload.get("lang", "en")
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Welcome to {config.APP_NAME}!</h2>'
|
||||
f"<p>Thanks for signing up. You're all set to start planning your padel business.</p>"
|
||||
f'{_email_button(f"{config.BASE_URL}/dashboard", "Go to Dashboard")}'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<p>{_t("email_welcome_body", lang)}</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_welcome_btn", lang))}'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=payload["email"],
|
||||
subject=f"Welcome to {config.APP_NAME}",
|
||||
html=_email_wrap(body),
|
||||
subject=_t("email_welcome_subject", lang, app_name=config.APP_NAME),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
)
|
||||
|
||||
@@ -269,45 +281,40 @@ async def handle_send_welcome(payload: dict) -> None:
|
||||
async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
||||
"""Send waitlist confirmation email."""
|
||||
intent = payload.get("intent", "signup")
|
||||
lang = payload.get("lang", "en")
|
||||
email = payload["email"]
|
||||
|
||||
if intent.startswith("supplier_"):
|
||||
# Supplier waitlist
|
||||
plan_name = intent.replace("supplier_", "").title()
|
||||
subject = f"You're on the list — {config.APP_NAME} {plan_name} is launching soon"
|
||||
subject = _t("email_waitlist_supplier_subject", lang, app_name=config.APP_NAME, plan_name=plan_name)
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">You\'re on the Supplier Waitlist</h2>'
|
||||
f'<p>Thanks for your interest in the <strong>{plan_name}</strong> plan. '
|
||||
f'We\'re building the ultimate supplier platform for padel entrepreneurs.</p>'
|
||||
f'<p>You\'ll be among the first to know when we launch. '
|
||||
f'We\'ll send you early access, exclusive launch pricing, and onboarding support.</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;">In the meantime, explore our free resources:</p>'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
|
||||
f'<p>{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}</p>'
|
||||
f'<p>{_t("email_waitlist_supplier_perks", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_supplier_meanwhile", lang)}</p>'
|
||||
f'<ul style="font-size:13px;color:#64748B;">'
|
||||
f'<li><a href="{config.BASE_URL}/planner">Financial Planning Tool</a> — model your padel facility</li>'
|
||||
f'<li><a href="{config.BASE_URL}/directory">Supplier Directory</a> — browse verified suppliers</li>'
|
||||
f'<li><a href="{config.BASE_URL}/planner">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
|
||||
f'<li><a href="{config.BASE_URL}/directory">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
|
||||
f'</ul>'
|
||||
)
|
||||
else:
|
||||
# Entrepreneur/demand-side waitlist
|
||||
subject = f"You're on the list — {config.APP_NAME} is launching soon"
|
||||
subject = _t("email_waitlist_general_subject", lang, app_name=config.APP_NAME)
|
||||
body = (
|
||||
'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">You\'re on the Waitlist</h2>'
|
||||
'<p>Thanks for joining the waitlist. We\'re preparing to launch the ultimate planning platform '
|
||||
'for padel entrepreneurs.</p>'
|
||||
'<p>You\'ll be among the first to get access when we open. '
|
||||
'We\'ll send you:</p>'
|
||||
'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
|
||||
'<li>Early access to the full platform</li>'
|
||||
'<li>Exclusive launch bonuses</li>'
|
||||
'<li>Priority onboarding and support</li>'
|
||||
'</ul>'
|
||||
'<p style="font-size:13px;color:#64748B;">We\'ll be in touch soon.</p>'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_general_heading", lang)}</h2>'
|
||||
f'<p>{_t("email_waitlist_general_body", lang)}</p>'
|
||||
f'<p>{_t("email_waitlist_general_perks_intro", lang)}</p>'
|
||||
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
|
||||
f'<li>{_t("email_waitlist_general_perk_1", lang)}</li>'
|
||||
f'<li>{_t("email_waitlist_general_perk_2", lang)}</li>'
|
||||
f'<li>{_t("email_waitlist_general_perk_3", lang)}</li>'
|
||||
f'</ul>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_general_outro", lang)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=email,
|
||||
subject=subject,
|
||||
html=_email_wrap(body),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
)
|
||||
|
||||
@@ -331,6 +338,7 @@ async def handle_cleanup_rate_limits(payload: dict) -> None:
|
||||
@task("send_lead_forward_email")
|
||||
async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
"""Send full project brief to supplier who unlocked/was forwarded a lead."""
|
||||
lang = payload.get("lang", "en")
|
||||
lead_id = payload["lead_id"]
|
||||
supplier_id = payload["supplier_id"]
|
||||
|
||||
@@ -346,14 +354,16 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
|
||||
subject = f"[{heat}] New padel project in {country} — {courts} courts, €{budget}"
|
||||
|
||||
t = lambda key: _t(key, lang) # noqa: E731
|
||||
|
||||
brief_rows = [
|
||||
("Facility", f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
|
||||
("Courts", f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
|
||||
("Location", f"{lead['location'] or '-'}, {country}"),
|
||||
("Timeline", f"{lead['timeline'] or '-'} | Budget: €{budget}"),
|
||||
("Phase", f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
|
||||
("Services", lead["services_needed"] or "-"),
|
||||
("Additional", lead["additional_info"] or "-"),
|
||||
(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_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 "-"),
|
||||
]
|
||||
|
||||
brief_html = ""
|
||||
@@ -364,11 +374,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
)
|
||||
|
||||
contact_rows = [
|
||||
("Name", lead["contact_name"] or "-"),
|
||||
("Email", lead["contact_email"] or "-"),
|
||||
("Phone", lead["contact_phone"] or "-"),
|
||||
("Company", lead["contact_company"] or "-"),
|
||||
("Role", lead["stakeholder_type"] or "-"),
|
||||
(t("email_lead_forward_lbl_name"), lead["contact_name"] or "-"),
|
||||
(t("email_lead_forward_lbl_email"), lead["contact_email"] or "-"),
|
||||
(t("email_lead_forward_lbl_phone"), lead["contact_phone"] or "-"),
|
||||
(t("email_lead_forward_lbl_company"), lead["contact_company"] or "-"),
|
||||
(t("email_lead_forward_lbl_role"), lead["stakeholder_type"] or "-"),
|
||||
]
|
||||
|
||||
contact_html = ""
|
||||
@@ -379,13 +389,13 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
)
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">New Project Lead</h2>'
|
||||
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">A new padel project matches your services.</p>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">Project Brief</h3>'
|
||||
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{t("email_lead_forward_heading")}</h2>'
|
||||
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">{t("email_lead_forward_subheading")}</p>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_brief")}</h3>'
|
||||
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{brief_html}</table>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">Contact</h3>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_contact")}</h3>'
|
||||
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{contact_html}</table>'
|
||||
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View in Lead Feed")}'
|
||||
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", t("email_lead_forward_btn"))}'
|
||||
)
|
||||
|
||||
# Send to supplier contact email or general contact
|
||||
@@ -397,12 +407,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
await send_email(
|
||||
to=to_email,
|
||||
subject=subject,
|
||||
html=_email_wrap(body),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["leads"],
|
||||
)
|
||||
|
||||
# Update email_sent_at on lead_forward
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"UPDATE lead_forwards SET email_sent_at = ? WHERE lead_id = ? AND supplier_id = ?",
|
||||
@@ -413,6 +422,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
@task("send_lead_matched_notification")
|
||||
async def handle_send_lead_matched_notification(payload: dict) -> None:
|
||||
"""Notify the entrepreneur that a supplier has been matched to their project."""
|
||||
lang = payload.get("lang", "en")
|
||||
lead_id = payload["lead_id"]
|
||||
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||
if not lead or not lead["contact_email"]:
|
||||
@@ -421,22 +431,18 @@ async def handle_send_lead_matched_notification(payload: dict) -> None:
|
||||
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">A supplier is reviewing your project</h2>'
|
||||
f'<p>Hi {first_name},</p>'
|
||||
f'<p>Great news — a verified supplier has been matched with your padel project. '
|
||||
f'They have your project brief and will reach out to you directly.</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;">You submitted a quote request for a '
|
||||
f'{lead["facility_type"] or "padel"} facility with {lead["court_count"] or "?"} courts '
|
||||
f'in {lead["country"] or "your area"}.</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/dashboard", "View Your Dashboard")}'
|
||||
f'<p style="font-size:12px;color:#94A3B8;">You\'ll receive this notification each time '
|
||||
f'a new supplier unlocks your project details.</p>'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_lead_matched_heading", lang)}</h2>'
|
||||
f'<p>{_t("email_lead_matched_greeting", lang, first_name=first_name)}</p>'
|
||||
f'<p>{_t("email_lead_matched_body", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_lead_matched_context", lang, facility_type=lead["facility_type"] or "padel", court_count=lead["court_count"] or "?", country=lead["country"] or "your area")}</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_lead_matched_btn", lang))}'
|
||||
f'<p style="font-size:12px;color:#94A3B8;">{_t("email_lead_matched_note", lang)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=lead["contact_email"],
|
||||
subject="A supplier is reviewing your padel project",
|
||||
html=_email_wrap(body),
|
||||
subject=_t("email_lead_matched_subject", lang),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["leads"],
|
||||
)
|
||||
|
||||
@@ -444,6 +450,7 @@ 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."""
|
||||
lang = payload.get("lang", "en")
|
||||
supplier_email = payload.get("supplier_email", "")
|
||||
if not supplier_email:
|
||||
return
|
||||
@@ -455,22 +462,21 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
|
||||
|
||||
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'{_t("email_enquiry_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</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'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</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'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">{_t("email_enquiry_lbl_message", lang)}</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>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=supplier_email,
|
||||
subject=f"New enquiry via {config.APP_NAME}: {contact_name}",
|
||||
html=_email_wrap(body),
|
||||
subject=_t("email_enquiry_subject", lang, app_name=config.APP_NAME, contact_name=contact_name),
|
||||
html=_email_wrap(body, lang),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
)
|
||||
|
||||
@@ -533,14 +539,14 @@ async def handle_generate_business_plan(payload: dict) -> None:
|
||||
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||
if user:
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Your Business Plan is Ready</h2>'
|
||||
f"<p>Your padel business plan PDF has been generated and is ready for download.</p>"
|
||||
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", "Download PDF")}'
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_business_plan_heading", language)}</h2>'
|
||||
f'<p>{_t("email_business_plan_body", language)}</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}'
|
||||
)
|
||||
await send_email(
|
||||
to=user["email"],
|
||||
subject="Your Padel Business Plan PDF is Ready",
|
||||
html=_email_wrap(body),
|
||||
subject=_t("email_business_plan_subject", language),
|
||||
html=_email_wrap(body, language),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user