feat(reports): add email-gated report PDF blueprint

- New reports/ blueprint: GET/POST /<lang>/reports/<slug> (email gate),
  GET /<lang>/reports/<slug>/download (PDF serve)
- REPORT_REGISTRY dict for q1-2026 EN/DE PDFs
- report_landing.html: stat strip, TOC preview, email form/download CTA
- Registered in app.py as /<lang>/reports (before content catch-all)
- Added /reports to RESERVED_PREFIXES in content/routes.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-27 07:46:45 +01:00
parent aea80f2541
commit 336ca67fdc
5 changed files with 273 additions and 1 deletions

View File

@@ -324,6 +324,7 @@ def create_app() -> Quart:
from .leads.routes import bp as leads_bp from .leads.routes import bp as leads_bp
from .planner.routes import bp as planner_bp from .planner.routes import bp as planner_bp
from .public.routes import bp as public_bp from .public.routes import bp as public_bp
from .reports.routes import bp as reports_bp
from .suppliers.routes import bp as suppliers_bp from .suppliers.routes import bp as suppliers_bp
from .webhooks import bp as webhooks_bp from .webhooks import bp as webhooks_bp
@@ -333,6 +334,7 @@ def create_app() -> Quart:
app.register_blueprint(directory_bp, url_prefix="/<lang>/directory") app.register_blueprint(directory_bp, url_prefix="/<lang>/directory")
app.register_blueprint(leads_bp, url_prefix="/<lang>/leads") app.register_blueprint(leads_bp, url_prefix="/<lang>/leads")
app.register_blueprint(suppliers_bp, url_prefix="/<lang>/suppliers") app.register_blueprint(suppliers_bp, url_prefix="/<lang>/suppliers")
app.register_blueprint(reports_bp, url_prefix="/<lang>/reports")
# Non-prefixed blueprints (internal / behind auth) # Non-prefixed blueprints (internal / behind auth)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)

View File

@@ -22,7 +22,7 @@ BUILD_DIR = Path("data/content/_build")
RESERVED_PREFIXES = ( RESERVED_PREFIXES = (
"/admin", "/auth", "/planner", "/billing", "/dashboard", "/admin", "/auth", "/planner", "/billing", "/dashboard",
"/directory", "/leads", "/suppliers", "/health", "/directory", "/leads", "/suppliers", "/reports", "/health",
"/sitemap", "/static", "/features", "/feedback", "/sitemap", "/static", "/features", "/feedback",
) )

View File

View File

