From c31d4a71a0be079618282267af29516cdb83a4e9 Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 25 Feb 2026 12:10:55 +0100 Subject: [PATCH] =?UTF-8?q?feat(emails):=20subtask=204=20=E2=80=94=204=20c?= =?UTF-8?q?omplex=20templates=20(lead=5Fforward,=20match=5Fnotify,=20diges?= =?UTF-8?q?t,=20business=5Fplan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lead_forward.html (brief table + contact table + optional CTA token link) - Add lead_match_notify.html (new matching lead alert with heat badge) - Add weekly_digest.html (leads table with Jinja2 for loop) - Add business_plan.html (PDF ready notification with download CTA) - Refactor 4 handlers in worker.py: send_lead_forward_email, notify_matching_suppliers, send_weekly_lead_digest, generate_business_plan Co-Authored-By: Claude Sonnet 4.6 --- .../templates/emails/business_plan.html | 11 ++ .../templates/emails/lead_forward.html | 53 ++++++ .../templates/emails/lead_match_notify.html | 30 ++++ .../templates/emails/weekly_digest.html | 33 ++++ web/src/padelnomics/worker.py | 160 +++++------------- 5 files changed, 172 insertions(+), 115 deletions(-) create mode 100644 web/src/padelnomics/templates/emails/business_plan.html create mode 100644 web/src/padelnomics/templates/emails/lead_forward.html create mode 100644 web/src/padelnomics/templates/emails/lead_match_notify.html create mode 100644 web/src/padelnomics/templates/emails/weekly_digest.html diff --git a/web/src/padelnomics/templates/emails/business_plan.html b/web/src/padelnomics/templates/emails/business_plan.html new file mode 100644 index 0000000..1443d87 --- /dev/null +++ b/web/src/padelnomics/templates/emails/business_plan.html @@ -0,0 +1,11 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button %} + +{% block body %} +

{{ t.email_business_plan_heading }}

+
+

{{ t.email_business_plan_body }}

+

{{ t.email_business_plan_includes }}

+{{ email_button(download_url, t.email_business_plan_btn) }} +

{{ t.email_business_plan_quote_cta | tformat(quote_url=quote_url) }}

+{% endblock %} diff --git a/web/src/padelnomics/templates/emails/lead_forward.html b/web/src/padelnomics/templates/emails/lead_forward.html new file mode 100644 index 0000000..fdac780 --- /dev/null +++ b/web/src/padelnomics/templates/emails/lead_forward.html @@ -0,0 +1,53 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button, heat_badge, section_heading %} + +{% block body %} +{# Yellow urgency banner #} +

{{ t.email_lead_forward_urgency }}

+ +

{{ t.email_lead_forward_heading }} {{ heat_badge(heat) }}

+
+ +{{ section_heading(t.email_lead_forward_section_brief) }} + + {% for label, value in brief_rows %} + + + + + {% endfor %} +
{{ label }}{{ value }}
+ +{{ section_heading(t.email_lead_forward_section_contact) }} + + + + + + + + + + + + + + + + + + + + + +
{{ t.email_lead_forward_lbl_name }}{{ contact_name }}
{{ t.email_lead_forward_lbl_email }}{{ contact_email }}
{{ t.email_lead_forward_lbl_phone }}{{ contact_phone }}
{{ t.email_lead_forward_lbl_company }}{{ contact_company }}
{{ t.email_lead_forward_lbl_role }}{{ stakeholder_type }}
+ +{{ email_button(base_url ~ "/suppliers/leads", t.email_lead_forward_btn) }} +

{{ t.email_lead_forward_reply_direct | tformat(contact_email=contact_email) }}

+ +{%- if cta_url %} +

+ ✓ Mark as contacted +

+{%- endif %} +{% endblock %} diff --git a/web/src/padelnomics/templates/emails/lead_match_notify.html b/web/src/padelnomics/templates/emails/lead_match_notify.html new file mode 100644 index 0000000..918625d --- /dev/null +++ b/web/src/padelnomics/templates/emails/lead_match_notify.html @@ -0,0 +1,30 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button, heat_badge %} + +{% block body %} +

