feat(email-templates): Jinja2 template system + admin gallery + compose preview
Converts all 11 transactional emails from inline f-string HTML in worker.py to standalone Jinja2 templates. Adds an admin gallery for rendered previews and a live HTMX preview pane on the compose page. Changes: - email_templates.py: standalone render_email_template() + EMAIL_TEMPLATE_REGISTRY - templates/emails/_base.html + _macros.html: branded shell + reusable macros - 11 email templates: magic_link, quote_verification, welcome, waitlist_supplier, waitlist_general, lead_matched, lead_forward, lead_match_notify, weekly_digest, business_plan, admin_compose - worker.py: all 10 handlers updated; _email_wrap/_email_button removed - admin/routes.py: gallery routes + compose_preview endpoint - admin gallery: email_gallery.html + email_gallery_preview.html - email_compose.html: two-column layout with HTMX live preview - base_admin.html: Gallery sidebar link - 50 new tests (test_email_templates.py) - CHANGELOG.md + PROJECT.md updated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Email template system** — all 11 transactional emails migrated from inline f-string HTML in `worker.py` to Jinja2 templates:
|
||||||
|
- **Standalone renderer** (`email_templates.py`) — `render_email_template()` uses a module-level `jinja2.Environment` with `autoescape=True`, works outside Quart request context (worker process); `tformat` filter mirrors the one in `app.py`
|
||||||
|
- **`_base.html`** — branded shell (dark header, 3px blue accent, white card body, footer with tagline + copyright); replaces the old `_email_wrap()` helper
|
||||||
|
- **`_macros.html`** — reusable Jinja2 macros: `email_button`, `heat_badge`, `heat_badge_sm`, `section_heading`, `info_box`
|
||||||
|
- **11 email templates**: `magic_link`, `quote_verification`, `welcome`, `waitlist_supplier`, `waitlist_general`, `lead_matched`, `lead_forward`, `lead_match_notify`, `weekly_digest`, `business_plan`, `admin_compose`
|
||||||
|
- **`EMAIL_TEMPLATE_REGISTRY`** — dict mapping slug → `{template, label, description, email_type, sample_data}` with realistic sample data callables for each template
|
||||||
|
- **Admin email gallery** (`/admin/emails/gallery`) — card grid of all email types; preview page with EN/DE language toggle renders each template in a sandboxed iframe (`srcdoc`); "View in sent log →" cross-link; gallery link added to admin sidebar
|
||||||
|
- **Compose live preview** — two-column compose layout: form on the left, HTMX-powered preview iframe on the right; `hx-trigger="input delay:500ms"` on the textarea; `POST /admin/emails/compose/preview` endpoint supports plain body or branded wrapper via `wrap` checkbox
|
||||||
|
- 50 new tests covering all template renders (EN + DE), registry structure, gallery routes (access control, list, preview, lang fallback), and compose preview endpoint
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `_email_wrap()` and `_email_button()` helper functions removed from `worker.py` — replaced by templates
|
||||||
|
|
||||||
- **Marketplace admin dashboard** (`/admin/marketplace`) — single-screen health view for the two-sided market:
|
- **Marketplace admin dashboard** (`/admin/marketplace`) — single-screen health view for the two-sided market:
|
||||||
- **Lead funnel** — total / verified-new (ready to unlock) / unlocked / won / conversion rate
|
- **Lead funnel** — total / verified-new (ready to unlock) / unlocked / won / conversion rate
|
||||||
- **Credit economy** — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate
|
- **Credit economy** — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate
|
||||||
|
|||||||
@@ -107,6 +107,8 @@
|
|||||||
- [x] Task queue management (list, retry, delete)
|
- [x] Task queue management (list, retry, delete)
|
||||||
- [x] Lead funnel stats on admin dashboard
|
- [x] Lead funnel stats on admin dashboard
|
||||||
- [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks
|
- [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks
|
||||||
|
- [x] **Email template system** — 11 transactional emails as Jinja2 templates (`emails/*.html`); standalone `render_email_template()` renderer works in worker + admin; `_base.html` + `_macros.html` shared shell; `EMAIL_TEMPLATE_REGISTRY` with sample data for gallery previews; `_email_wrap()` / `_email_button()` helpers removed
|
||||||
|
- [x] **Admin email gallery** (`/admin/emails/gallery`) — card grid of all templates, EN/DE preview in sandboxed iframe, "View in sent log" cross-link; compose page now has HTMX live preview pane
|
||||||
- [x] **pSEO Engine tab** (`/admin/pseo`) — content gap detection, data freshness signals, article health checks (hreflang orphans, missing build files, broken scenario refs), generation job monitoring with live progress bars
|
- [x] **pSEO Engine tab** (`/admin/pseo`) — content gap detection, data freshness signals, article health checks (hreflang orphans, missing build files, broken scenario refs), generation job monitoring with live progress bars
|
||||||
- [x] **Marketplace admin dashboard** (`/admin/marketplace`) — lead funnel, credit economy, supplier engagement, live activity stream, inline feature flag toggles
|
- [x] **Marketplace admin dashboard** (`/admin/marketplace`) — lead funnel, credit economy, supplier engagement, live activity stream, inline feature flag toggles
|
||||||
- [x] **Lead matching notifications** — `notify_matching_suppliers` task on quote verification + `send_weekly_lead_digest` every Monday; one-click CTA token in forward emails
|
- [x] **Lead matching notifications** — `notify_matching_suppliers` task on quote verification + `send_weekly_lead_digest` every Monday; one-click CTA token in forward emails
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from ..core import (
|
|||||||
utcnow,
|
utcnow,
|
||||||
utcnow_iso,
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
|
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1248,6 +1249,45 @@ async def emails():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/gallery")
|
||||||
|
@role_required("admin")
|
||||||
|
async def email_gallery():
|
||||||
|
"""Gallery of all email template types with sample previews."""
|
||||||
|
return await render_template(
|
||||||
|
"admin/email_gallery.html",
|
||||||
|
registry=EMAIL_TEMPLATE_REGISTRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/gallery/<slug>")
|
||||||
|
@role_required("admin")
|
||||||
|
async def email_gallery_preview(slug: str):
|
||||||
|
"""Rendered preview of a single email template with sample data."""
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY.get(slug)
|
||||||
|
if not entry:
|
||||||
|
await flash(f"Unknown email template: {slug!r}", "error")
|
||||||
|
return redirect(url_for("admin.email_gallery"))
|
||||||
|
|
||||||
|
lang = request.args.get("lang", "en")
|
||||||
|
if lang not in ("en", "de"):
|
||||||
|
lang = "en"
|
||||||
|
|
||||||
|
try:
|
||||||
|
sample = entry["sample_data"](lang)
|
||||||
|
rendered_html = render_email_template(entry["template"], lang=lang, **sample)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("email_gallery_preview: render failed for %s (lang=%s)", slug, lang)
|
||||||
|
rendered_html = "<p style='padding:2rem;color:#DC2626;'>Render error — see logs.</p>"
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/email_gallery_preview.html",
|
||||||
|
slug=slug,
|
||||||
|
entry=entry,
|
||||||
|
lang=lang,
|
||||||
|
rendered_html=rendered_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/emails/results")
|
@bp.route("/emails/results")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def email_results():
|
async def email_results():
|
||||||
@@ -1398,10 +1438,16 @@ async def email_compose():
|
|||||||
email_addresses=EMAIL_ADDRESSES,
|
email_addresses=EMAIL_ADDRESSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||||
if wrap:
|
if wrap:
|
||||||
from ..worker import _email_wrap
|
html = render_email_template(
|
||||||
html = _email_wrap(html)
|
"emails/admin_compose.html",
|
||||||
|
lang="en",
|
||||||
|
body_html=body_html,
|
||||||
|
preheader="",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = body_html
|
||||||
|
|
||||||
result = await send_email(
|
result = await send_email(
|
||||||
to=to, subject=subject, html=html,
|
to=to, subject=subject, html=html,
|
||||||
@@ -1424,6 +1470,36 @@ async def email_compose():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/compose/preview", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
async def compose_preview():
|
||||||
|
"""HTMX endpoint: render live preview for compose textarea (no CSRF — read-only)."""
|
||||||
|
form = await request.form
|
||||||
|
body = form.get("body", "").strip()
|
||||||
|
wrap = form.get("wrap", "") == "1"
|
||||||
|
|
||||||
|
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>" if body else ""
|
||||||
|
|
||||||
|
if wrap and body_html:
|
||||||
|
try:
|
||||||
|
rendered_html = render_email_template(
|
||||||
|
"emails/admin_compose.html",
|
||||||
|
lang="en",
|
||||||
|
body_html=body_html,
|
||||||
|
preheader="",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("compose_preview: template render failed")
|
||||||
|
rendered_html = body_html
|
||||||
|
else:
|
||||||
|
rendered_html = body_html
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/email_preview_frame.html",
|
||||||
|
rendered_html=rendered_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Audiences ---
|
# --- Audiences ---
|
||||||
|
|
||||||
@bp.route("/emails/audiences")
|
@bp.route("/emails/audiences")
|
||||||
|
|||||||
@@ -118,6 +118,10 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||||
Compose
|
Compose
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('admin.email_gallery') }}" class="{% if admin_page == 'gallery' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z"/></svg>
|
||||||
|
Gallery
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
|
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
||||||
Audiences
|
Audiences
|
||||||
|
|||||||
@@ -2,51 +2,91 @@
|
|||||||
{% set admin_page = "compose" %}
|
{% set admin_page = "compose" %}
|
||||||
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.compose-layout { display: grid; grid-template-columns: 480px 1fr; gap: 1.5rem; align-items: start; }
|
||||||
|
@media (max-width: 1100px) { .compose-layout { grid-template-columns: 1fr; } }
|
||||||
|
.preview-panel { position: sticky; top: 1rem; }
|
||||||
|
.preview-label { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||||
|
#preview-pane { min-height: 400px; background: #F1F5F9; border-radius: 8px; }
|
||||||
|
#preview-pane.loading { opacity: 0.5; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
||||||
<h1 class="text-2xl mt-1">Compose Email</h1>
|
<h1 class="text-2xl mt-1">Compose Email</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card" style="padding:1.5rem;max-width:640px">
|
<div class="compose-layout">
|
||||||
<form method="post" action="{{ url_for('admin.email_compose') }}">
|
{# ── Left: form ────────────────────────────────────── #}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<div>
|
||||||
|
<div class="card" style="padding:1.5rem;">
|
||||||
|
<form id="compose-form" method="post" action="{{ url_for('admin.email_compose') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">From</label>
|
<label class="text-xs font-semibold text-slate block mb-1">From</label>
|
||||||
<select name="from_addr" class="form-input">
|
<select name="from_addr" class="form-input">
|
||||||
{% for key, addr in email_addresses.items() %}
|
{% for key, addr in email_addresses.items() %}
|
||||||
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
|
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">To</label>
|
<label class="text-xs font-semibold text-slate block mb-1">To</label>
|
||||||
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
|
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
|
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
|
||||||
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
|
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
|
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
|
||||||
<textarea name="body" rows="12" class="form-input" placeholder="Plain text (line breaks become <br>)" required>{{ data.get('body', '') }}</textarea>
|
<textarea
|
||||||
</div>
|
name="body" rows="14" class="form-input"
|
||||||
|
placeholder="Plain text (line breaks become <br>)"
|
||||||
|
required
|
||||||
|
hx-post="{{ url_for('admin.compose_preview') }}"
|
||||||
|
hx-trigger="input delay:500ms, change"
|
||||||
|
hx-target="#preview-pane"
|
||||||
|
hx-include="#compose-form [name='wrap']"
|
||||||
|
hx-indicator="#preview-pane"
|
||||||
|
>{{ data.get('body', '') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" name="wrap" value="1" checked>
|
<input
|
||||||
Wrap in branded email template
|
type="checkbox" name="wrap" value="1"
|
||||||
</label>
|
{% if data.get('wrap', True) %}checked{% endif %}
|
||||||
</div>
|
hx-post="{{ url_for('admin.compose_preview') }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#preview-pane"
|
||||||
|
hx-include="#compose-form textarea[name='body']"
|
||||||
|
>
|
||||||
|
Wrap in branded email template
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="submit" class="btn">Send Email</button>
|
<button type="submit" class="btn">Send Email</button>
|
||||||
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
|
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
{# ── Right: live preview panel ─────────────────────── #}
|
||||||
|
<div class="preview-panel">
|
||||||
|
<div class="preview-label">Live preview</div>
|
||||||
|
<div id="preview-pane">
|
||||||
|
<p style="padding:2rem;font-size:0.875rem;color:#94A3B8;text-align:center;">Start typing to see a preview…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
81
web/src/padelnomics/admin/templates/admin/email_gallery.html
Normal file
81
web/src/padelnomics/admin/templates/admin/email_gallery.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "gallery" %}
|
||||||
|
{% block title %}Email Gallery - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) { .gallery-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 720px) { .gallery-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.gallery-card {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gallery-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: #1D4ED8;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: bottom;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.gallery-card:hover {
|
||||||
|
border-color: #BFDBFE;
|
||||||
|
box-shadow: 0 4px 16px rgba(29,78,216,0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.gallery-card:hover::before { transform: scaleY(1); }
|
||||||
|
|
||||||
|
.gallery-card__icon {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
background: #EFF6FF;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.gallery-card__icon svg { width: 18px; height: 18px; color: #1D4ED8; }
|
||||||
|
.gallery-card__label { font-size: 0.9375rem; font-weight: 600; color: #0F172A; margin-bottom: 0.25rem; }
|
||||||
|
.gallery-card__desc { font-size: 0.8125rem; color: #64748B; line-height: 1.5; margin-bottom: 0.875rem; }
|
||||||
|
.gallery-card__cta { font-size: 0.8125rem; font-weight: 500; color: #1D4ED8; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="mb-6">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<h1 class="text-2xl">Email Gallery</h1>
|
||||||
|
<span style="font-size:0.8125rem;color:#94A3B8;">{{ registry | length }} template{{ 's' if registry | length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:0.25rem;font-size:0.875rem;color:#64748B;">Rendered previews of all transactional email templates with sample data.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="gallery-grid">
|
||||||
|
{% for slug, entry in registry.items() %}
|
||||||
|
<a class="gallery-card" href="{{ url_for('admin.email_gallery_preview', slug=slug) }}">
|
||||||
|
<div class="gallery-card__icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="gallery-card__label">{{ entry.label }}</div>
|
||||||
|
<div class="gallery-card__desc">{{ entry.description }}</div>
|
||||||
|
<div class="gallery-card__cta">Preview →</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "gallery" %}
|
||||||
|
{% block title %}{{ entry.label }} Preview - Email Gallery - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.preview-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.preview-toolbar__title { flex: 1; min-width: 0; }
|
||||||
|
.lang-toggle { display: flex; gap: 2px; background: #F1F5F9; border-radius: 6px; padding: 2px; }
|
||||||
|
.lang-toggle a {
|
||||||
|
padding: 4px 12px; font-size: 0.75rem; font-weight: 600;
|
||||||
|
border-radius: 4px; text-decoration: none; color: #64748B; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.lang-toggle a.active { background: #FFFFFF; color: #1D4ED8; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.preview-iframe {
|
||||||
|
width: 100%; height: 700px;
|
||||||
|
border: 1px solid #E2E8F0; border-radius: 8px;
|
||||||
|
background: #F1F5F9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="mb-4">
|
||||||
|
<a href="{{ url_for('admin.email_gallery') }}" class="text-sm text-slate">← Email Gallery</a>
|
||||||
|
<h1 class="text-2xl mt-1">{{ entry.label }}</h1>
|
||||||
|
<p style="margin-top:0.25rem;font-size:0.875rem;color:#64748B;">{{ entry.description }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<div class="preview-toolbar__title">
|
||||||
|
{% if entry.email_type %}
|
||||||
|
<a href="{{ url_for('admin.emails') }}?email_type={{ entry.email_type }}"
|
||||||
|
style="font-size:0.8125rem;color:#1D4ED8;text-decoration:none;">
|
||||||
|
View in sent log →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Language toggle #}
|
||||||
|
<div class="lang-toggle">
|
||||||
|
<a href="{{ url_for('admin.email_gallery_preview', slug=slug, lang='en') }}"
|
||||||
|
class="{{ 'active' if lang == 'en' else '' }}">EN</a>
|
||||||
|
<a href="{{ url_for('admin.email_gallery_preview', slug=slug, lang='de') }}"
|
||||||
|
class="{{ 'active' if lang == 'de' else '' }}">DE</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
class="preview-iframe"
|
||||||
|
srcdoc="{{ rendered_html | e }}"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
title="{{ entry.label }} email preview"
|
||||||
|
></iframe>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{# HTMX partial: sandboxed iframe showing a rendered email preview.
|
||||||
|
Rendered by POST /admin/emails/compose/preview. #}
|
||||||
|
<iframe
|
||||||
|
srcdoc="{{ rendered_html | e }}"
|
||||||
|
style="width:100%;height:600px;border:none;border-radius:8px;background:#F1F5F9;"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
title="Email preview"
|
||||||
|
></iframe>
|
||||||
307
web/src/padelnomics/email_templates.py
Normal file
307
web/src/padelnomics/email_templates.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
Standalone Jinja2 email template renderer.
|
||||||
|
|
||||||
|
Used by both the worker (outside Quart request context) and admin gallery routes.
|
||||||
|
Creates a module-level Environment pointing at the same templates/ directory
|
||||||
|
used by the web app, so templates share the same file tree.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from .email_templates import render_email_template, EMAIL_TEMPLATE_REGISTRY
|
||||||
|
|
||||||
|
html = render_email_template("emails/magic_link.html", lang="en", link=link, expiry_minutes=15)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
from .core import config, utcnow
|
||||||
|
from .i18n import get_translations
|
||||||
|
|
||||||
|
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||||
|
|
||||||
|
# Standalone environment — not tied to Quart's request context.
|
||||||
|
# autoescape=True: user-supplied data (names, emails, messages) is auto-escaped.
|
||||||
|
# Trusted HTML sections use the `| safe` filter explicitly.
|
||||||
|
_env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||||
|
autoescape=True,
|
||||||
|
undefined=jinja2.StrictUndefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tformat(s: str, **kwargs) -> str:
|
||||||
|
"""Jinja filter: interpolate {placeholders} into a translation string.
|
||||||
|
|
||||||
|
Mirrors the `tformat` filter registered in app.py so email templates
|
||||||
|
and web templates use the same syntax:
|
||||||
|
{{ t.some_key | tformat(name=supplier.name, count=n) }}
|
||||||
|
"""
|
||||||
|
if not kwargs:
|
||||||
|
return s
|
||||||
|
return s.format(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
_env.filters["tformat"] = _tformat
|
||||||
|
|
||||||
|
|
||||||
|
def render_email_template(template_name: str, lang: str = "en", **kwargs) -> str:
|
||||||
|
"""Render an email template with standard context injected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_name: Path relative to templates/ (e.g. "emails/magic_link.html").
|
||||||
|
lang: Language code ("en" or "de"). Used for translations + html lang attr.
|
||||||
|
**kwargs: Additional context variables passed to the template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered HTML string containing a full <!DOCTYPE html> document.
|
||||||
|
"""
|
||||||
|
assert lang in ("en", "de"), f"Unsupported lang: {lang!r}"
|
||||||
|
assert template_name.startswith("emails/"), f"Expected emails/ prefix: {template_name!r}"
|
||||||
|
|
||||||
|
translations = get_translations(lang)
|
||||||
|
year = utcnow().year
|
||||||
|
|
||||||
|
# Pre-interpolate footer strings so templates don't need to call tformat on them.
|
||||||
|
tagline = translations.get("email_footer_tagline", "")
|
||||||
|
copyright_text = translations.get("email_footer_copyright", "").format(
|
||||||
|
year=year, app_name=config.APP_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"lang": lang,
|
||||||
|
"app_name": config.APP_NAME,
|
||||||
|
"base_url": config.BASE_URL,
|
||||||
|
"t": translations,
|
||||||
|
"tagline": tagline,
|
||||||
|
"copyright_text": copyright_text,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl = _env.get_template(template_name)
|
||||||
|
rendered = tmpl.render(**context)
|
||||||
|
|
||||||
|
assert "<!DOCTYPE html>" in rendered, f"Template {template_name!r} must produce a DOCTYPE document"
|
||||||
|
assert "padelnomics" in rendered.lower(), f"Template {template_name!r} must include the wordmark"
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Template registry — used by admin gallery for sample preview rendering
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _magic_link_sample(lang: str) -> dict:
|
||||||
|
return {
|
||||||
|
"link": f"{config.BASE_URL}/auth/verify?token=sample_token_abc123",
|
||||||
|
"expiry_minutes": 15,
|
||||||
|
"preheader": get_translations(lang).get("email_magic_link_preheader", "").format(expiry_minutes=15),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_verification_sample(lang: str) -> dict:
|
||||||
|
t = get_translations(lang)
|
||||||
|
court_count = "4"
|
||||||
|
return {
|
||||||
|
"link": f"{config.BASE_URL}/{lang}/leads/verify?token=verify123&lead=lead456",
|
||||||
|
"first_name": "Alex",
|
||||||
|
"court_count": court_count,
|
||||||
|
"facility_type": "Indoor Padel Club",
|
||||||
|
"country": "Germany",
|
||||||
|
"recap_parts": ["4 courts", "Indoor Padel Club", "Germany"],
|
||||||
|
"preheader": t.get("email_quote_verify_preheader_courts", "").format(court_count=court_count),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _welcome_sample(lang: str) -> dict:
|
||||||
|
t = get_translations(lang)
|
||||||
|
return {
|
||||||
|
"first_name": "Maria",
|
||||||
|
"preheader": t.get("email_welcome_preheader", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _waitlist_supplier_sample(lang: str) -> dict:
|
||||||
|
t = get_translations(lang)
|
||||||
|
return {
|
||||||
|
"plan_name": "Growth",
|
||||||
|
"preheader": t.get("email_waitlist_supplier_preheader", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _waitlist_general_sample(lang: str) -> dict:
|
||||||
|
t = get_translations(lang)
|
||||||
|
return {
|
||||||
|
"preheader": t.get("email_waitlist_general_preheader", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_matched_sample(lang: str) -> dict:
|
||||||
|
t = get_translations(lang)
|
||||||
|
return {
|
||||||
|
"first_name": "Thomas",
|
||||||
|
"facility_type": "padel",
|
||||||
|
"court_count": "6",
|
||||||
|
"country": "Austria",
|
||||||
|
"preheader": t.get("email_lead_matched_preheader", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_forward_sample(lang: str) -> dict:
|
||||||
|
return {
|
||||||
|
"heat": "HOT",
|
||||||
|
"country": "Spain",
|
||||||
|
"courts": "8",
|
||||||
|
"budget": "450000",
|
||||||
|
"facility_type": "Outdoor Padel Club",
|
||||||
|
"timeline": "Q3 2025",
|
||||||
|
"contact_email": "ceo@padelclub.es",
|
||||||
|
"contact_name": "Carlos Rivera",
|
||||||
|
"contact_phone": "+34 612 345 678",
|
||||||
|
"contact_company": "PadelClub Madrid SL",
|
||||||
|
"stakeholder_type": "Developer / Investor",
|
||||||
|
"build_context": "New build",
|
||||||
|
"glass_type": "Panoramic",
|
||||||
|
"lighting_type": "LED",
|
||||||
|
"location": "Madrid",
|
||||||
|
"location_status": "Site confirmed",
|
||||||
|
"financing_status": "Self-financed",
|
||||||
|
"services_needed": "Full turnkey construction",
|
||||||
|
"additional_info": "Seeking experienced international suppliers only.",
|
||||||
|
"cta_url": f"{config.BASE_URL}/suppliers/leads/cta/sample_cta_token",
|
||||||
|
"preheader": "Outdoor Padel Club project · Q3 2025 timeline — contact details inside",
|
||||||
|
"brief_rows": [
|
||||||
|
("Facility", "Outdoor Padel Club (New build)"),
|
||||||
|
("Courts", "8 | Glass: Panoramic | Lighting: LED"),
|
||||||
|
("Location", "Madrid, Spain"),
|
||||||
|
("Timeline", "Q3 2025 | Budget: €450000"),
|
||||||
|
("Phase", "Site confirmed | Financing: Self-financed"),
|
||||||
|
("Services", "Full turnkey construction"),
|
||||||
|
("Additional Info", "Seeking experienced international suppliers only."),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_match_notify_sample(lang: str) -> dict:
|
||||||
|
return {
|
||||||
|
"heat": "WARM",
|
||||||
|
"country": "Netherlands",
|
||||||
|
"courts": "4",
|
||||||
|
"facility_type": "Indoor Padel",
|
||||||
|
"timeline": "Q1 2026",
|
||||||
|
"credit_cost": 2,
|
||||||
|
"preheader": "New matching lead in Netherlands",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _weekly_digest_sample(lang: str) -> dict:
|
||||||
|
return {
|
||||||
|
"leads": [
|
||||||
|
{"heat": "HOT", "facility_type": "Outdoor Padel", "court_count": "6", "country": "Germany", "timeline": "Q2 2025"},
|
||||||
|
{"heat": "WARM", "facility_type": "Indoor Club", "court_count": "4", "country": "Austria", "timeline": "Q3 2025"},
|
||||||
|
{"heat": "COOL", "facility_type": "Padel Centre", "court_count": "8", "country": "Switzerland", "timeline": "2026"},
|
||||||
|
],
|
||||||
|
"preheader": "3 new leads matching your service area",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _business_plan_sample(lang: str) -> dict:
|
||||||
|
t = get_translations(lang)
|
||||||
|
return {
|
||||||
|
"download_url": f"{config.BASE_URL}/planner/export/sample_export_token",
|
||||||
|
"quote_url": f"{config.BASE_URL}/{lang}/leads/quote",
|
||||||
|
"preheader": t.get("email_business_plan_preheader", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_compose_sample(lang: str) -> dict:
|
||||||
|
return {
|
||||||
|
"body_html": "<p>Hello,</p><p>This is a test message from the admin compose panel.</p><p>Best regards,<br>Padelnomics Team</p>",
|
||||||
|
"preheader": "Test message from admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry entry shape:
|
||||||
|
# template: path relative to templates/
|
||||||
|
# label: human-readable name shown in gallery
|
||||||
|
# description: one-line description
|
||||||
|
# email_type: email_type value stored in email_log (for cross-linking)
|
||||||
|
# sample_data: callable(lang) → dict of template context
|
||||||
|
EMAIL_TEMPLATE_REGISTRY: dict[str, dict] = {
|
||||||
|
"magic_link": {
|
||||||
|
"template": "emails/magic_link.html",
|
||||||
|
"label": "Magic Link",
|
||||||
|
"description": "Passwordless sign-in link sent to users requesting access.",
|
||||||
|
"email_type": "magic_link",
|
||||||
|
"sample_data": _magic_link_sample,
|
||||||
|
},
|
||||||
|
"quote_verification": {
|
||||||
|
"template": "emails/quote_verification.html",
|
||||||
|
"label": "Quote Verification",
|
||||||
|
"description": "Email address verification for new project quote requests.",
|
||||||
|
"email_type": "quote_verification",
|
||||||
|
"sample_data": _quote_verification_sample,
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"template": "emails/welcome.html",
|
||||||
|
"label": "Welcome",
|
||||||
|
"description": "Sent to new users after their first successful sign-in.",
|
||||||
|
"email_type": "welcome",
|
||||||
|
"sample_data": _welcome_sample,
|
||||||
|
},
|
||||||
|
"waitlist_supplier": {
|
||||||
|
"template": "emails/waitlist_supplier.html",
|
||||||
|
"label": "Waitlist — Supplier",
|
||||||
|
"description": "Confirmation for suppliers who joined the Growth/Pro waitlist.",
|
||||||
|
"email_type": "waitlist",
|
||||||
|
"sample_data": _waitlist_supplier_sample,
|
||||||
|
},
|
||||||
|
"waitlist_general": {
|
||||||
|
"template": "emails/waitlist_general.html",
|
||||||
|
"label": "Waitlist — General",
|
||||||
|
"description": "Confirmation for general sign-up waitlist submissions.",
|
||||||
|
"email_type": "waitlist",
|
||||||
|
"sample_data": _waitlist_general_sample,
|
||||||
|
},
|
||||||
|
"lead_matched": {
|
||||||
|
"template": "emails/lead_matched.html",
|
||||||
|
"label": "Lead Matched",
|
||||||
|
"description": "Notifies the project owner that suppliers are now reviewing their brief.",
|
||||||
|
"email_type": "lead_matched",
|
||||||
|
"sample_data": _lead_matched_sample,
|
||||||
|
},
|
||||||
|
"lead_forward": {
|
||||||
|
"template": "emails/lead_forward.html",
|
||||||
|
"label": "Lead Forward",
|
||||||
|
"description": "Full project brief sent to a supplier after they unlock a lead.",
|
||||||
|
"email_type": "lead_forward",
|
||||||
|
"sample_data": _lead_forward_sample,
|
||||||
|
},
|
||||||
|
"lead_match_notify": {
|
||||||
|
"template": "emails/lead_match_notify.html",
|
||||||
|
"label": "Lead Match Notify",
|
||||||
|
"description": "Notifies matching suppliers that a new lead is available in their area.",
|
||||||
|
"email_type": "lead_match_notify",
|
||||||
|
"sample_data": _lead_match_notify_sample,
|
||||||
|
},
|
||||||
|
"weekly_digest": {
|
||||||
|
"template": "emails/weekly_digest.html",
|
||||||
|
"label": "Weekly Digest",
|
||||||
|
"description": "Monday digest of new leads matching a supplier's service area.",
|
||||||
|
"email_type": "weekly_digest",
|
||||||
|
"sample_data": _weekly_digest_sample,
|
||||||
|
},
|
||||||
|
"business_plan": {
|
||||||
|
"template": "emails/business_plan.html",
|
||||||
|
"label": "Business Plan Ready",
|
||||||
|
"description": "Notifies the user when their business plan PDF export is ready.",
|
||||||
|
"email_type": "business_plan",
|
||||||
|
"sample_data": _business_plan_sample,
|
||||||
|
},
|
||||||
|
"admin_compose": {
|
||||||
|
"template": "emails/admin_compose.html",
|
||||||
|
"label": "Admin Compose",
|
||||||
|
"description": "Branded wrapper used for ad-hoc emails sent from the compose panel.",
|
||||||
|
"email_type": "admin_compose",
|
||||||
|
"sample_data": _admin_compose_sample,
|
||||||
|
},
|
||||||
|
}
|
||||||
54
web/src/padelnomics/templates/emails/_base.html
Normal file
54
web/src/padelnomics/templates/emails/_base.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{ app_name }}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
|
||||||
|
|
||||||
|
{%- if preheader %}
|
||||||
|
{# Hidden preheader trick: visible text + invisible padding to prevent
|
||||||
|
email clients from pulling body text into the preview. #}
|
||||||
|
<span style="display:none;font-size:1px;color:#F1F5F9;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">{{ preheader }}{% for _ in range(30) %}͏ ‌ {% endfor %}</span>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Blue accent border -->
|
||||||
|
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
|
||||||
|
<!-- Wordmark header -->
|
||||||
|
<tr><td style="background-color:#0F172A;padding:24px 36px;">
|
||||||
|
<a href="{{ base_url }}" style="text-decoration:none;">
|
||||||
|
<span style="color:#FFFFFF;font-size:18px;font-weight:800;letter-spacing:-0.02em;font-family:'Bricolage Grotesque',Georgia,'Times New Roman',serif;">padelnomics</span>
|
||||||
|
</a>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr><td style="padding:36px;color:#334155;font-size:15px;line-height:1.65;">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<tr><td style="height:1px;background-color:#E2E8F0;"></td></tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
|
||||||
|
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
|
||||||
|
<a href="{{ base_url }}" style="color:#64748B;text-decoration:none;font-weight:500;">padelnomics.io</a>
|
||||||
|
·
|
||||||
|
{{ tagline }}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
|
||||||
|
{{ copyright_text }}
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
web/src/padelnomics/templates/emails/_macros.html
Normal file
61
web/src/padelnomics/templates/emails/_macros.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{#
|
||||||
|
Shared macros for transactional email templates.
|
||||||
|
|
||||||
|
Import in child templates:
|
||||||
|
{% from "emails/_macros.html" import email_button, heat_badge, section_heading, info_box %}
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
{# ─── CTA Button ─────────────────────────────────────────────────────────────
|
||||||
|
Table-based blue button — works in all major email clients.
|
||||||
|
Uses display:block for full-width tap target on mobile.
|
||||||
|
#}
|
||||||
|
{% macro email_button(url, label) %}
|
||||||
|
<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">
|
||||||
|
<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">
|
||||||
|
<a href="{{ url }}" style="display:block;padding:14px 32px;color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">{{ label }}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# ─── Heat Badge ─────────────────────────────────────────────────────────────
|
||||||
|
Inline colored badge: HOT (red), WARM (orange), COOL (blue).
|
||||||
|
heat: uppercase string "HOT" | "WARM" | "COOL"
|
||||||
|
#}
|
||||||
|
{% macro heat_badge(heat) %}
|
||||||
|
{%- set colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} -%}
|
||||||
|
{%- set bg = colors.get(heat, "#2563EB") -%}
|
||||||
|
<span style="display:inline-block;padding:2px 8px;border-radius:4px;background-color:{{ bg }};color:#FFFFFF;font-size:11px;font-weight:700;letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{{ heat }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# ─── Small heat badge (compact variant for table rows) ──────────────────────
|
||||||
|
1px smaller padding, used in weekly_digest lead table rows.
|
||||||
|
#}
|
||||||
|
{% macro heat_badge_sm(heat) %}
|
||||||
|
{%- set colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} -%}
|
||||||
|
{%- set bg = colors.get(heat, "#2563EB") -%}
|
||||||
|
<span style="display:inline-block;padding:1px 6px;border-radius:4px;background-color:{{ bg }};color:#fff;font-size:10px;font-weight:700;">{{ heat }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# ─── Section Heading ─────────────────────────────────────────────────────────
|
||||||
|
Small uppercase label above a data table section.
|
||||||
|
#}
|
||||||
|
{% macro section_heading(text) %}
|
||||||
|
<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px;">{{ text }}</h3>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# ─── Info Box ────────────────────────────────────────────────────────────────
|
||||||
|
Left-bordered callout box. color: "blue" (default) or "yellow".
|
||||||
|
#}
|
||||||
|
{% macro info_box(text, color="blue") %}
|
||||||
|
{%- if color == "yellow" -%}
|
||||||
|
{%- set bg = "#FEF3C7" -%}{%- set border = "#F59E0B" -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set bg = "#F0F9FF" -%}{%- set border = "#1D4ED8" -%}
|
||||||
|
{%- endif -%}
|
||||||
|
<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;background-color:{{ bg }};border-radius:6px;border-left:3px solid {{ border }};">{{ text }}</p>
|
||||||
|
{% endmacro %}
|
||||||
5
web/src/padelnomics/templates/emails/admin_compose.html
Normal file
5
web/src/padelnomics/templates/emails/admin_compose.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{{ body_html | safe }}
|
||||||
|
{% endblock %}
|
||||||
11
web/src/padelnomics/templates/emails/business_plan.html
Normal file
11
web/src/padelnomics/templates/emails/business_plan.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_business_plan_heading }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_business_plan_body }}</p>
|
||||||
|
<p style="font-size:14px;color:#334155;">{{ t.email_business_plan_includes }}</p>
|
||||||
|
{{ email_button(download_url, t.email_business_plan_btn) }}
|
||||||
|
<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">{{ t.email_business_plan_quote_cta | tformat(quote_url=quote_url) }}</p>
|
||||||
|
{% endblock %}
|
||||||
53
web/src/padelnomics/templates/emails/lead_forward.html
Normal file
53
web/src/padelnomics/templates/emails/lead_forward.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button, heat_badge, section_heading %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{# Yellow urgency banner #}
|
||||||
|
<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">{{ t.email_lead_forward_urgency }}</p>
|
||||||
|
|
||||||
|
<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{{ t.email_lead_forward_heading }} {{ heat_badge(heat) }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">
|
||||||
|
|
||||||
|
{{ section_heading(t.email_lead_forward_section_brief) }}
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
|
||||||
|
{% for label, value in brief_rows %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top;">{{ label }}</td>
|
||||||
|
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ value }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{ section_heading(t.email_lead_forward_section_contact) }}
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_name }}</td>
|
||||||
|
<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600;">{{ contact_name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_email }}</td>
|
||||||
|
<td style="padding:4px 0;font-size:14px;"><a href="mailto:{{ contact_email }}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{{ contact_email }}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_phone }}</td>
|
||||||
|
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ contact_phone }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_company }}</td>
|
||||||
|
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ contact_company }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_role }}</td>
|
||||||
|
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ stakeholder_type }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{ email_button(base_url ~ "/suppliers/leads", t.email_lead_forward_btn) }}
|
||||||
|
<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">{{ t.email_lead_forward_reply_direct | tformat(contact_email=contact_email) }}</p>
|
||||||
|
|
||||||
|
{%- if cta_url %}
|
||||||
|
<p style="font-size:12px;color:#94A3B8;text-align:center;margin:16px 0 0;">
|
||||||
|
<a href="{{ cta_url }}" style="color:#94A3B8;">✓ Mark as contacted</a>
|
||||||
|
</p>
|
||||||
|
{%- endif %}
|
||||||
|
{% endblock %}
|
||||||
30
web/src/padelnomics/templates/emails/lead_match_notify.html
Normal file
30
web/src/padelnomics/templates/emails/lead_match_notify.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button, heat_badge %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">New [{{ heat }}] lead in {{ country }} {{ heat_badge(heat) }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p style="font-size:14px;color:#334155;">A new project brief has been submitted that matches your service area.</p>
|
||||||
|
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Facility</td>
|
||||||
|
<td style="font-size:13px;color:#1E293B;">{{ facility_type }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Courts</td>
|
||||||
|
<td style="font-size:13px;color:#1E293B;">{{ courts }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Country</td>
|
||||||
|
<td style="font-size:13px;color:#1E293B;">{{ country }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Timeline</td>
|
||||||
|
<td style="font-size:13px;color:#1E293B;">{{ timeline or "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="font-size:13px;color:#64748B;">Contact details are available after unlocking. Credits required: {{ credit_cost }}.</p>
|
||||||
|
{{ email_button(base_url ~ "/suppliers/leads", "View lead feed") }}
|
||||||
|
{% endblock %}
|
||||||
17
web/src/padelnomics/templates/emails/lead_matched.html
Normal file
17
web/src/padelnomics/templates/emails/lead_matched.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_lead_matched_heading }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_lead_matched_greeting | tformat(first_name=first_name) }}</p>
|
||||||
|
<p>{{ t.email_lead_matched_body }}</p>
|
||||||
|
<p style="font-size:13px;color:#64748B;">{{ t.email_lead_matched_context | tformat(facility_type=facility_type, court_count=court_count, country=country) }}</p>
|
||||||
|
|
||||||
|
<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{{ t.email_lead_matched_next_heading }}</p>
|
||||||
|
<p style="font-size:14px;color:#334155;">{{ t.email_lead_matched_next_body }}</p>
|
||||||
|
<p style="font-size:13px;color:#64748B;padding:10px 14px;background-color:#F0F9FF;border-radius:6px;border-left:3px solid #1D4ED8;">{{ t.email_lead_matched_tip }}</p>
|
||||||
|
|
||||||
|
{{ email_button(base_url ~ "/dashboard", t.email_lead_matched_btn) }}
|
||||||
|
<p style="font-size:12px;color:#94A3B8;">{{ t.email_lead_matched_note }}</p>
|
||||||
|
{% endblock %}
|
||||||
12
web/src/padelnomics/templates/emails/magic_link.html
Normal file
12
web/src/padelnomics/templates/emails/magic_link.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_magic_link_heading | tformat(app_name=app_name) }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_magic_link_body | tformat(expiry_minutes=expiry_minutes) }}</p>
|
||||||
|
{{ email_button(link, t.email_magic_link_btn) }}
|
||||||
|
<p style="font-size:13px;color:#94A3B8;">{{ t.email_magic_link_fallback }}</p>
|
||||||
|
<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{{ link }}</p>
|
||||||
|
<p style="font-size:13px;color:#94A3B8;">{{ t.email_magic_link_ignore }}</p>
|
||||||
|
{% endblock %}
|
||||||
24
web/src/padelnomics/templates/emails/quote_verification.html
Normal file
24
web/src/padelnomics/templates/emails/quote_verification.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_quote_verify_heading }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_quote_verify_greeting | tformat(first_name=first_name) }}</p>
|
||||||
|
<p>{{ t.email_quote_verify_body | tformat(app_name=app_name) }}</p>
|
||||||
|
|
||||||
|
{%- if recap_parts %}
|
||||||
|
<table cellpadding="0" cellspacing="0" width="100%" style="margin:16px 0;border:1px solid #E2E8F0;border-radius:8px;overflow:hidden;">
|
||||||
|
<tr><td style="padding:14px 18px;background-color:#F8FAFC;font-size:13px;color:#64748B;">
|
||||||
|
<strong style="color:#0F172A;">{{ t.email_quote_verify_project_label }}</strong> {{ recap_parts | join(" · ") | safe }}
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
<p style="font-size:13px;color:#334155;">{{ t.email_quote_verify_urgency }}</p>
|
||||||
|
{{ email_button(link, t.email_quote_verify_btn) }}
|
||||||
|
<p style="font-size:13px;color:#94A3B8;">{{ t.email_quote_verify_expires }}</p>
|
||||||
|
<p style="font-size:13px;color:#94A3B8;">{{ t.email_quote_verify_fallback }}</p>
|
||||||
|
<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{{ link }}</p>
|
||||||
|
<p style="font-size:13px;color:#94A3B8;">{{ t.email_quote_verify_ignore }}</p>
|
||||||
|
{% endblock %}
|
||||||
19
web/src/padelnomics/templates/emails/supplier_enquiry.html
Normal file
19
web/src/padelnomics/templates/emails/supplier_enquiry.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_enquiry_heading | tformat(contact_name=contact_name) }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_enquiry_body | tformat(supplier_name=supplier_name) }}</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;color:#64748B;width:120px;">{{ t.email_enquiry_lbl_from }}</td>
|
||||||
|
<td style="padding:6px 0;"><strong>{{ contact_name }}</strong> <{{ contact_email }}></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0;color:#64748B;vertical-align:top;">{{ t.email_enquiry_lbl_message }}</td>
|
||||||
|
<td style="padding:6px 0;white-space:pre-wrap;">{{ message }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:13px;color:#64748B;">{{ t.email_enquiry_respond_fast }}</p>
|
||||||
|
<p style="font-size:13px;color:#64748B;">{{ t.email_enquiry_reply | tformat(contact_email=contact_email) }}</p>
|
||||||
|
{% endblock %}
|
||||||
14
web/src/padelnomics/templates/emails/waitlist_general.html
Normal file
14
web/src/padelnomics/templates/emails/waitlist_general.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_waitlist_general_heading }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_waitlist_general_body }}</p>
|
||||||
|
<p>{{ t.email_waitlist_general_perks_intro }}</p>
|
||||||
|
<ul style="font-size:14px;color:#1E293B;margin:16px 0;">
|
||||||
|
<li>{{ t.email_waitlist_general_perk_1 }}</li>
|
||||||
|
<li>{{ t.email_waitlist_general_perk_2 }}</li>
|
||||||
|
<li>{{ t.email_waitlist_general_perk_3 }}</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-size:13px;color:#64748B;">{{ t.email_waitlist_general_outro }}</p>
|
||||||
|
{% endblock %}
|
||||||
18
web/src/padelnomics/templates/emails/waitlist_supplier.html
Normal file
18
web/src/padelnomics/templates/emails/waitlist_supplier.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_waitlist_supplier_heading }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_waitlist_supplier_body | tformat(plan_name=plan_name) }}</p>
|
||||||
|
<p>{{ t.email_waitlist_supplier_perks_intro }}</p>
|
||||||
|
<ul style="font-size:14px;color:#1E293B;margin:16px 0;">
|
||||||
|
<li>{{ t.email_waitlist_supplier_perk_1 }}</li>
|
||||||
|
<li>{{ t.email_waitlist_supplier_perk_2 }}</li>
|
||||||
|
<li>{{ t.email_waitlist_supplier_perk_3 }}</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-size:13px;color:#64748B;">{{ t.email_waitlist_supplier_meanwhile }}</p>
|
||||||
|
<ul style="font-size:13px;color:#64748B;">
|
||||||
|
<li><a href="{{ base_url }}/planner" style="color:#1D4ED8;">{{ t.email_waitlist_supplier_link_planner }}</a></li>
|
||||||
|
<li><a href="{{ base_url }}/directory" style="color:#1D4ED8;">{{ t.email_waitlist_supplier_link_directory }}</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
33
web/src/padelnomics/templates/emails/weekly_digest.html
Normal file
33
web/src/padelnomics/templates/emails/weekly_digest.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button, heat_badge_sm %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">
|
||||||
|
Your weekly lead digest — {{ leads | length }} new {{ "lead" if leads | length == 1 else "leads" }}
|
||||||
|
</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p style="font-size:14px;color:#334155;">New matching leads in your service area this week:</p>
|
||||||
|
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;width:100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase;">Project</th>
|
||||||
|
<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase;">Country</th>
|
||||||
|
<th style="text-align:left;font-size:11px;color:#94A3B8;text-transform:uppercase;">Timeline</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for lead in leads %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 12px 6px 0;font-size:13px;color:#1E293B;">
|
||||||
|
{{ heat_badge_sm(lead.heat | upper) }} {{ lead.facility_type or "Padel" }}, {{ lead.court_count or "?" }} courts
|
||||||
|
</td>
|
||||||
|
<td style="padding:6px 12px 6px 0;font-size:13px;color:#64748B;">{{ lead.country or "-" }}</td>
|
||||||
|
<td style="padding:6px 0;font-size:13px;color:#64748B;">{{ lead.timeline or "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{ email_button(base_url ~ "/suppliers/leads", "Unlock leads →") }}
|
||||||
|
{% endblock %}
|
||||||
25
web/src/padelnomics/templates/emails/welcome.html
Normal file
25
web/src/padelnomics/templates/emails/welcome.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% from "emails/_macros.html" import email_button %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_welcome_heading | tformat(app_name=app_name) }}</h2>
|
||||||
|
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
|
||||||
|
<p>{{ t.email_welcome_greeting | tformat(first_name=first_name) }}</p>
|
||||||
|
<p>{{ t.email_welcome_body }}</p>
|
||||||
|
<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{{ t.email_welcome_quickstart_heading }}</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>
|
||||||
|
<td style="padding:4px 0;"><a href="{{ base_url }}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_planner }}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>
|
||||||
|
<td style="padding:4px 0;"><a href="{{ base_url }}/{{ lang }}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_markets }}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>
|
||||||
|
<td style="padding:4px 0;"><a href="{{ base_url }}/{{ lang }}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_quotes }}</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{ email_button(base_url ~ "/planner", t.email_welcome_btn) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -22,6 +22,7 @@ from .core import (
|
|||||||
utcnow,
|
utcnow,
|
||||||
utcnow_iso,
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
|
from .email_templates import render_email_template
|
||||||
from .i18n import get_translations
|
from .i18n import get_translations
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -41,89 +42,6 @@ def _t(key: str, lang: str = "en", **kwargs) -> str:
|
|||||||
return raw.format(**kwargs) if kwargs else raw
|
return raw.format(**kwargs) if kwargs else raw
|
||||||
|
|
||||||
|
|
||||||
def _email_wrap(body: str, lang: str = "en", preheader: str = "") -> str:
|
|
||||||
"""Wrap email body in a branded layout with inline CSS.
|
|
||||||
|
|
||||||
preheader: hidden preview text shown in email client list views.
|
|
||||||
"""
|
|
||||||
year = utcnow().year
|
|
||||||
tagline = _t("email_footer_tagline", lang)
|
|
||||||
copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME)
|
|
||||||
# Hidden preheader trick: visible text + invisible padding to prevent
|
|
||||||
# email clients from pulling body text into the preview.
|
|
||||||
preheader_html = ""
|
|
||||||
if preheader:
|
|
||||||
preheader_html = (
|
|
||||||
f'<span style="display:none;font-size:1px;color:#F1F5F9;line-height:1px;'
|
|
||||||
f'max-height:0;max-width:0;opacity:0;overflow:hidden;">'
|
|
||||||
f'{preheader}{"͏ ‌ " * 30}</span>'
|
|
||||||
)
|
|
||||||
return f"""\
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{lang}">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>{config.APP_NAME}</title>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
|
|
||||||
{preheader_html}
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
|
|
||||||
<tr><td align="center">
|
|
||||||
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
|
|
||||||
|
|
||||||
<!-- Blue accent border -->
|
|
||||||
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;"> </td></tr>
|
|
||||||
|
|
||||||
<!-- Wordmark header -->
|
|
||||||
<tr><td style="background-color:#0F172A;padding:24px 36px;">
|
|
||||||
<a href="{config.BASE_URL}" style="text-decoration:none;">
|
|
||||||
<span style="color:#FFFFFF;font-size:18px;font-weight:800;letter-spacing:-0.02em;font-family:'Bricolage Grotesque',Georgia,'Times New Roman',serif;">padelnomics</span>
|
|
||||||
</a>
|
|
||||||
</td></tr>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<tr><td style="padding:36px;color:#334155;font-size:15px;line-height:1.65;">
|
|
||||||
{body}
|
|
||||||
</td></tr>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<tr><td style="height:1px;background-color:#E2E8F0;"></td></tr>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
|
|
||||||
<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;">padelnomics.io</a>
|
|
||||||
·
|
|
||||||
{tagline}
|
|
||||||
</p>
|
|
||||||
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
|
|
||||||
{copyright_text}
|
|
||||||
</p>
|
|
||||||
</td></tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _email_button(url: str, label: str) -> str:
|
|
||||||
"""Render a branded CTA button for email.
|
|
||||||
|
|
||||||
Uses display:block for full-width tap target on mobile.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
f'<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">'
|
|
||||||
f'<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">'
|
|
||||||
f'<a href="{url}" style="display:block;padding:14px 32px;'
|
|
||||||
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;'
|
|
||||||
f'letter-spacing:-0.01em;">'
|
|
||||||
f"{label}</a></td></tr></table>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def task(name: str):
|
def task(name: str):
|
||||||
"""Decorator to register a task handler."""
|
"""Decorator to register a task handler."""
|
||||||
|
|
||||||
@@ -228,20 +146,18 @@ async def handle_send_magic_link(payload: dict) -> None:
|
|||||||
logger.debug("MAGIC LINK for %s: %s", payload["email"], link)
|
logger.debug("MAGIC LINK for %s: %s", payload["email"], link)
|
||||||
|
|
||||||
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
|
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
|
||||||
body = (
|
html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
|
"emails/magic_link.html",
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
lang=lang,
|
||||||
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=expiry_minutes)}</p>'
|
link=link,
|
||||||
f'{_email_button(link, _t("email_magic_link_btn", lang))}'
|
expiry_minutes=expiry_minutes,
|
||||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>'
|
preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes),
|
||||||
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
|
|
||||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_ignore", lang)}</p>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
|
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
|
||||||
html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
email_type="magic_link",
|
email_type="magic_link",
|
||||||
)
|
)
|
||||||
@@ -266,8 +182,6 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
|||||||
facility_type = payload.get("facility_type", "")
|
facility_type = payload.get("facility_type", "")
|
||||||
country = payload.get("country", "")
|
country = payload.get("country", "")
|
||||||
|
|
||||||
# Project recap card
|
|
||||||
project_card = ""
|
|
||||||
recap_parts = []
|
recap_parts = []
|
||||||
if court_count:
|
if court_count:
|
||||||
recap_parts.append(f"{court_count} courts")
|
recap_parts.append(f"{court_count} courts")
|
||||||
@@ -275,34 +189,22 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
|||||||
recap_parts.append(facility_type)
|
recap_parts.append(facility_type)
|
||||||
if country:
|
if country:
|
||||||
recap_parts.append(country)
|
recap_parts.append(country)
|
||||||
if recap_parts:
|
|
||||||
project_card = (
|
|
||||||
f'<table cellpadding="0" cellspacing="0" width="100%" style="margin:16px 0;border:1px solid #E2E8F0;border-radius:8px;overflow:hidden;">'
|
|
||||||
f'<tr><td style="padding:14px 18px;background-color:#F8FAFC;font-size:13px;color:#64748B;">'
|
|
||||||
f'<strong style="color:#0F172A;">{_t("email_quote_verify_project_label", lang)}</strong> {" · ".join(recap_parts)}'
|
|
||||||
f'</td></tr></table>'
|
|
||||||
)
|
|
||||||
|
|
||||||
preheader = _t("email_quote_verify_preheader_courts", lang, court_count=court_count) if court_count else _t("email_quote_verify_preheader", lang)
|
preheader = _t("email_quote_verify_preheader_courts", lang, court_count=court_count) if court_count else _t("email_quote_verify_preheader", lang)
|
||||||
|
|
||||||
body = (
|
html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_quote_verify_heading", lang)}</h2>'
|
"emails/quote_verification.html",
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
lang=lang,
|
||||||
f'<p>{_t("email_quote_verify_greeting", lang, first_name=first_name)}</p>'
|
link=link,
|
||||||
f'<p>{_t("email_quote_verify_body", lang, app_name=config.APP_NAME)}</p>'
|
first_name=first_name,
|
||||||
f'{project_card}'
|
recap_parts=recap_parts,
|
||||||
f'<p style="font-size:13px;color:#334155;">{_t("email_quote_verify_urgency", lang)}</p>'
|
preheader=preheader,
|
||||||
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;">{_t("email_quote_verify_ignore", lang)}</p>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=_t("email_quote_verify_subject", lang),
|
subject=_t("email_quote_verify_subject", lang),
|
||||||
html=_email_wrap(body, lang, preheader=preheader),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
email_type="quote_verification",
|
email_type="quote_verification",
|
||||||
)
|
)
|
||||||
@@ -315,27 +217,17 @@ async def handle_send_welcome(payload: dict) -> None:
|
|||||||
name_parts = (payload.get("name") or "").split()
|
name_parts = (payload.get("name") or "").split()
|
||||||
first_name = name_parts[0] if name_parts else "there"
|
first_name = name_parts[0] if name_parts else "there"
|
||||||
|
|
||||||
body = (
|
html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
|
"emails/welcome.html",
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
lang=lang,
|
||||||
f'<p>{_t("email_welcome_greeting", lang, first_name=first_name)}</p>'
|
first_name=first_name,
|
||||||
f'<p>{_t("email_welcome_body", lang)}</p>'
|
preheader=_t("email_welcome_preheader", lang),
|
||||||
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_welcome_quickstart_heading", lang)}</p>'
|
|
||||||
f'<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">'
|
|
||||||
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>'
|
|
||||||
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_planner", lang)}</a></td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>'
|
|
||||||
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_markets", lang)}</a></td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>'
|
|
||||||
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_quotes", lang)}</a></td></tr>'
|
|
||||||
f'</table>'
|
|
||||||
f'{_email_button(f"{config.BASE_URL}/planner", _t("email_welcome_btn", lang))}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=_t("email_welcome_subject", lang),
|
subject=_t("email_welcome_subject", lang),
|
||||||
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
email_type="welcome",
|
email_type="welcome",
|
||||||
)
|
)
|
||||||
@@ -351,43 +243,24 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
|||||||
if intent.startswith("supplier_"):
|
if intent.startswith("supplier_"):
|
||||||
plan_name = intent.replace("supplier_", "").title()
|
plan_name = intent.replace("supplier_", "").title()
|
||||||
subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name)
|
subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name)
|
||||||
preheader = _t("email_waitlist_supplier_preheader", lang)
|
html = render_email_template(
|
||||||
body = (
|
"emails/waitlist_supplier.html",
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
|
lang=lang,
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
plan_name=plan_name,
|
||||||
f'<p>{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}</p>'
|
preheader=_t("email_waitlist_supplier_preheader", lang),
|
||||||
f'<p>{_t("email_waitlist_supplier_perks_intro", lang)}</p>'
|
|
||||||
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
|
|
||||||
f'<li>{_t("email_waitlist_supplier_perk_1", lang)}</li>'
|
|
||||||
f'<li>{_t("email_waitlist_supplier_perk_2", lang)}</li>'
|
|
||||||
f'<li>{_t("email_waitlist_supplier_perk_3", lang)}</li>'
|
|
||||||
f'</ul>'
|
|
||||||
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" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
|
|
||||||
f'<li><a href="{config.BASE_URL}/directory" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
|
|
||||||
f'</ul>'
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
subject = _t("email_waitlist_general_subject", lang)
|
subject = _t("email_waitlist_general_subject", lang)
|
||||||
preheader = _t("email_waitlist_general_preheader", lang)
|
html = render_email_template(
|
||||||
body = (
|
"emails/waitlist_general.html",
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_general_heading", lang)}</h2>'
|
lang=lang,
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
preheader=_t("email_waitlist_general_preheader", lang),
|
||||||
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(
|
await send_email(
|
||||||
to=email,
|
to=email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
html=_email_wrap(body, lang, preheader=preheader),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
email_type="waitlist",
|
email_type="waitlist",
|
||||||
)
|
)
|
||||||
@@ -428,15 +301,6 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
|||||||
|
|
||||||
subject = f"[{heat}] New padel project in {country} \u2014 {courts} courts, \u20ac{budget}"
|
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'<span style="display:inline-block;padding:2px 8px;border-radius:4px;'
|
|
||||||
f'background-color:{heat_bg};color:#FFFFFF;font-size:11px;font-weight:700;'
|
|
||||||
f'letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{heat}</span>'
|
|
||||||
)
|
|
||||||
|
|
||||||
tl = lambda key: _t(key, lang) # noqa: E731
|
tl = lambda key: _t(key, lang) # noqa: E731
|
||||||
|
|
||||||
brief_rows = [
|
brief_rows = [
|
||||||
@@ -449,50 +313,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
|||||||
(tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
(tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
||||||
]
|
]
|
||||||
|
|
||||||
brief_html = ""
|
|
||||||
for label, value in brief_rows:
|
|
||||||
brief_html += (
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top">{label}</td>'
|
|
||||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
|
|
||||||
)
|
|
||||||
|
|
||||||
contact_name = lead["contact_name"] or "-"
|
|
||||||
contact_phone = lead["contact_phone"] or "-"
|
|
||||||
|
|
||||||
# Contact section with prominent email
|
|
||||||
contact_html = (
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_name")}</td>'
|
|
||||||
f'<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600">{contact_name}</td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_email")}</td>'
|
|
||||||
f'<td style="padding:4px 0;font-size:14px;"><a href="mailto:{contact_email}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{contact_email}</a></td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_phone")}</td>'
|
|
||||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{contact_phone}</td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_company")}</td>'
|
|
||||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["contact_company"] or "-"}</td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_role")}</td>'
|
|
||||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["stakeholder_type"] or "-"}</td></tr>'
|
|
||||||
)
|
|
||||||
|
|
||||||
preheader_parts = [f"{facility_type} project"]
|
preheader_parts = [f"{facility_type} project"]
|
||||||
if timeline:
|
if timeline:
|
||||||
preheader_parts.append(f"{timeline} timeline")
|
preheader_parts.append(f"{timeline} timeline")
|
||||||
preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang))
|
preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang))
|
||||||
|
|
||||||
body = (
|
|
||||||
f'<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;'
|
|
||||||
f'background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">'
|
|
||||||
f'{_t("email_lead_forward_urgency", lang)}</p>'
|
|
||||||
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{tl("email_lead_forward_heading")} {heat_badge}</h2>'
|
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">'
|
|
||||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("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">{tl("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", tl("email_lead_forward_btn"))}'
|
|
||||||
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">'
|
|
||||||
f'{_t("email_lead_forward_reply_direct", lang, contact_email=contact_email)}</p>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send to supplier contact email or general contact
|
# Send to supplier contact email or general contact
|
||||||
to_email = supplier.get("contact_email") or supplier.get("contact") or ""
|
to_email = supplier.get("contact_email") or supplier.get("contact") or ""
|
||||||
if not to_email:
|
if not to_email:
|
||||||
@@ -502,16 +327,25 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
|||||||
# Generate one-click "I've contacted this lead" CTA token
|
# Generate one-click "I've contacted this lead" CTA token
|
||||||
cta_token = secrets.token_urlsafe(24)
|
cta_token = secrets.token_urlsafe(24)
|
||||||
cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}"
|
cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}"
|
||||||
body += (
|
|
||||||
f'<p style="font-size:12px;color:#94A3B8;text-align:center;margin:16px 0 0;">'
|
html = render_email_template(
|
||||||
f'<a href="{cta_url}" style="color:#94A3B8;">'
|
"emails/lead_forward.html",
|
||||||
f'✓ Mark as contacted</a></p>'
|
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(
|
await send_email(
|
||||||
to=to_email,
|
to=to_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["leads"],
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
email_type="lead_forward",
|
email_type="lead_forward",
|
||||||
)
|
)
|
||||||
@@ -535,26 +369,20 @@ 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"
|
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
|
||||||
|
|
||||||
body = (
|
html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_lead_matched_heading", lang)}</h2>'
|
"emails/lead_matched.html",
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
lang=lang,
|
||||||
f'<p>{_t("email_lead_matched_greeting", lang, first_name=first_name)}</p>'
|
first_name=first_name,
|
||||||
f'<p>{_t("email_lead_matched_body", lang)}</p>'
|
facility_type=lead["facility_type"] or "padel",
|
||||||
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>'
|
court_count=lead["court_count"] or "?",
|
||||||
# What happens next
|
country=lead["country"] or "your area",
|
||||||
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_lead_matched_next_heading", lang)}</p>'
|
preheader=_t("email_lead_matched_preheader", lang),
|
||||||
f'<p style="font-size:14px;color:#334155;">{_t("email_lead_matched_next_body", lang)}</p>'
|
|
||||||
f'<p style="font-size:13px;color:#64748B;padding:10px 14px;background-color:#F0F9FF;'
|
|
||||||
f'border-radius:6px;border-left:3px solid #1D4ED8;">'
|
|
||||||
f'{_t("email_lead_matched_tip", lang)}</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(
|
await send_email(
|
||||||
to=lead["contact_email"],
|
to=lead["contact_email"],
|
||||||
subject=_t("email_lead_matched_subject", lang, first_name=first_name),
|
subject=_t("email_lead_matched_subject", lang, first_name=first_name),
|
||||||
html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["leads"],
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
email_type="lead_matched",
|
email_type="lead_matched",
|
||||||
)
|
)
|
||||||
@@ -599,30 +427,22 @@ async def handle_notify_matching_suppliers(payload: dict) -> None:
|
|||||||
if not to_email:
|
if not to_email:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
body = (
|
notify_html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">'
|
"emails/lead_match_notify.html",
|
||||||
f'New [{heat}] lead in {country}</h2>'
|
lang=lang,
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
heat=heat,
|
||||||
f'<p style="font-size:14px;color:#334155;">A new project brief has been submitted that matches your service area.</p>'
|
country=country,
|
||||||
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">'
|
facility_type=facility_type,
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Facility</td>'
|
courts=courts,
|
||||||
f'<td style="font-size:13px;color:#1E293B">{facility_type}</td></tr>'
|
timeline=timeline,
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Courts</td>'
|
credit_cost=lead.get("credit_cost", "?"),
|
||||||
f'<td style="font-size:13px;color:#1E293B">{courts}</td></tr>'
|
preheader=f"New matching lead in {country}",
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Country</td>'
|
|
||||||
f'<td style="font-size:13px;color:#1E293B">{country}</td></tr>'
|
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Timeline</td>'
|
|
||||||
f'<td style="font-size:13px;color:#1E293B">{timeline or "-"}</td></tr>'
|
|
||||||
f'</table>'
|
|
||||||
f'<p style="font-size:13px;color:#64748B;">'
|
|
||||||
f'Contact details are available after unlocking. Credits required: {lead.get("credit_cost", "?")}.</p>'
|
|
||||||
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View lead feed")}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=to_email,
|
to=to_email,
|
||||||
subject=f"[{heat}] New {facility_type} project in {country} — {courts} courts",
|
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"],
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
email_type="lead_match_notify",
|
email_type="lead_match_notify",
|
||||||
)
|
)
|
||||||
@@ -667,48 +487,27 @@ async def handle_send_weekly_lead_digest(payload: dict) -> None:
|
|||||||
if not new_leads:
|
if not new_leads:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lead_rows_html = ""
|
# Normalise lead dicts for template — heat_score → heat (uppercase)
|
||||||
for ld in new_leads:
|
digest_leads = [
|
||||||
heat = (ld["heat_score"] or "cool").upper()
|
{**ld, "heat": (ld["heat_score"] or "cool").upper()}
|
||||||
heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"}
|
for ld in new_leads
|
||||||
hc = heat_colors.get(heat, "#2563EB")
|
]
|
||||||
badge = (
|
|
||||||
f'<span style="display:inline-block;padding:1px 6px;border-radius:4px;'
|
|
||||||
f'background-color:{hc};color:#fff;font-size:10px;font-weight:700">{heat}</span>'
|
|
||||||
)
|
|
||||||
lead_rows_html += (
|
|
||||||
f'<tr>'
|
|
||||||
f'<td style="padding:6px 12px 6px 0;font-size:13px;color:#1E293B">'
|
|
||||||
f'{badge} {ld["facility_type"] or "Padel"}, {ld["court_count"] or "?"} courts</td>'
|
|
||||||
f'<td style="padding:6px 12px 6px 0;font-size:13px;color:#64748B">{ld["country"] or "-"}</td>'
|
|
||||||
f'<td style="padding:6px 0;font-size:13px;color:#64748B">{ld["timeline"] or "-"}</td>'
|
|
||||||
f'</tr>'
|
|
||||||
)
|
|
||||||
|
|
||||||
body = (
|
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">'
|
|
||||||
f'Your weekly lead digest — {len(new_leads)} new {"lead" if len(new_leads) == 1 else "leads"}</h2>'
|
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
|
||||||
f'<p style="font-size:14px;color:#334155;">New matching leads in your service area this week:</p>'
|
|
||||||
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;width:100%">'
|
|
||||||
f'<thead><tr>'
|
|
||||||
f'<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase">Project</th>'
|
|
||||||
f'<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase">Country</th>'
|
|
||||||
f'<th style="text-align:left;font-size:11px;color:#94A3B8;text-transform:uppercase">Timeline</th>'
|
|
||||||
f'</tr></thead>'
|
|
||||||
f'<tbody>{lead_rows_html}</tbody>'
|
|
||||||
f'</table>'
|
|
||||||
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "Unlock leads →")}'
|
|
||||||
)
|
|
||||||
|
|
||||||
area_summary = ", ".join(countries[:3])
|
area_summary = ", ".join(countries[:3])
|
||||||
if len(countries) > 3:
|
if len(countries) > 3:
|
||||||
area_summary += f" +{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(
|
await send_email(
|
||||||
to=to_email,
|
to=to_email,
|
||||||
subject=f"{len(new_leads)} new padel {'lead' if len(new_leads) == 1 else 'leads'} in {area_summary}",
|
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"],
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
email_type="weekly_digest",
|
email_type="weekly_digest",
|
||||||
)
|
)
|
||||||
@@ -727,25 +526,20 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
|
|||||||
contact_email = payload.get("contact_email", "")
|
contact_email = payload.get("contact_email", "")
|
||||||
message = payload.get("message", "")
|
message = payload.get("message", "")
|
||||||
|
|
||||||
body = (
|
html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">'
|
"emails/supplier_enquiry.html",
|
||||||
f'{_t("email_enquiry_heading", lang, contact_name=contact_name)}</h2>'
|
lang=lang,
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
supplier_name=supplier_name,
|
||||||
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</p>'
|
contact_name=contact_name,
|
||||||
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">'
|
contact_email=contact_email,
|
||||||
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</td>'
|
message=message,
|
||||||
f'<td style="padding:6px 0"><strong>{contact_name}</strong> <{contact_email}></td></tr>'
|
preheader=_t("email_enquiry_preheader", lang),
|
||||||
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;">{_t("email_enquiry_respond_fast", lang)}</p>'
|
|
||||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=supplier_email,
|
to=supplier_email,
|
||||||
subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
|
subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
|
||||||
html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)),
|
html=html,
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
email_type="supplier_enquiry",
|
email_type="supplier_enquiry",
|
||||||
)
|
)
|
||||||
@@ -831,19 +625,17 @@ async def handle_generate_business_plan(payload: dict) -> None:
|
|||||||
export_token = export_row["token"]
|
export_token = export_row["token"]
|
||||||
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
|
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||||
if user:
|
if user:
|
||||||
body = (
|
bp_html = render_email_template(
|
||||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_business_plan_heading", language)}</h2>'
|
"emails/business_plan.html",
|
||||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
lang=language,
|
||||||
f'<p>{_t("email_business_plan_body", language)}</p>'
|
download_url=f"{config.BASE_URL}/planner/export/{export_token}",
|
||||||
f'<p style="font-size:14px;color:#334155;">{_t("email_business_plan_includes", language)}</p>'
|
quote_url=f"{config.BASE_URL}/{language}/leads/quote",
|
||||||
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}'
|
preheader=_t("email_business_plan_preheader", language),
|
||||||
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">'
|
|
||||||
f'{_t("email_business_plan_quote_cta", language, quote_url=f"{config.BASE_URL}/{language}/leads/quote")}</p>'
|
|
||||||
)
|
)
|
||||||
await send_email(
|
await send_email(
|
||||||
to=user["email"],
|
to=user["email"],
|
||||||
subject=_t("email_business_plan_subject", language),
|
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"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
email_type="business_plan",
|
email_type="business_plan",
|
||||||
)
|
)
|
||||||
|
|||||||
248
web/tests/test_email_templates.py
Normal file
248
web/tests/test_email_templates.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""
|
||||||
|
Tests for the standalone email template renderer and admin gallery routes.
|
||||||
|
|
||||||
|
render_email_template() tests: each registry entry renders without error,
|
||||||
|
produces a valid DOCTYPE document, includes the wordmark, and supports both
|
||||||
|
EN and DE languages.
|
||||||
|
|
||||||
|
Admin gallery tests: access control, list page, preview page, error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from padelnomics.core import utcnow_iso
|
||||||
|
from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
|
||||||
|
|
||||||
|
|
||||||
|
# ── render_email_template() ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderEmailTemplate:
|
||||||
|
"""render_email_template() produces valid HTML for all registry entries."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", list(EMAIL_TEMPLATE_REGISTRY.keys()))
|
||||||
|
def test_all_templates_render_en(self, slug):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY[slug]
|
||||||
|
sample = entry["sample_data"]("en")
|
||||||
|
html = render_email_template(entry["template"], lang="en", **sample)
|
||||||
|
assert "<!DOCTYPE html>" in html
|
||||||
|
assert "padelnomics" in html.lower()
|
||||||
|
assert 'lang="en"' in html
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", list(EMAIL_TEMPLATE_REGISTRY.keys()))
|
||||||
|
def test_all_templates_render_de(self, slug):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY[slug]
|
||||||
|
sample = entry["sample_data"]("de")
|
||||||
|
html = render_email_template(entry["template"], lang="de", **sample)
|
||||||
|
assert "<!DOCTYPE html>" in html
|
||||||
|
assert "padelnomics" in html.lower()
|
||||||
|
assert 'lang="de"' in html
|
||||||
|
|
||||||
|
def test_magic_link_contains_verify_link(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "/auth/verify?token=" in html
|
||||||
|
|
||||||
|
def test_magic_link_has_preheader(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "display:none" in html # hidden preheader span
|
||||||
|
|
||||||
|
def test_lead_forward_has_heat_badge(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "HOT" in html
|
||||||
|
assert "#DC2626" in html # HOT badge color
|
||||||
|
|
||||||
|
def test_lead_forward_has_brief_rows(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
# Brief rows table is rendered (e.g. "Facility" label)
|
||||||
|
assert "Facility" in html
|
||||||
|
|
||||||
|
def test_lead_forward_has_contact_info(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "ceo@padelclub.es" in html
|
||||||
|
assert "Carlos Rivera" in html
|
||||||
|
|
||||||
|
def test_weekly_digest_loops_over_leads(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["weekly_digest"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
# Sample data has 3 leads — all 3 countries should appear
|
||||||
|
assert "Germany" in html
|
||||||
|
assert "Austria" in html
|
||||||
|
assert "Switzerland" in html
|
||||||
|
|
||||||
|
def test_weekly_digest_has_heat_badges(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["weekly_digest"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "HOT" in html
|
||||||
|
assert "WARM" in html
|
||||||
|
assert "COOL" in html
|
||||||
|
|
||||||
|
def test_welcome_has_quickstart_links(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["welcome"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "/planner" in html
|
||||||
|
assert "/markets" in html
|
||||||
|
|
||||||
|
def test_admin_compose_renders_body_html(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["admin_compose"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "test message" in html.lower()
|
||||||
|
|
||||||
|
def test_business_plan_has_download_link(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["business_plan"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "/planner/export/" in html
|
||||||
|
|
||||||
|
def test_invalid_lang_raises(self):
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||||
|
with pytest.raises(AssertionError, match="Unsupported lang"):
|
||||||
|
render_email_template(entry["template"], lang="fr", **entry["sample_data"]("en"))
|
||||||
|
|
||||||
|
def test_non_emails_prefix_raises(self):
|
||||||
|
with pytest.raises(AssertionError, match="Expected emails/ prefix"):
|
||||||
|
render_email_template("base.html", lang="en")
|
||||||
|
|
||||||
|
def test_common_design_elements_present(self):
|
||||||
|
"""Branded shell must include font + blue accent across all templates."""
|
||||||
|
entry = EMAIL_TEMPLATE_REGISTRY["magic_link"]
|
||||||
|
html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en"))
|
||||||
|
assert "Bricolage Grotesque" in html
|
||||||
|
assert "#1D4ED8" in html
|
||||||
|
assert "padelnomics.io" in html
|
||||||
|
|
||||||
|
def test_registry_has_required_keys(self):
|
||||||
|
for slug, entry in EMAIL_TEMPLATE_REGISTRY.items():
|
||||||
|
assert "template" in entry, f"{slug}: missing 'template'"
|
||||||
|
assert "label" in entry, f"{slug}: missing 'label'"
|
||||||
|
assert "description" in entry, f"{slug}: missing 'description'"
|
||||||
|
assert callable(entry.get("sample_data")), f"{slug}: sample_data must be callable"
|
||||||
|
assert entry["template"].startswith("emails/"), f"{slug}: template must start with emails/"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin gallery routes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_client(app, db):
|
||||||
|
"""Test client with a user that has the admin role."""
|
||||||
|
now = utcnow_iso()
|
||||||
|
async with db.execute(
|
||||||
|
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
|
||||||
|
("gallery_admin@test.com", "Gallery Admin", now),
|
||||||
|
) as cursor:
|
||||||
|
admin_id = cursor.lastrowid
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async with app.test_client() as c:
|
||||||
|
async with c.session_transaction() as sess:
|
||||||
|
sess["user_id"] = admin_id
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailGalleryRoutes:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_requires_auth(self, client):
|
||||||
|
resp = await client.get("/admin/emails/gallery")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_list_returns_200(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_list_shows_all_template_labels(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery")
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
for entry in EMAIL_TEMPLATE_REGISTRY.values():
|
||||||
|
assert entry["label"] in html, f"Expected label {entry['label']!r} on gallery page"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_magic_link_en(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery/magic_link")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
assert "srcdoc" in html # sandboxed iframe is present
|
||||||
|
assert "Magic Link" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_magic_link_de(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery/magic_link?lang=de")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
assert 'lang="de"' in html or "de" in html # lang toggle shows active state
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_lead_forward(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery/lead_forward")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
assert "Lead Forward" in html
|
||||||
|
assert "srcdoc" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_weekly_digest(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery/weekly_digest")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_nonexistent_slug_redirects(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery/does-not-exist")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_invalid_lang_falls_back(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery/magic_link?lang=fr")
|
||||||
|
assert resp.status_code == 200 # invalid lang → falls back to "en"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_preview_requires_auth(self, client):
|
||||||
|
resp = await client.get("/admin/emails/gallery/magic_link")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gallery_list_has_preview_links(self, admin_client):
|
||||||
|
resp = await admin_client.get("/admin/emails/gallery")
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
# Each card links to the preview page
|
||||||
|
for slug in EMAIL_TEMPLATE_REGISTRY:
|
||||||
|
assert f"/admin/emails/gallery/{slug}" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compose_preview_plain_body(self, admin_client):
|
||||||
|
"""POST to compose/preview with wrap=0 returns plain HTML body."""
|
||||||
|
resp = await admin_client.post(
|
||||||
|
"/admin/emails/compose/preview",
|
||||||
|
form={"body": "Hello world", "wrap": "0"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
assert "Hello world" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compose_preview_wrapped_body(self, admin_client):
|
||||||
|
"""POST to compose/preview with wrap=1 wraps body in branded layout."""
|
||||||
|
resp = await admin_client.post(
|
||||||
|
"/admin/emails/compose/preview",
|
||||||
|
form={"body": "Test preview content", "wrap": "1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.get_data(as_text=True))
|
||||||
|
assert "Test preview content" in html
|
||||||
|
# Branded wrapper includes padelnomics wordmark
|
||||||
|
assert "padelnomics" in html.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compose_preview_empty_body(self, admin_client):
|
||||||
|
"""Empty body returns an empty but valid partial."""
|
||||||
|
resp = await admin_client.post(
|
||||||
|
"/admin/emails/compose/preview",
|
||||||
|
form={"body": "", "wrap": "1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
Reference in New Issue
Block a user