diff --git a/.gitignore b/.gitignore index df3ed51..04a3164 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ build/ # Local binaries (tailwindcss, sops, age) bin/ web/src/padelnomics/static/css/output.css + +# Generated report PDFs (built locally via make report-pdf, not committed) +data/content/reports/_build/ diff --git a/Makefile b/Makefile index 2de2240..96aca51 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ TAILWIND_VERSION := v4.1.18 TAILWIND := ./bin/tailwindcss 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-edit-dev secrets-edit-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 " css-build Build + minify 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-prod Decrypt .env.prod.sops → .env" @echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR" @@ -47,6 +48,9 @@ css-build: bin/tailwindcss css-watch: bin/tailwindcss $(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) ───────────────────────────────────────────────────── # Requires: sops (https://github.com/getsops/sops) + age (https://github.com/FiloSottile/age) # Keys config: .sops.yaml diff --git a/data/content/articles/state-of-padel-q1-2026-de.md b/data/content/reports/state-of-padel-q1-2026-de.md similarity index 100% rename from data/content/articles/state-of-padel-q1-2026-de.md rename to data/content/reports/state-of-padel-q1-2026-de.md diff --git a/data/content/articles/state-of-padel-q1-2026-en.md b/data/content/reports/state-of-padel-q1-2026-en.md similarity index 100% rename from data/content/articles/state-of-padel-q1-2026-en.md rename to data/content/reports/state-of-padel-q1-2026-en.md diff --git a/web/scripts/build_report_pdf.py b/web/scripts/build_report_pdf.py new file mode 100644 index 0000000..2015480 --- /dev/null +++ b/web/scripts/build_report_pdf.py @@ -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/-.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() diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 3f33fcb..8788711 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -5,6 +5,8 @@ import csv import io import json import logging +import os +import re from datetime import date, timedelta from pathlib import Path @@ -2200,6 +2202,82 @@ async def scenario_pdf(scenario_id: int): # 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( status: str = None, template_slug: str = None, @@ -2251,7 +2329,12 @@ async def _get_article_list_grouped( page: int = 1, per_page: int = 50, ) -> 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"] params: list = [] @@ -2271,43 +2354,48 @@ async def _get_article_list_grouped( where = " AND ".join(wheres) offset = (page - 1) * per_page - # Group by url_path — language variants share the same url_path (no lang prefix stored) - path_rows = await fetch_all( - f"""SELECT url_path, MAX(created_at) AS latest_created + # First pass: paginate over distinct group keys + group_rows = await fetch_all( + f"""SELECT COALESCE(group_key, url_path) AS group_id, + MAX(created_at) AS latest_created FROM articles WHERE {where} - GROUP BY url_path + GROUP BY COALESCE(group_key, url_path) ORDER BY latest_created DESC LIMIT ? OFFSET ?""", tuple(params + [per_page, offset]), ) - if not path_rows: + if not group_rows: return [] - url_paths = [r["url_path"] for r in path_rows] - placeholders = ",".join("?" * len(url_paths)) + group_ids = [r["group_id"] for r in group_rows] + placeholders = ",".join("?" * len(group_ids)) + + # Second pass: fetch all variants for the paginated groups variants = await fetch_all( f"""SELECT *, - CASE WHEN status = 'published' AND published_at > datetime('now') - THEN 'scheduled' - WHEN status = 'published' THEN 'live' - ELSE status END AS display_status - FROM articles WHERE url_path IN ({placeholders}) - ORDER BY url_path, language""", - tuple(url_paths), + COALESCE(group_key, url_path) AS group_id, + CASE WHEN status = 'published' AND published_at > datetime('now') + THEN 'scheduled' + WHEN status = 'published' THEN 'live' + ELSE status END AS display_status + FROM articles + 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: - by_path.setdefault(v["url_path"], []).append(dict(v)) + by_group.setdefault(v["group_id"], []).append(dict(v)) groups = [] - for url_path in url_paths: - variant_list = by_path.get(url_path, []) + for gid in group_ids: + variant_list = by_group.get(gid, []) if not variant_list: continue primary = next((v for v in variant_list if v["language"] == "en"), variant_list[0]) groups.append({ - "url_path": url_path, + "url_path": primary["url_path"], "title": primary["title"], "published_at": primary["published_at"], "template_slug": primary["template_slug"], @@ -2341,7 +2429,8 @@ async def _is_generating() -> bool: @bp.route("/articles") @role_required("admin") 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() status_filter = request.args.get("status", "") template_filter = request.args.get("template", "") diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index fbcf74b..0278b2c 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -324,6 +324,7 @@ def create_app() -> Quart: from .leads.routes import bp as leads_bp from .planner.routes import bp as planner_bp from .public.routes import bp as public_bp + from .reports.routes import bp as reports_bp from .suppliers.routes import bp as suppliers_bp from .webhooks import bp as webhooks_bp @@ -333,6 +334,7 @@ def create_app() -> Quart: app.register_blueprint(directory_bp, url_prefix="//directory") app.register_blueprint(leads_bp, url_prefix="//leads") app.register_blueprint(suppliers_bp, url_prefix="//suppliers") + app.register_blueprint(reports_bp, url_prefix="//reports") # Non-prefixed blueprints (internal / behind auth) app.register_blueprint(auth_bp) diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index 952302d..5b84376 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -22,7 +22,7 @@ BUILD_DIR = Path("data/content/_build") RESERVED_PREFIXES = ( "/admin", "/auth", "/planner", "/billing", "/dashboard", - "/directory", "/leads", "/suppliers", "/health", + "/directory", "/leads", "/suppliers", "/reports", "/health", "/sitemap", "/static", "/features", "/feedback", ) diff --git a/web/src/padelnomics/locales/de.json b/web/src/padelnomics/locales/de.json index 0de3885..e5f9440 100644 --- a/web/src/padelnomics/locales/de.json +++ b/web/src/padelnomics/locales/de.json @@ -1748,5 +1748,38 @@ "sd_guarantee_submit": "Credits zurückbuchen", "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_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." } \ No newline at end of file diff --git a/web/src/padelnomics/locales/en.json b/web/src/padelnomics/locales/en.json index 3bcdc71..7360a55 100644 --- a/web/src/padelnomics/locales/en.json +++ b/web/src/padelnomics/locales/en.json @@ -1751,5 +1751,38 @@ "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_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." } \ No newline at end of file diff --git a/web/src/padelnomics/migrations/versions/0020_articles_group_key.py b/web/src/padelnomics/migrations/versions/0020_articles_group_key.py new file mode 100644 index 0000000..dfa2bfb --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0020_articles_group_key.py @@ -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%'") diff --git a/web/src/padelnomics/reports/__init__.py b/web/src/padelnomics/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/src/padelnomics/reports/routes.py b/web/src/padelnomics/reports/routes.py new file mode 100644 index 0000000..c46d05d --- /dev/null +++ b/web/src/padelnomics/reports/routes.py @@ -0,0 +1,119 @@ +""" +Reports domain: email-gated PDF market intelligence reports. + +Flow: + GET //reports/ → landing page with teaser + email form + POST //reports/ → capture email → re-render with download button + GET //reports//download → serve pre-built PDF +""" + +import logging +from pathlib import Path + +from quart import Blueprint, Response, abort, g, render_template, request + +from ..core import capture_waitlist_email, csrf_protect +from ..i18n import get_translations + +logger = logging.getLogger(__name__) + +bp = Blueprint( + "reports", + __name__, + template_folder=str(Path(__file__).parent / "templates"), + url_prefix="/reports", +) + +# Registry of available reports. Key = slug, value = per-lang PDF filenames. +# PDFs live in data/content/reports/_build/. +REPORT_REGISTRY: dict[str, dict[str, str]] = { + "q1-2026": { + "en": "state-of-padel-q1-2026-en.pdf", + "de": "state-of-padel-q1-2026-de.pdf", + "title_en": "State of Padel Q1 2026: Global Market Report", + "title_de": "State of Padel Q1 2026: Weltmarktbericht", + }, +} + +_BUILD_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "content" / "reports" / "_build" + + +def _get_report(slug: str) -> dict | None: + return REPORT_REGISTRY.get(slug) + + +def _pdf_path(slug: str, lang: str) -> Path | None: + report = _get_report(slug) + if not report: + return None + filename = report.get(lang) or report.get("en") + if not filename: + return None + return _BUILD_DIR / filename + + +@bp.get("/") +async def landing(slug: str) -> str: + """Report landing page with teaser stats and email capture form.""" + report = _get_report(slug) + if not report: + abort(404) + lang = g.get("lang", "en") + t = get_translations(lang) + title = report.get(f"title_{lang}") or report.get("title_en", "") + return await render_template( + "reports/report_landing.html", + slug=slug, + report=report, + title=title, + lang=lang, + t=t, + confirmed=False, + ) + + +@bp.post("/") +@csrf_protect +async def capture(slug: str) -> str: + """Capture email and re-render with download button.""" + report = _get_report(slug) + if not report: + abort(404) + lang = g.get("lang", "en") + t = get_translations(lang) + title = report.get(f"title_{lang}") or report.get("title_en", "") + + form = await request.form + email = (form.get("email") or "").strip().lower() + if email: + await capture_waitlist_email(email, intent="report", plan=slug) + + return await render_template( + "reports/report_landing.html", + slug=slug, + report=report, + title=title, + lang=lang, + t=t, + confirmed=bool(email), + ) + + +@bp.get("//download") +async def download(slug: str) -> Response: + """Serve pre-built PDF for a given report slug and language.""" + report = _get_report(slug) + if not report: + abort(404) + lang = g.get("lang", "en") + pdf_path = _pdf_path(slug, lang) + if not pdf_path or not pdf_path.exists(): + abort(404) + + pdf_bytes = pdf_path.read_bytes() + filename = pdf_path.name + return Response( + pdf_bytes, + mimetype="application/pdf", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/web/src/padelnomics/reports/templates/reports/report_landing.html b/web/src/padelnomics/reports/templates/reports/report_landing.html new file mode 100644 index 0000000..7ef2351 --- /dev/null +++ b/web/src/padelnomics/reports/templates/reports/report_landing.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} — {{ config.APP_NAME }}{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +
+ + {# ── Report hero ── #} +
+

{{ t.report_q1_eyebrow }}

+

{{ title }}

+

{{ t.report_q1_subtitle }}

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

{{ t.report_q1_toc_heading }}

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

{{ t.report_q1_toc_note }}

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

{{ t.report_q1_sources }}

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

{{ t.report_q1_confirmed_title }}

+

{{ t.report_q1_confirmed_body }}

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

{{ t.report_q1_download_note }}

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

{{ t.report_q1_gate_title }}

+

{{ t.report_q1_gate_body }}

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

{{ t.report_q1_privacy_note }}

+ + {% endif %} + +
+
+ +
+
+{% endblock %} diff --git a/web/src/padelnomics/templates/reports/report.css b/web/src/padelnomics/templates/reports/report.css new file mode 100644 index 0000000..98354e5 --- /dev/null +++ b/web/src/padelnomics/templates/reports/report.css @@ -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; +} diff --git a/web/src/padelnomics/templates/reports/report.html b/web/src/padelnomics/templates/reports/report.html new file mode 100644 index 0000000..d886c20 --- /dev/null +++ b/web/src/padelnomics/templates/reports/report.html @@ -0,0 +1,94 @@ + + + + + {{ title }} + + + + +{# ── Named strings for running headers (invisible anchors) ── #} +Padelnomics +{{ report_slug_label }} + +{# ── Watermark: Padelnomics logo, repeated on every content page via position:fixed ── #} +
+ +
+ +{# ══════════════════════════════════════════════════════════════════ + COVER PAGE — full-bleed navy, no running headers + ════════════════════════════════════════════════════════════════ #} +
+ +
+ +
{{ edition_label }}
+
+ +
+
{{ report_type_label }}
+
+

{{ title }}

+

{{ subtitle }}

+ +
+ {% for stat in cover_stats %} +
+
{{ stat.value }}
+
{{ stat.label }}
+
+ {% endfor %} +
+
+ + + +
+ +{# ══════════════════════════════════════════════════════════════════ + TABLE OF CONTENTS + ════════════════════════════════════════════════════════════════ #} +{% if toc %} +
+

{{ toc_heading }}

+
    + {% for item in toc %} +
  1. + {% if not item.is_section %}{{ loop.index }}.{% endif %} + {{ item.title }} + {% if not item.is_section %} + + {% endif %} +
  2. + {% endfor %} +
+
+{% endif %} + +{# ══════════════════════════════════════════════════════════════════ + REPORT BODY (markdown → HTML, injected by build script) + ════════════════════════════════════════════════════════════════ #} +
+ {{ body_html }} +
+ +{# ══════════════════════════════════════════════════════════════════ + DISCLAIMER + ════════════════════════════════════════════════════════════════ #} +
+ {{ disclaimer }} +
+ + +