New [{{ heat }}] lead in {{ country }} {{ heat_badge(heat) }}

+
+

A new project brief has been submitted that matches your service area.

+ + + + + + + + + + + + + + + + + + +
Facility{{ facility_type }}
Courts{{ courts }}
Country{{ country }}
Timeline{{ timeline or "-" }}
+ +

Contact details are available after unlocking. Credits required: {{ credit_cost }}.

+{{ email_button(base_url ~ "/suppliers/leads", "View lead feed") }} +{% endblock %} diff --git a/web/src/padelnomics/templates/emails/weekly_digest.html b/web/src/padelnomics/templates/emails/weekly_digest.html new file mode 100644 index 0000000..3f74930 --- /dev/null +++ b/web/src/padelnomics/templates/emails/weekly_digest.html @@ -0,0 +1,33 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button, heat_badge_sm %} + +{% block body %} +

+ Your weekly lead digest — {{ leads | length }} new {{ "lead" if leads | length == 1 else "leads" }} +

+
+

New matching leads in your service area this week:

+ + + + + + + + + + + {% for lead in leads %} + + + + + + {% endfor %} + +
ProjectCountryTimeline
+ {{ heat_badge_sm(lead.heat | upper) }} {{ lead.facility_type or "Padel" }}, {{ lead.court_count or "?" }} courts + {{ lead.country or "-" }}{{ lead.timeline or "-" }}
+ +{{ email_button(base_url ~ "/suppliers/leads", "Unlock leads →") }} +{% endblock %} diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index f9db18e..76e48bd 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -384,15 +384,6 @@ async def handle_send_lead_forward_email(payload: dict) -> None: subject = f"[{heat}] New padel project in {country} \u2014 {courts} courts, \u20ac{budget}" - # Heat badge color - heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} - heat_bg = heat_colors.get(heat, "#2563EB") - heat_badge = ( - f'{heat}' - ) - tl = lambda key: _t(key, lang) # noqa: E731 brief_rows = [ @@ -405,50 +396,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None: (tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"), ] - brief_html = "" - for label, value in brief_rows: - brief_html += ( - f'{label}' - f'{value}' - ) - - contact_name = lead["contact_name"] or "-" - contact_phone = lead["contact_phone"] or "-" - - # Contact section with prominent email - contact_html = ( - f'{tl("email_lead_forward_lbl_name")}' - f'{contact_name}' - f'{tl("email_lead_forward_lbl_email")}' - f'{contact_email}' - f'{tl("email_lead_forward_lbl_phone")}' - f'{contact_phone}' - f'{tl("email_lead_forward_lbl_company")}' - f'{lead["contact_company"] or "-"}' - f'{tl("email_lead_forward_lbl_role")}' - f'{lead["stakeholder_type"] or "-"}' - ) - preheader_parts = [f"{facility_type} project"] if timeline: preheader_parts.append(f"{timeline} timeline") preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang)) - body = ( - f'

' - f'{_t("email_lead_forward_urgency", lang)}

' - f'

{tl("email_lead_forward_heading")} {heat_badge}

' - f'
' - f'

{tl("email_lead_forward_section_brief")}

' - f'{brief_html}
' - f'

{tl("email_lead_forward_section_contact")}

' - f'{contact_html}
' - f'{_email_button(f"{config.BASE_URL}/suppliers/leads", tl("email_lead_forward_btn"))}' - f'

' - f'{_t("email_lead_forward_reply_direct", lang, contact_email=contact_email)}

