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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -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
|
||||||
|
|||||||
185
web/scripts/build_report_pdf.py
Normal file
185
web/scripts/build_report_pdf.py
Normal 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()
|
||||||
@@ -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 *,
|
||||||
CASE WHEN status = 'published' AND published_at > datetime('now')
|
COALESCE(group_key, url_path) AS group_id,
|
||||||
THEN 'scheduled'
|
CASE WHEN status = 'published' AND published_at > datetime('now')
|
||||||
WHEN status = 'published' THEN 'live'
|
THEN 'scheduled'
|
||||||
ELSE status END AS display_status
|
WHEN status = 'published' THEN 'live'
|
||||||
FROM articles WHERE url_path IN ({placeholders})
|
ELSE status END AS display_status
|
||||||
ORDER BY url_path, language""",
|
FROM articles
|
||||||
tuple(url_paths),
|
WHERE COALESCE(group_key, url_path) IN ({placeholders})
|
||||||
|
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", "")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 3–30 Tage nach dem Freischalten verfügbar).",
|
"sd_guarantee_window_error": "Garantiezeitraum abgelaufen (nur 3–30 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."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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%'")
|
||||||
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 %}
|
||||||
549
web/src/padelnomics/templates/reports/report.css
Normal file
549
web/src/padelnomics/templates/reports/report.css
Normal 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;
|
||||||
|
}
|
||||||
94
web/src/padelnomics/templates/reports/report.html
Normal file
94
web/src/padelnomics/templates/reports/report.html
Normal 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>
|
||||||
Reference in New Issue
Block a user