merge: group_key static article grouping + email-gated report PDF

Feature 1 — group_key for static article admin grouping:
- Migration 0020: group_key TEXT column + index on articles table
- _sync_static_articles(): auto-upserts data/content/articles/*.md on
  every /admin/articles load, reads cornerstone → group_key
- _get_article_list_grouped(): COALESCE(group_key, url_path) as group_id,
  so EN/DE static cornerstones pair into one row (pSEO unchanged)

Feature 2 — Email-gated State of Padel report PDF:
- data/content/articles/state-of-padel-q1-2026-{en,de}.md → reports/
- New reports/ blueprint: GET/POST /<lang>/reports/<slug> (email gate),
  GET /<lang>/reports/<slug>/download (PDF serve)
- Premium PDF: full-bleed navy cover, Padelnomics wordmark watermark at
  3.5% opacity (position:fixed, every page), gold/teal accents, Georgia
  headings, WeasyPrint CSS3 (no JS)
- make report-pdf target to build PDFs
- i18n EN + DE (26 keys each, native German via linguistic-mediation)
- /reports added to RESERVED_PREFIXES, data/content/reports/_build/ gitignored

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-27 07:53:15 +01:00
16 changed files with 1311 additions and 25 deletions

3
.gitignore vendored
View File

@@ -58,3 +58,6 @@ build/
# Local binaries (tailwindcss, sops, age) # Local binaries (tailwindcss, sops, age)
bin/ bin/
web/src/padelnomics/static/css/output.css web/src/padelnomics/static/css/output.css
# Generated report PDFs (built locally via make report-pdf, not committed)
data/content/reports/_build/

View File

@@ -2,7 +2,7 @@ TAILWIND_VERSION := v4.1.18
TAILWIND := ./bin/tailwindcss TAILWIND := ./bin/tailwindcss
SOPS_DOTENV := sops --input-type dotenv --output-type dotenv SOPS_DOTENV := sops --input-type dotenv --output-type dotenv
.PHONY: help dev init-landing-seeds css-build css-watch \ .PHONY: help dev init-landing-seeds css-build css-watch report-pdf \
secrets-decrypt-dev secrets-decrypt-prod \ secrets-decrypt-dev secrets-decrypt-prod \
secrets-edit-dev secrets-edit-prod \ secrets-edit-dev secrets-edit-prod \
secrets-encrypt-dev secrets-encrypt-prod \ secrets-encrypt-dev secrets-encrypt-prod \
@@ -14,6 +14,7 @@ help:
@echo " init-landing-seeds Create seed landing files for SQLMesh (run once after clone)" @echo " init-landing-seeds Create seed landing files for SQLMesh (run once after clone)"
@echo " css-build Build + minify Tailwind CSS" @echo " css-build Build + minify Tailwind CSS"
@echo " css-watch Watch + rebuild Tailwind CSS" @echo " css-watch Watch + rebuild Tailwind CSS"
@echo " report-pdf Build market intelligence report PDFs (WeasyPrint)"
@echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env" @echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env"
@echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env" @echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env"
@echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR" @echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR"
@@ -47,6 +48,9 @@ css-build: bin/tailwindcss
css-watch: bin/tailwindcss css-watch: bin/tailwindcss
$(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch $(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch
report-pdf:
uv run python web/scripts/build_report_pdf.py
# ── Secrets (SOPS + age) ───────────────────────────────────────────────────── # ── Secrets (SOPS + age) ─────────────────────────────────────────────────────
# Requires: sops (https://github.com/getsops/sops) + age (https://github.com/FiloSottile/age) # Requires: sops (https://github.com/getsops/sops) + age (https://github.com/FiloSottile/age)
# Keys config: .sops.yaml # Keys config: .sops.yaml

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Build market intelligence report PDFs from data/content/reports/*.md.
Reads YAML frontmatter + Markdown body from each .md file, renders the
HTML template (web/src/padelnomics/templates/reports/report.html) with
the report content, and generates a PDF via WeasyPrint.
Output: data/content/reports/_build/<slug>-<lang>.pdf
Usage:
uv run python web/scripts/build_report_pdf.py [--slug q1-2026] [--lang en]
The --slug and --lang flags filter which reports to build. With no flags,
all .md files in data/content/reports/ are built.
"""
import argparse
import re
import sys
from pathlib import Path
import mistune
import yaml
from jinja2 import Template
from weasyprint import HTML
REPO_ROOT = Path(__file__).parent.parent.parent
REPORTS_DIR = REPO_ROOT / "data" / "content" / "reports"
BUILD_DIR = REPORTS_DIR / "_build"
TEMPLATE_DIR = REPO_ROOT / "web" / "src" / "padelnomics" / "templates" / "reports"
LOGO_PATH = REPO_ROOT / "web" / "src" / "padelnomics" / "static" / "images" / "logo.png"
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _parse_md(path: Path) -> tuple[dict, str]:
"""Return (frontmatter_dict, markdown_body) for a .md file."""
raw = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(raw)
assert m, f"No YAML frontmatter found in {path}"
fm = yaml.safe_load(m.group(1)) or {}
body = raw[m.end():]
return fm, body
def _make_toc(body_md: str) -> list[dict]:
"""Extract H2 headings from markdown for the TOC."""
toc = []
for line in body_md.splitlines():
if line.startswith("## "):
toc.append({"title": line[3:].strip(), "is_section": False})
return toc
def _cover_stats_en() -> list[dict]:
return [
{"value": "77,355", "label": "Courts Worldwide"},
{"value": "+29%", "label": "Growth in 18 Months"},
{"value": "80", "label": "Countries Tracked"},
{"value": "12,441", "label": "Venues in Pipeline"},
]
def _cover_stats_de() -> list[dict]:
return [
{"value": "77.355", "label": "Plätze weltweit"},
{"value": "+29 %", "label": "Wachstum in 18 Monaten"},
{"value": "80", "label": "Länder erfasst"},
{"value": "12.441", "label": "Venues im Datensatz"},
]
def _labels_en(fm: dict) -> dict:
return {
"report_slug_label": "State of Padel Q1 2026",
"edition_label": "Q1 2026 Edition",
"report_type_label": "Global Market Intelligence Report",
"subtitle": (
"77,355 courts. +29% growth. 80 countries. "
"The most complete independent picture of the global padel market."
),
"published_label": "Published Q1 2026",
"confidential_label": "For registered recipients",
"toc_heading": "Contents",
"disclaimer": (
"This report has been prepared by Padelnomics for informational purposes only. "
"All data is sourced from publicly available reports (FIP, Playtomic/PwC) and "
"Padelnomics' proprietary data pipeline. Market figures reflect the best available "
"data at time of publication and may differ from subsequently reported figures. "
"Nothing in this report constitutes investment advice. "
"© 2026 Padelnomics — padelnomics.io"
),
}
def _labels_de(fm: dict) -> dict:
return {
"report_slug_label": "State of Padel Q1 2026",
"edition_label": "Ausgabe Q1 2026",
"report_type_label": "Globaler Marktintelligenz-Bericht",
"subtitle": (
"77.355 Plätze. +29 % Wachstum. 80 Länder. "
"Das vollständigste unabhängige Bild des globalen Padel-Markts."
),
"published_label": "Veröffentlicht Q1 2026",
"confidential_label": "Für registrierte Empfänger",
"toc_heading": "Inhalt",
"disclaimer": (
"Dieser Bericht wurde von Padelnomics ausschließlich zu Informationszwecken erstellt. "
"Alle Daten stammen aus öffentlich zugänglichen Berichten (FIP, Playtomic/PwC) sowie "
"der proprietären Datenpipeline von Padelnomics. Marktdaten spiegeln den besten "
"verfügbaren Stand zum Zeitpunkt der Veröffentlichung wider. "
"Der Bericht stellt keine Anlageberatung dar. "
"© 2026 Padelnomics — padelnomics.io"
),
}
def build_one(md_path: Path, output_dir: Path) -> Path:
"""Build a single PDF from a .md report file. Returns the output path."""
fm, body_md = _parse_md(md_path)
lang = fm.get("language", "en")
slug = fm.get("slug", md_path.stem)
title = fm.get("title", slug)
body_html = mistune.html(body_md)
labels = _labels_de(fm) if lang == "de" else _labels_en(fm)
cover_stats = _cover_stats_de() if lang == "de" else _cover_stats_en()
toc = _make_toc(body_md)
template_html = (TEMPLATE_DIR / "report.html").read_text(encoding="utf-8")
css = (TEMPLATE_DIR / "report.css").read_text(encoding="utf-8")
# WeasyPrint resolves relative URLs from base_url; pass logo as file:// path
logo_file_url = LOGO_PATH.as_uri()
rendered = Template(template_html).render(
language=lang,
title=title,
css=css,
logo_path=logo_file_url,
body_html=body_html,
toc=toc,
cover_stats=cover_stats,
**labels,
)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{slug}-{lang}.pdf"
HTML(string=rendered).write_pdf(str(output_path))
print(f" ✓ Built {output_path.relative_to(REPO_ROOT)}")
return output_path
def main() -> None:
parser = argparse.ArgumentParser(description="Build market intelligence report PDFs")
parser.add_argument("--slug", help="Only build report with this slug substring")
parser.add_argument("--lang", help="Only build this language (en or de)")
args = parser.parse_args()
md_files = sorted(REPORTS_DIR.glob("*.md"))
if not md_files:
print(f"No .md files found in {REPORTS_DIR}")
sys.exit(0)
built = 0
for md_path in md_files:
if args.slug and args.slug not in md_path.stem:
continue
if args.lang and not md_path.stem.endswith(f"-{args.lang}"):
continue
try:
build_one(md_path, BUILD_DIR)
built += 1
except Exception as exc:
print(f" ✗ Failed {md_path.name}: {exc}", file=sys.stderr)
raise
print(f"\n✓ Built {built} PDF(s) → {BUILD_DIR}")
if __name__ == "__main__":
main()

View File

@@ -5,6 +5,8 @@ import csv
import io import io
import json import json
import logging import logging
import os
import re
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
@@ -2200,6 +2202,82 @@ async def scenario_pdf(scenario_id: int):
# Article Management # Article Management
# ============================================================================= # =============================================================================
_ARTICLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "content" / "articles"
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
async def _sync_static_articles() -> None:
"""Upsert static .md articles from data/content/articles/ into the DB.
Reads YAML frontmatter from each file, renders body markdown to HTML,
and upserts into the articles table keyed on slug. Skips files where
the DB updated_at is newer than the file's mtime (no-op on unchanged files).
"""
import yaml
if not _ARTICLES_DIR.is_dir():
return
md_files = sorted(_ARTICLES_DIR.glob("*.md"))
if not md_files:
return
for md_path in md_files:
raw = md_path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(raw)
if not m:
continue
try:
fm = yaml.safe_load(m.group(1)) or {}
except Exception:
continue
slug = fm.get("slug")
if not slug:
continue
# Skip if DB record is newer than file mtime
file_mtime_iso = (
__import__("datetime").datetime.utcfromtimestamp(
os.path.getmtime(md_path)
).strftime("%Y-%m-%d %H:%M:%S")
)
existing = await fetch_one(
"SELECT updated_at FROM articles WHERE slug = ?", (slug,)
)
if existing and existing["updated_at"] and existing["updated_at"] >= file_mtime_iso:
continue
body_md = raw[m.end():]
body_html = mistune.html(body_md)
title = fm.get("title", slug)
url_path = fm.get("url_path", f"/{slug}")
language = fm.get("language", "en")
meta_description = fm.get("meta_description", "")
template_slug = fm.get("template_slug") or None
group_key = fm.get("cornerstone") or None
now_iso = utcnow_iso()
await execute(
"""INSERT INTO articles
(slug, title, url_path, language, meta_description, body_html,
status, template_slug, group_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
url_path = excluded.url_path,
language = excluded.language,
meta_description = excluded.meta_description,
body_html = excluded.body_html,
template_slug = excluded.template_slug,
group_key = excluded.group_key,
updated_at = excluded.updated_at""",
(slug, title, url_path, language, meta_description, body_html,
template_slug, group_key, now_iso, now_iso),
)
async def _get_article_list( async def _get_article_list(
status: str = None, status: str = None,
template_slug: str = None, template_slug: str = None,
@@ -2251,7 +2329,12 @@ async def _get_article_list_grouped(
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
) -> list[dict]: ) -> list[dict]:
"""Get articles grouped by slug; each item has a 'variants' list (one per language).""" """Get articles grouped by COALESCE(group_key, url_path).
pSEO articles (group_key NULL) group by url_path — EN/DE share the same url_path.
Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path.
Each returned item has a 'variants' list (one dict per language variant).
"""
wheres = ["1=1"] wheres = ["1=1"]
params: list = [] params: list = []
@@ -2271,43 +2354,48 @@ async def _get_article_list_grouped(
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
# Group by url_path — language variants share the same url_path (no lang prefix stored) # First pass: paginate over distinct group keys
path_rows = await fetch_all( group_rows = await fetch_all(
f"""SELECT url_path, MAX(created_at) AS latest_created f"""SELECT COALESCE(group_key, url_path) AS group_id,
MAX(created_at) AS latest_created
FROM articles WHERE {where} FROM articles WHERE {where}
GROUP BY url_path GROUP BY COALESCE(group_key, url_path)
ORDER BY latest_created DESC ORDER BY latest_created DESC
LIMIT ? OFFSET ?""", LIMIT ? OFFSET ?""",
tuple(params + [per_page, offset]), tuple(params + [per_page, offset]),
) )
if not path_rows: if not group_rows:
return [] return []
url_paths = [r["url_path"] for r in path_rows] group_ids = [r["group_id"] for r in group_rows]
placeholders = ",".join("?" * len(url_paths)) placeholders = ",".join("?" * len(group_ids))
# Second pass: fetch all variants for the paginated groups
variants = await fetch_all( variants = await fetch_all(
f"""SELECT *, f"""SELECT *,
COALESCE(group_key, url_path) AS group_id,
CASE WHEN status = 'published' AND published_at > datetime('now') CASE WHEN status = 'published' AND published_at > datetime('now')
THEN 'scheduled' THEN 'scheduled'
WHEN status = 'published' THEN 'live' WHEN status = 'published' THEN 'live'
ELSE status END AS display_status ELSE status END AS display_status
FROM articles WHERE url_path IN ({placeholders}) FROM articles
ORDER BY url_path, language""", WHERE COALESCE(group_key, url_path) IN ({placeholders})
tuple(url_paths), ORDER BY COALESCE(group_key, url_path), language""",
tuple(group_ids),
) )
by_path: dict[str, list] = {} by_group: dict[str, list] = {}
for v in variants: for v in variants:
by_path.setdefault(v["url_path"], []).append(dict(v)) by_group.setdefault(v["group_id"], []).append(dict(v))
groups = [] groups = []
for url_path in url_paths: for gid in group_ids:
variant_list = by_path.get(url_path, []) variant_list = by_group.get(gid, [])
if not variant_list: if not variant_list:
continue continue
primary = next((v for v in variant_list if v["language"] == "en"), variant_list[0]) primary = next((v for v in variant_list if v["language"] == "en"), variant_list[0])
groups.append({ groups.append({
"url_path": url_path, "url_path": primary["url_path"],
"title": primary["title"], "title": primary["title"],
"published_at": primary["published_at"], "published_at": primary["published_at"],
"template_slug": primary["template_slug"], "template_slug": primary["template_slug"],
@@ -2341,7 +2429,8 @@ async def _is_generating() -> bool:
@bp.route("/articles") @bp.route("/articles")
@role_required("admin") @role_required("admin")
async def articles(): async def articles():
"""List all articles with filters.""" """List all articles with filters. Syncs static .md files on every load."""
await _sync_static_articles()
search = request.args.get("search", "").strip() search = request.args.get("search", "").strip()
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")

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

@@ -1748,5 +1748,38 @@
"sd_guarantee_submit": "Credits zurückbuchen", "sd_guarantee_submit": "Credits zurückbuchen",
"sd_guarantee_success": "Credits wurden deinem Guthaben gutgeschrieben.", "sd_guarantee_success": "Credits wurden deinem Guthaben gutgeschrieben.",
"sd_guarantee_window_error": "Garantiezeitraum abgelaufen (nur 330 Tage nach dem Freischalten verfügbar).", "sd_guarantee_window_error": "Garantiezeitraum abgelaufen (nur 330 Tage nach dem Freischalten verfügbar).",
"sd_guarantee_already_claimed": "Du hast für diesen Lead bereits eine Rückerstattung beantragt." "sd_guarantee_already_claimed": "Du hast für diesen Lead bereits eine Rückerstattung beantragt.",
"report_q1_eyebrow": "Globale Marktanalyse",
"report_q1_meta_description": "77.355 Padelplätze weltweit, +29 % in 18 Monaten. Der umfassendste unabhängige Marktbericht zum globalen Padelmarkt — FIP, Playtomic/PwC und die Padelnomics-Datenpipeline.",
"report_q1_subtitle": "Das vollständigste unabhängige Bild des globalen Padelmarkts — Daten aus FIP, Playtomic/PwC und der Padelnomics-Pipeline mit 12.441 Standorten in 80 Ländern.",
"report_q1_stat1_label": "Plätze weltweit",
"report_q1_stat1_unit": "",
"report_q1_stat2_label": "Wachstum in 18 Monaten",
"report_q1_stat2_unit": "",
"report_q1_stat3_label": "Erfasste Länder",
"report_q1_stat3_unit": "",
"report_q1_stat4_label": "Venues im Datensatz",
"report_q1_stat4_unit": "",
"report_q1_toc_heading": "Im Bericht",
"report_q1_toc_1": "Globaler Überblick: Plätze, Spieler, Verbände",
"report_q1_toc_2": "Europa: eine ungleich verteilte Karte",
"report_q1_toc_3": "Deutschland: unterversorgt und hochpotent",
"report_q1_toc_4": "Wirtschaftlichkeit: was Clubs tatsächlich verdienen",
"report_q1_toc_5": "Investitionsklima: wer baut und warum",
"report_q1_toc_6": "Ausblick: wie sich der Markt entwickelt",
"report_q1_toc_note": "22-seitiger Bericht · FIP, Playtomic/PwC, Padelnomics-Pipeline",
"report_q1_sources": "Datenquellen: FIP World Padel Report 2025, Playtomic/PwC Global Padel Report 2025, Padelnomics-Pipeline (12.441 Venues, 80 Länder, 5.492 Städte).",
"report_q1_gate_title": "Vollständigen Bericht herunterladen",
"report_q1_gate_body": "Kostenlos. E-Mail angeben und den Bericht sofort als PDF laden.",
"report_q1_feature1": "Weltweite Platzzahlen und Spielerdaten der FIP",
"report_q1_feature2": "Umsatz-Benchmarks für Clubs (Playtomic/PwC)",
"report_q1_feature3": "Unabhängige Padelnomics-Daten: 12.441 Standorte",
"report_q1_email_label": "Ihre E-Mail-Adresse",
"report_q1_cta_btn": "Bericht herunterladen (PDF)",
"report_q1_privacy_note": "Kein Spam. Jederzeit abbestellbar.",
"report_q1_confirmed_title": "Download bereit",
"report_q1_confirmed_body": "Unten auf den Button klicken, um das vollständige Bericht-PDF zu öffnen.",
"report_q1_download_btn": "PDF herunterladen",
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern."
} }

View File

@@ -1751,5 +1751,38 @@
"mscore_faq_q6": "What is the difference between the padelnomics Marktreife-Score and the padelnomics Marktpotenzial-Score?", "mscore_faq_q6": "What is the difference between the padelnomics Marktreife-Score and the padelnomics Marktpotenzial-Score?",
"mscore_faq_a6": "The padelnomics Marktreife-Score measures how established and mature an existing padel market is — it only applies to cities with at least one venue. The padelnomics Marktpotenzial-Score measures greenfield investment opportunity and covers all locations globally, rewarding supply gaps and underserved catchment areas where no courts exist yet.", "mscore_faq_a6": "The padelnomics Marktreife-Score measures how established and mature an existing padel market is — it only applies to cities with at least one venue. The padelnomics Marktpotenzial-Score measures greenfield investment opportunity and covers all locations globally, rewarding supply gaps and underserved catchment areas where no courts exist yet.",
"mscore_faq_q7": "Why does my town have a high padelnomics Marktpotenzial-Score but no padel courts?", "mscore_faq_q7": "Why does my town have a high padelnomics Marktpotenzial-Score but no padel courts?",
"mscore_faq_a7": "That is exactly the point. A high padelnomics Marktpotenzial-Score indicates an underserved location: strong demographics, economic purchasing power, no existing supply, and distance from the nearest court. These are precisely the signals that suggest a greenfield opportunity — not a sign of a weak market." "mscore_faq_a7": "That is exactly the point. A high padelnomics Marktpotenzial-Score indicates an underserved location: strong demographics, economic purchasing power, no existing supply, and distance from the nearest court. These are precisely the signals that suggest a greenfield opportunity — not a sign of a weak market.",
"report_q1_eyebrow": "Global Market Intelligence",
"report_q1_meta_description": "77,355 padel courts worldwide, +29% in 18 months. The most complete independent market report on global padel — FIP, Playtomic/PwC, and the Padelnomics data pipeline.",
"report_q1_subtitle": "The most complete independent picture of the global padel market — synthesising data from FIP, Playtomic/PwC, and the Padelnomics pipeline of 12,441 venues across 80 countries.",
"report_q1_stat1_label": "Courts worldwide",
"report_q1_stat1_unit": "",
"report_q1_stat2_label": "Growth in 18 months",
"report_q1_stat2_unit": "",
"report_q1_stat3_label": "Countries tracked",
"report_q1_stat3_unit": "",
"report_q1_stat4_label": "Venues in pipeline",
"report_q1_stat4_unit": "",
"report_q1_toc_heading": "What's inside",
"report_q1_toc_1": "Global snapshot: courts, players, federations",
"report_q1_toc_2": "Europe: the uneven map",
"report_q1_toc_3": "Germany: underserved and high-potential",
"report_q1_toc_4": "Economics: what clubs actually earn",
"report_q1_toc_5": "Investment landscape: who's building and why",
"report_q1_toc_6": "Outlook: where the market goes from here",
"report_q1_toc_note": "22-page report · FIP, Playtomic/PwC, Padelnomics pipeline",
"report_q1_sources": "Data sources: FIP World Padel Report 2025, Playtomic/PwC Global Padel Report 2025, Padelnomics pipeline (12,441 venues, 80 countries, 5,492 cities).",
"report_q1_gate_title": "Download the full report",
"report_q1_gate_body": "Free to download. Enter your email to receive a copy and occasional Padelnomics market updates.",
"report_q1_feature1": "FIP-sourced global court and player data",
"report_q1_feature2": "Playtomic/PwC club-level revenue benchmarks",
"report_q1_feature3": "Padelnomics independent pipeline: 12,441 venues",
"report_q1_email_label": "Your email",
"report_q1_cta_btn": "Download report (PDF)",
"report_q1_privacy_note": "No spam. Unsubscribe at any time.",
"report_q1_confirmed_title": "Your download is ready",
"report_q1_confirmed_body": "Click below to open the full report PDF.",
"report_q1_download_btn": "Download PDF",
"report_q1_download_note": "PDF opens in your browser. Right-click to save."
} }

View File

@@ -0,0 +1,24 @@
"""Add group_key column to articles for cross-language static article grouping.
Static cornerstone articles (e.g. padel-hall-cost-guide EN vs padel-halle-kosten DE)
have different url_paths per language, so they cannot be grouped by url_path alone.
The group_key (populated from the `cornerstone` frontmatter field, e.g. 'C2') is a
shared key that pairs EN/DE variants of the same static article.
pSEO articles leave group_key NULL and continue to group by url_path (unchanged).
Also removes any state-of-padel articles that are being moved to the reports flow.
"""
def up(conn):
conn.execute(
"ALTER TABLE articles ADD COLUMN group_key TEXT DEFAULT NULL"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_group_key"
" ON articles(group_key)"
)
# Remove state-of-padel rows — these are moving to the reports flow
# and would cause slug collisions (both EN/DE use slug 'state-of-padel-q1-2026')
conn.execute("DELETE FROM articles WHERE slug LIKE 'state-of-padel%'")

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 %}

View File

@@ -0,0 +1,549 @@
/* ============================================================
Padelnomics Market Intelligence Report — Premium PDF Stylesheet
Rendered by WeasyPrint (A4, CSS3, no JavaScript)
============================================================ */
/* ---------- Design tokens ---------- */
:root {
--navy: #0F2651;
--navy-deep: #091a3a;
--navy-mid: #1e3a6e;
--gold: #C9922C;
--gold-lt: #EDD48A;
--teal: #0D9488;
--teal-lt: #CCFBF1;
--text: #1C2333;
--muted: #5E6E8A;
--border: #DDE3EE;
--bg-light: #F6F8FC;
--white: #FFFFFF;
--green: #166534;
--green-bg: #DCFCE7;
--red: #991B1B;
--red-bg: #FEE2E2;
}
/* ---------- Page setup ---------- */
@page {
size: A4;
margin: 22mm 20mm 22mm 20mm;
@top-left {
content: string(doc-brand);
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 7pt;
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
@top-right {
content: string(doc-title);
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 7pt;
color: var(--muted);
font-style: italic;
}
@bottom-left {
content: "© 2026 Padelnomics — padelnomics.io";
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 6.5pt;
color: var(--border);
letter-spacing: 0.03em;
}
@bottom-center {
content: counter(page);
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 8pt;
color: var(--muted);
}
}
/* Cover page: full-bleed, no running headers/footers */
@page cover {
size: A4;
margin: 0;
@top-left { content: none; }
@top-right { content: none; }
@bottom-left { content: none; }
@bottom-center { content: none; }
}
/* TOC page: suppress page number */
@page toc {
@bottom-center { content: none; }
}
/* ---------- Named strings for running headers ---------- */
.doc-brand-anchor { string-set: doc-brand content(); }
.doc-title-anchor { string-set: doc-title content(); }
/* ---------- Watermark (WeasyPrint repeats position:fixed on every content page) ---------- */
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-30deg);
opacity: 0.035;
z-index: -1;
pointer-events: none;
}
.watermark img {
width: 400pt;
height: auto;
}
/* ---------- Base typography ---------- */
body {
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
font-size: 9.5pt;
line-height: 1.58;
color: var(--text);
background: var(--white);
}
p { margin: 0 0 7pt; }
/* ---------- Cover page — full-bleed navy ---------- */
.cover {
page: cover;
page-break-after: always;
width: 210mm;
height: 297mm;
background: var(--navy);
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 22mm 20mm 18mm 22mm;
}
/* Decorative geometric accent — bottom-right corner stripe */
.cover::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 90mm;
height: 90mm;
background: linear-gradient(135deg, transparent 50%, rgba(201,146,44,0.12) 50%);
pointer-events: none;
}
.cover__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.cover__logo img {
height: 22pt;
filter: brightness(0) invert(1);
opacity: 0.92;
}
.cover__edition {
font-size: 7pt;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
background: rgba(201,146,44,0.12);
padding: 3pt 9pt;
border-radius: 3pt;
border: 1pt solid rgba(201,146,44,0.3);
}
.cover__body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20mm 0;
}
.cover__type-label {
font-size: 8pt;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 12pt;
}
.cover__rule {
width: 48pt;
height: 2.5pt;
background: var(--gold);
margin-bottom: 16pt;
}
.cover__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 32pt;
font-weight: 700;
color: var(--white);
line-height: 1.18;
letter-spacing: -0.02em;
margin: 0 0 12pt;
}
.cover__subtitle {
font-size: 11pt;
color: rgba(255,255,255,0.65);
margin-bottom: 20pt;
line-height: 1.5;
}
/* 4-stat row on cover */
.cover__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
border-top: 1pt solid rgba(255,255,255,0.15);
border-left: 1pt solid rgba(255,255,255,0.15);
border-radius: 4pt;
overflow: hidden;
}
.cover__stat {
padding: 10pt 12pt;
border-right: 1pt solid rgba(255,255,255,0.15);
border-bottom: 1pt solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.04);
}
.cover__stat-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 16pt;
font-weight: 700;
color: var(--gold);
line-height: 1.1;
}
.cover__stat-label {
font-size: 6.5pt;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.5);
margin-top: 3pt;
}
.cover__footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-top: 14pt;
border-top: 1pt solid rgba(255,255,255,0.12);
}
.cover__footer-left {
font-size: 7.5pt;
color: rgba(255,255,255,0.35);
line-height: 1.6;
}
.cover__footer-right {
font-size: 7.5pt;
color: rgba(255,255,255,0.35);
text-align: right;
}
.cover__footer a {
color: var(--gold);
text-decoration: none;
opacity: 0.7;
}
/* ---------- Table of Contents ---------- */
.toc-page {
page: toc;
page-break-after: always;
}
.toc-heading {
font-family: Georgia, 'Times New Roman', serif;
font-size: 18pt;
font-weight: 700;
color: var(--navy);
margin: 0 0 18pt;
padding-bottom: 8pt;
border-bottom: 2pt solid var(--navy);
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-item {
display: flex;
align-items: baseline;
padding: 5pt 0;
border-bottom: 0.5pt dotted var(--border);
font-size: 9pt;
}
.toc-item--section {
font-weight: 700;
color: var(--navy);
padding-top: 10pt;
border-bottom: none;
font-size: 9.5pt;
padding-bottom: 2pt;
}
.toc-num {
font-size: 8pt;
color: var(--gold);
font-weight: 700;
width: 22pt;
flex-shrink: 0;
}
.toc-text { flex: 1; color: var(--text); }
.toc-dots {
flex: 1;
border-bottom: 1pt dotted var(--border);
margin: 0 6pt 2pt;
min-width: 20pt;
}
/* ---------- Section headings ---------- */
h1 {
font-family: Georgia, 'Times New Roman', serif;
font-size: 20pt;
font-weight: 700;
color: var(--navy);
margin: 0 0 6pt;
letter-spacing: -0.02em;
}
h2 {
font-family: Georgia, 'Times New Roman', serif;
font-size: 14pt;
font-weight: 700;
color: var(--navy);
margin: 0 0 10pt;
padding: 8pt 0 8pt 12pt;
border-left: 3.5pt solid var(--gold);
page-break-after: avoid;
break-after: avoid;
}
h3 {
font-size: 8.5pt;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.09em;
margin: 14pt 0 5pt;
page-break-after: avoid;
}
.section {
margin-top: 22pt;
page-break-inside: avoid;
}
.section--break {
page-break-before: always;
break-before: page;
margin-top: 0;
padding-top: 6pt;
}
/* ---------- Key metrics grid ---------- */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 7pt;
margin: 8pt 0 12pt;
}
.metric-box {
border: 1pt solid var(--border);
border-top: 3pt solid var(--navy);
border-radius: 0 0 4pt 4pt;
padding: 8pt 9pt 7pt;
text-align: center;
background: var(--white);
page-break-inside: avoid;
}
.metric-box--gold { border-top-color: var(--gold); }
.metric-box--teal { border-top-color: var(--teal); }
.metric-label {
font-size: 6.5pt;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin-bottom: 4pt;
}
.metric-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 16pt;
font-weight: 700;
color: var(--navy);
line-height: 1.1;
}
.metric-value--gold { color: var(--gold); }
.metric-value--teal { color: var(--teal); }
.metric-sub {
font-size: 7pt;
color: var(--muted);
margin-top: 2pt;
}
/* ---------- Tables ---------- */
table {
width: 100%;
border-collapse: collapse;
margin: 6pt 0 12pt;
font-size: 8.5pt;
page-break-inside: auto;
}
thead th {
background: var(--navy);
color: var(--white);
font-size: 7pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 5.5pt 8pt;
text-align: left;
}
tbody tr:nth-child(even) { background: var(--bg-light); }
tbody tr:nth-child(odd) { background: var(--white); }
tbody td {
padding: 5pt 8pt;
border-bottom: 0.5pt solid var(--border);
color: var(--text);
vertical-align: top;
}
tbody tr:last-child td { border-bottom: none; }
.total-row td {
font-weight: 700;
color: var(--navy);
background: #E8EDF5 !important;
border-top: 1.5pt solid var(--navy);
border-bottom: none;
}
.highlight-row td {
background: #FEF9EE !important;
font-weight: 700;
color: var(--navy);
border-top: 1pt solid var(--gold);
border-bottom: 1pt solid var(--gold);
}
.positive-cell { color: var(--green); font-weight: 600; }
.negative-cell { color: var(--red); font-weight: 600; }
td.r, th.r { text-align: right; }
td.c, th.c { text-align: center; }
/* ---------- Callout / pull quote ---------- */
blockquote {
border-left: 4pt solid var(--teal);
background: var(--teal-lt);
padding: 9pt 12pt;
margin: 8pt 0 12pt;
font-style: italic;
font-size: 9.5pt;
color: var(--text);
page-break-inside: avoid;
}
blockquote p { margin: 0; }
/* ---------- Insight box (key takeaway) ---------- */
.insight-box {
border: 1pt solid var(--gold);
border-left: 4pt solid var(--gold);
background: #FEF9EE;
padding: 9pt 12pt;
margin: 8pt 0 12pt;
font-size: 9pt;
page-break-inside: avoid;
}
.insight-box strong {
display: block;
color: var(--navy);
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 3pt;
}
/* ---------- Market callout cards ---------- */
.market-callout {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8pt;
margin: 8pt 0 12pt;
}
.market-stat {
background: var(--bg-light);
border: 1pt solid var(--border);
border-radius: 4pt;
padding: 8pt 10pt;
text-align: center;
page-break-inside: avoid;
}
.market-stat-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 14pt;
font-weight: 700;
color: var(--navy);
}
.market-stat-label {
font-size: 7pt;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2pt;
}
/* ---------- Data note / footnote ---------- */
.data-note {
font-size: 7.5pt;
color: var(--muted);
font-style: italic;
margin: -8pt 0 10pt;
padding-left: 6pt;
border-left: 2pt solid var(--border);
}
/* ---------- Two-column layout helper ---------- */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12pt;
}
/* ---------- Disclaimer / legal ---------- */
.disclaimer {
font-size: 7pt;
color: var(--muted);
line-height: 1.55;
padding-top: 10pt;
border-top: 0.5pt solid var(--border);
margin-top: 20pt;
}

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="{{ language }}">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>{{ css }}</style>
</head>
<body>
{# ── Named strings for running headers (invisible anchors) ── #}
<span class="doc-brand-anchor">Padelnomics</span>
<span class="doc-title-anchor">{{ report_slug_label }}</span>
{# ── Watermark: Padelnomics logo, repeated on every content page via position:fixed ── #}
<div class="watermark">
<img src="{{ logo_path }}" alt="">
</div>
{# ══════════════════════════════════════════════════════════════════
COVER PAGE — full-bleed navy, no running headers
════════════════════════════════════════════════════════════════ #}
<div class="cover">
<div class="cover__header">
<div class="cover__logo">
<img src="{{ logo_path }}" alt="Padelnomics">
</div>
<div class="cover__edition">{{ edition_label }}</div>
</div>
<div class="cover__body">
<div class="cover__type-label">{{ report_type_label }}</div>
<div class="cover__rule"></div>
<h1 class="cover__title">{{ title }}</h1>
<p class="cover__subtitle">{{ subtitle }}</p>
<div class="cover__stats">
{% for stat in cover_stats %}
<div class="cover__stat">
<div class="cover__stat-value">{{ stat.value }}</div>
<div class="cover__stat-label">{{ stat.label }}</div>
</div>
{% endfor %}
</div>
</div>
<div class="cover__footer">
<div class="cover__footer-left">
{{ published_label }}<br>
<a href="https://padelnomics.io">padelnomics.io</a>
</div>
<div class="cover__footer-right">
{{ confidential_label }}
</div>
</div>
</div>
{# ══════════════════════════════════════════════════════════════════
TABLE OF CONTENTS
════════════════════════════════════════════════════════════════ #}
{% if toc %}
<div class="toc-page">
<h2 class="toc-heading">{{ toc_heading }}</h2>
<ol class="toc-list">
{% for item in toc %}
<li class="toc-item{% if item.is_section %} toc-item--section{% endif %}">
{% if not item.is_section %}<span class="toc-num">{{ loop.index }}.</span>{% endif %}
<span class="toc-text">{{ item.title }}</span>
{% if not item.is_section %}
<span class="toc-dots"></span>
{% endif %}
</li>
{% endfor %}
</ol>
</div>
{% endif %}
{# ══════════════════════════════════════════════════════════════════
REPORT BODY (markdown → HTML, injected by build script)
════════════════════════════════════════════════════════════════ #}
<div class="report-body">
{{ body_html }}
</div>
{# ══════════════════════════════════════════════════════════════════
DISCLAIMER
════════════════════════════════════════════════════════════════ #}
<div class="disclaimer">
{{ disclaimer }}
</div>
</body>
</html>