From 336ca67fdcca9a2dcda4066f0924c5b64bd87750 Mon Sep 17 00:00:00 2001 From: Deeman Date: Fri, 27 Feb 2026 07:46:45 +0100 Subject: [PATCH] feat(reports): add email-gated report PDF blueprint - New reports/ blueprint: GET/POST //reports/ (email gate), GET //reports//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 //reports (before content catch-all) - Added /reports to RESERVED_PREFIXES in content/routes.py Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/app.py | 2 + web/src/padelnomics/content/routes.py | 2 +- web/src/padelnomics/reports/__init__.py | 0 web/src/padelnomics/reports/routes.py | 119 ++++++++++++++ .../templates/reports/report_landing.html | 151 ++++++++++++++++++ 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 web/src/padelnomics/reports/__init__.py create mode 100644 web/src/padelnomics/reports/routes.py create mode 100644 web/src/padelnomics/reports/templates/reports/report_landing.html diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index fbcf74b..0278b2c 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -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="//directory") app.register_blueprint(leads_bp, url_prefix="//leads") app.register_blueprint(suppliers_bp, url_prefix="//suppliers") + app.register_blueprint(reports_bp, url_prefix="//reports") # Non-prefixed blueprints (internal / behind auth) app.register_blueprint(auth_bp) diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index 952302d..5b84376 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -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", ) diff --git a/web/src/padelnomics/reports/__init__.py b/web/src/padelnomics/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/src/padelnomics/reports/routes.py b/web/src/padelnomics/reports/routes.py new file mode 100644 index 0000000..c46d05d --- /dev/null +++ b/web/src/padelnomics/reports/routes.py @@ -0,0 +1,119 @@ +""" +Reports domain: email-gated PDF market intelligence reports. + +Flow: + GET //reports/ → landing page with teaser + email form + POST //reports/ → capture email → re-render with download button + GET //reports//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/. +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("/") +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("/") +@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("//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}"'}, + ) diff --git a/web/src/padelnomics/reports/templates/reports/report_landing.html b/web/src/padelnomics/reports/templates/reports/report_landing.html new file mode 100644 index 0000000..7ef2351 --- /dev/null +++ b/web/src/padelnomics/reports/templates/reports/report_landing.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} — {{ config.APP_NAME }}{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +
+ + {# ── Report hero ── #} +
+

{{ t.report_q1_eyebrow }}

+

{{ title }}

+

{{ t.report_q1_subtitle }}

+
+ +
+ + {# ── Left column: stats + TOC ── #} +
+ + {# Stats strip #} +
+
+
{{ t.report_q1_stat1_label }}
+
77,355{{ t.report_q1_stat1_unit }}
+
+
+
{{ t.report_q1_stat2_label }}
+
+29%{{ t.report_q1_stat2_unit }}
+
+
+
{{ t.report_q1_stat3_label }}
+
80{{ t.report_q1_stat3_unit }}
+
+
+
{{ t.report_q1_stat4_label }}
+
12,441{{ t.report_q1_stat4_unit }}
+
+
+ + {# Table of contents preview #} +
+

{{ t.report_q1_toc_heading }}

+
    +
  1. {{ t.report_q1_toc_1 }}
  2. +
  3. {{ t.report_q1_toc_2 }}
  4. +
  5. {{ t.report_q1_toc_3 }}
  6. +
  7. {{ t.report_q1_toc_4 }}
  8. +
  9. {{ t.report_q1_toc_5 }}
  10. +
  11. {{ t.report_q1_toc_6 }}
  12. +
+

{{ t.report_q1_toc_note }}

+
+ + {# Data sources note #} +
+

{{ t.report_q1_sources }}

+
+ +
+ + {# ── Right column: email gate / download ── #} +
+
+ + {% if confirmed %} + + {# ── Post-capture: download state ── #} +
+
+ + + +
+

{{ t.report_q1_confirmed_title }}

+

{{ t.report_q1_confirmed_body }}

+
+ + + + + + {{ t.report_q1_download_btn }} + + +

{{ t.report_q1_download_note }}

+ + {% else %} + + {# ── Pre-capture: email form ── #} +

{{ t.report_q1_gate_title }}

+

{{ t.report_q1_gate_body }}

+ +
    +
  • + + + + + {{ t.report_q1_feature1 }} +
  • +
  • + + + + + {{ t.report_q1_feature2 }} +
  • +
  • + + + + + {{ t.report_q1_feature3 }} +
  • +
+ +
+ +
+ + +
+ +
+ +

{{ t.report_q1_privacy_note }}

+ + {% endif %} + +
+
+ +
+
+{% endblock %}