' - ) - # Send to supplier contact email or general contact to_email = supplier.get("contact_email") or supplier.get("contact") or "" if not to_email: @@ -458,16 +410,25 @@ async def handle_send_lead_forward_email(payload: dict) -> None: # Generate one-click "I've contacted this lead" CTA token cta_token = secrets.token_urlsafe(24) cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}" - body += ( - f'

' - f'' - f'✓ Mark as contacted

' + + html = render_email_template( + "emails/lead_forward.html", + lang=lang, + heat=heat, + brief_rows=brief_rows, + contact_name=lead["contact_name"] or "-", + contact_email=contact_email, + contact_phone=lead["contact_phone"] or "-", + contact_company=lead["contact_company"] or "-", + stakeholder_type=lead["stakeholder_type"] or "-", + cta_url=cta_url, + preheader=", ".join(preheader_parts), ) await send_email( to=to_email, subject=subject, - html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)), + html=html, from_addr=EMAIL_ADDRESSES["leads"], email_type="lead_forward", ) @@ -549,30 +510,22 @@ async def handle_notify_matching_suppliers(payload: dict) -> None: if not to_email: continue - body = ( - f'

' - f'New [{heat}] lead in {country}

' - f'
' - f'

A new project brief has been submitted that matches your service area.

' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'
Facility{facility_type}
Courts{courts}
Country{country}
Timeline{timeline or "-"}
' - f'

' - f'Contact details are available after unlocking. Credits required: {lead.get("credit_cost", "?")}.

' - f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View lead feed")}' + notify_html = render_email_template( + "emails/lead_match_notify.html", + lang=lang, + heat=heat, + country=country, + facility_type=facility_type, + courts=courts, + timeline=timeline, + credit_cost=lead.get("credit_cost", "?"), + preheader=f"New matching lead in {country}", ) await send_email( to=to_email, subject=f"[{heat}] New {facility_type} project in {country} — {courts} courts", - html=_email_wrap(body, lang, preheader=f"New matching lead in {country}"), + html=notify_html, from_addr=EMAIL_ADDRESSES["leads"], email_type="lead_match_notify", ) @@ -617,48 +570,27 @@ async def handle_send_weekly_lead_digest(payload: dict) -> None: if not new_leads: continue - lead_rows_html = "" - for ld in new_leads: - heat = (ld["heat_score"] or "cool").upper() - heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} - hc = heat_colors.get(heat, "#2563EB") - badge = ( - f'{heat}' - ) - lead_rows_html += ( - f'' - f'' - f'{badge} {ld["facility_type"] or "Padel"}, {ld["court_count"] or "?"} courts' - f'{ld["country"] or "-"}' - f'{ld["timeline"] or "-"}' - f'' - ) - - body = ( - f'

' - f'Your weekly lead digest — {len(new_leads)} new {"lead" if len(new_leads) == 1 else "leads"}

' - f'
' - f'

New matching leads in your service area this week:

' - f'' - f'' - f'' - f'' - f'' - f'' - f'{lead_rows_html}' - f'
ProjectCountryTimeline
' - f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "Unlock leads →")}' - ) + # Normalise lead dicts for template — heat_score → heat (uppercase) + digest_leads = [ + {**ld, "heat": (ld["heat_score"] or "cool").upper()} + for ld in new_leads + ] area_summary = ", ".join(countries[:3]) if len(countries) > 3: area_summary += f" +{len(countries) - 3}" + digest_html = render_email_template( + "emails/weekly_digest.html", + lang="en", + leads=digest_leads, + preheader=f"{len(new_leads)} new leads matching your service area", + ) + await send_email( to=to_email, subject=f"{len(new_leads)} new padel {'lead' if len(new_leads) == 1 else 'leads'} in {area_summary}", - html=_email_wrap(body, "en", preheader=f"{len(new_leads)} new leads matching your service area"), + html=digest_html, from_addr=EMAIL_ADDRESSES["leads"], email_type="weekly_digest", ) @@ -776,19 +708,17 @@ async def handle_generate_business_plan(payload: dict) -> None: export_token = export_row["token"] user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,)) if user: - body = ( - f'

{_t("email_business_plan_heading", language)}

' - f'
' - f'

{_t("email_business_plan_body", language)}

' - f'

{_t("email_business_plan_includes", language)}

' - f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}' - f'

' - f'{_t("email_business_plan_quote_cta", language, quote_url=f"{config.BASE_URL}/{language}/leads/quote")}

' + bp_html = render_email_template( + "emails/business_plan.html", + lang=language, + download_url=f"{config.BASE_URL}/planner/export/{export_token}", + quote_url=f"{config.BASE_URL}/{language}/leads/quote", + preheader=_t("email_business_plan_preheader", language), ) await send_email( to=user["email"], subject=_t("email_business_plan_subject", language), - html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)), + html=bp_html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="business_plan", )