@@ -0,0 +1,119 @@
"""
Reports domain: email-gated PDF market intelligence reports.
Flow:
GET /<lang>/reports/<slug> → landing page with teaser + email form
POST /<lang>/reports/<slug> → capture email → re-render with download button
GET /<lang>/reports/<slug>/download → serve pre-built PDF
"""
import logging
from pathlib import Path
from quart import Blueprint, Response, abort, g, render_template, request
from ..core import capture_waitlist_email, csrf_protect
from ..i18n import get_translations
logger = logging.getLogger(__name__)
bp = Blueprint(
"reports",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/reports",
)
# Registry of available reports. Key = slug, value = per-lang PDF filenames.
# PDFs live in data/content/reports/_build/<filename>.
REPORT_REGISTRY: dict[str, dict[str, str]] = {
"q1-2026": {
"en": "state-of-padel-q1-2026-en.pdf",
"de": "state-of-padel-q1-2026-de.pdf",
"title_en": "State of Padel Q1 2026: Global Market Report",
"title_de": "State of Padel Q1 2026: Weltmarktbericht",
},
}
_BUILD_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "content" / "reports" / "_build"
def _get_report(slug: str) -> dict | None:
return REPORT_REGISTRY.get(slug)
def _pdf_path(slug: str, lang: str) -> Path | None:
report = _get_report(slug)
if not report:
return None
filename = report.get(lang) or report.get("en")
if not filename:
return None
return _BUILD_DIR / filename
@bp.get("/<slug>")
async def landing(slug: str) -> str:
"""Report landing page with teaser stats and email capture form."""
report = _get_report(slug)
if not report:
abort(404)
lang = g.get("lang", "en")
t = get_translations(lang)
title = report.get(f"title_{lang}") or report.get("title_en", "")
return await render_template(
"reports/report_landing.html",
slug=slug,
report=report,
title=title,
lang=lang,
t=t,
confirmed=False,
)
@bp.post("/<slug>")
@csrf_protect
async def capture(slug: str) -> str:
"""Capture email and re-render with download button."""
report = _get_report(slug)
if not report:
abort(404)
lang = g.get("lang", "en")
t = get_translations(lang)
title = report.get(f"title_{lang}") or report.get("title_en", "")
form = await request.form
email = (form.get("email") or "").strip().lower()
if email:
await capture_waitlist_email(email, intent="report", plan=slug)
return await render_template(
"reports/report_landing.html",
slug=slug,
report=report,
title=title,
lang=lang,
t=t,
confirmed=bool(email),
)
@bp.get("/<slug>/download")
async def download(slug: str) -> Response:
"""Serve pre-built PDF for a given report slug and language."""
report = _get_report(slug)
if not report:
abort(404)
lang = g.get("lang", "en")
pdf_path = _pdf_path(slug, lang)
if not pdf_path or not pdf_path.exists():
abort(404)
pdf_bytes = pdf_path.read_bytes()
filename = pdf_path.name
return Response(
pdf_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}{{ title }} — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.report_q1_meta_description }}">
<meta property="og:title" content="{{ title }}">
<meta property="og:description" content="{{ t.report_q1_meta_description }}">
{% endblock %}
{% block content %}
<main class="container-page py-10">
{# ── Report hero ── #}
<div class="mb-8">
<p class="text-xs font-semibold uppercase tracking-widest text-electric mb-2">{{ t.report_q1_eyebrow }}</p>
<h1 class="text-4xl font-bold text-navy mb-3 leading-tight" style="font-family: var(--font-display)">{{ title }}</h1>
<p class="text-lg text-slate max-w-2xl">{{ t.report_q1_subtitle }}</p>
</div>
<div class="grid-2" style="gap: 3rem; align-items: start">
{# ── Left column: stats + TOC ── #}
<div>
{# Stats strip #}
<div class="stats-strip mb-8">
<div class="stats-strip__item">
<div class="stats-strip__label">{{ t.report_q1_stat1_label }}</div>
<div class="stats-strip__value">77,355<span class="stats-strip__unit">{{ t.report_q1_stat1_unit }}</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">{{ t.report_q1_stat2_label }}</div>
<div class="stats-strip__value">+29%<span class="stats-strip__unit">{{ t.report_q1_stat2_unit }}</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">{{ t.report_q1_stat3_label }}</div>
<div class="stats-strip__value">80<span class="stats-strip__unit">{{ t.report_q1_stat3_unit }}</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">{{ t.report_q1_stat4_label }}</div>
<div class="stats-strip__value">12,441<span class="stats-strip__unit">{{ t.report_q1_stat4_unit }}</span></div>
</div>
</div>
{# Table of contents preview #}
<div class="card" style="padding: 1.5rem">
<h2 class="text-base font-semibold text-navy mb-3" style="font-family: var(--font-sans)">{{ t.report_q1_toc_heading }}</h2>
<ol class="space-y-1 text-sm text-slate-dark" style="padding-left: 1.25rem; list-style: decimal">
<li>{{ t.report_q1_toc_1 }}</li>
<li>{{ t.report_q1_toc_2 }}</li>
<li>{{ t.report_q1_toc_3 }}</li>
<li>{{ t.report_q1_toc_4 }}</li>
<li>{{ t.report_q1_toc_5 }}</li>
<li>{{ t.report_q1_toc_6 }}</li>
</ol>
<p class="text-xs text-slate mt-3">{{ t.report_q1_toc_note }}</p>
</div>
{# Data sources note #}
<div class="mt-4 text-xs text-slate">
<p>{{ t.report_q1_sources }}</p>
</div>
</div>
{# ── Right column: email gate / download ── #}
<div>
<div class="card" style="padding: 2rem">
{% if confirmed %}
{# ── Post-capture: download state ── #}
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent/10 mb-3">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="#16A34A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="text-xl font-bold text-navy mb-1">{{ t.report_q1_confirmed_title }}</h2>
<p class="text-sm text-slate">{{ t.report_q1_confirmed_body }}</p>
</div>
<a href="{{ url_for('reports.download', lang=lang, slug=slug) }}"
class="btn w-full text-center"
style="display: flex; justify-content: center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2" style="flex-shrink:0">
<path d="M12 15l-4-4h3V4h2v7h3l-4 4zM4 20h16v-2H4v2z" fill="currentColor"/>
</svg>
{{ t.report_q1_download_btn }}
</a>
<p class="text-center text-xs text-slate mt-3">{{ t.report_q1_download_note }}</p>
{% else %}
{# ── Pre-capture: email form ── #}
<h2 class="text-xl font-bold text-navy mb-1">{{ t.report_q1_gate_title }}</h2>
<p class="text-sm text-slate mb-5">{{ t.report_q1_gate_body }}</p>
<ul class="space-y-1.5 text-sm text-slate-dark mb-6">
<li class="flex items-start gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:3px">
<circle cx="7" cy="7" r="7" fill="#16A34A" fill-opacity="0.12"/>
<path d="M4 7l2 2 4-4" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ t.report_q1_feature1 }}
</li>
<li class="flex items-start gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:3px">
<circle cx="7" cy="7" r="7" fill="#16A34A" fill-opacity="0.12"/>
<path d="M4 7l2 2 4-4" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ t.report_q1_feature2 }}
</li>
<li class="flex items-start gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:3px">
<circle cx="7" cy="7" r="7" fill="#16A34A" fill-opacity="0.12"/>
<path d="M4 7l2 2 4-4" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ t.report_q1_feature3 }}
</li>
</ul>
<form method="post" action="{{ url_for('reports.capture', lang=lang, slug=slug) }}" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="form-label">{{ t.report_q1_email_label }}</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="you@example.com"
required
autofocus
>
</div>
<button type="submit" class="btn w-full">{{ t.report_q1_cta_btn }}</button>
</form>
<p class="text-center text-xs text-slate mt-3">{{ t.report_q1_privacy_note }}</p>
{% endif %}
</div>
</div>
</div>
</main>
{% endblock %}