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:
@@ -324,6 +324,7 @@ def create_app() -> Quart:
|
||||
from .leads.routes import bp as leads_bp
|
||||
from .planner.routes import bp as planner_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 .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(leads_bp, url_prefix="/<lang>/leads")
|
||||
app.register_blueprint(suppliers_bp, url_prefix="/<lang>/suppliers")
|
||||
app.register_blueprint(reports_bp, url_prefix="/<lang>/reports")
|
||||
|
||||
# Non-prefixed blueprints (internal / behind auth)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
@@ -22,7 +22,7 @@ BUILD_DIR = Path("data/content/_build")
|
||||
|
||||
RESERVED_PREFIXES = (
|
||||
"/admin", "/auth", "/planner", "/billing", "/dashboard",
|
||||
"/directory", "/leads", "/suppliers", "/health",
|
||||
"/directory", "/leads", "/suppliers", "/reports", "/health",
|
||||
"/sitemap", "/static", "/features", "/feedback",
|
||||
)
|
||||
|
||||
|
||||
0
web/src/padelnomics/reports/__init__.py
Normal file
0
web/src/padelnomics/reports/__init__.py
Normal file
119
web/src/padelnomics/reports/routes.py
Normal file
119
web/src/padelnomics/reports/routes.py
Normal 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}"'},
|
||||
)
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user