feat: i18n URL prefixes + German legal pages

All public-facing blueprints (public, planner, directory, content,
leads, suppliers) now serve under /<lang>/ (e.g. /en/, /de/). Internal
blueprints (auth, dashboard, admin, billing) are unchanged.

URL routing:
- Root / detects lang from cookie → Accept-Language → default 'en'
  and 301-redirects to /<lang>/
- Quart url_value_preprocessor pops <lang> into g.lang; url_defaults
  auto-injects it so existing url_for() calls need no changes
- Unsupported lang prefixes (e.g. /fr/) return 404
- Legacy bare URLs (/terms, /privacy, /imprint, /about, /features,
  /suppliers) redirect 301 to /en/ equivalents
- robots.txt and sitemap.xml moved to app-level root; sitemap now
  includes both en and de variants of every SEO page
- lang cookie persisted 1 year, SameSite=Lax

i18n:
- New i18n.py: SUPPORTED_LANGS, LANG_BLUEPRINTS, flat translation dicts
  for ~20 nav/footer keys in en + de
- lang and t injected into every template context

Templates:
- base.html: <html lang="{{ lang }}">, hreflang tags (en/de/x-default)
  on lang-prefixed pages, nav/footer strings translated via t.*, footer
  language toggle EN | DE, SVG racket logo removed from footer
- 6 legal templates (terms/privacy/imprint × en/de) replacing old 3:
  - English: GDPR sections with correct controller identity (Hendrik
    Dreesmann, Zum Offizierskasino 1, 26127 Oldenburg), real sub-
    processors (Umami self-hosted, Paddle, Resend with SCCs), German-
    law jurisdiction
  - German: DSGVO-konforme Datenschutzerklärung, AGB, Impressum per
    § 5 DDG; Kleinunternehmer § 19 UStG; LfD Niedersachsen reference

Tests updated to use /en/ prefixed routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-20 02:28:59 +01:00
parent b19b6b907c
commit b416cd682a
18 changed files with 963 additions and 444 deletions

View File

@@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- i18n URL prefixes: all public-facing blueprints (`public`, `planner`, `directory`, `content`, `leads`, `suppliers`) now live under `/<lang>/` (e.g. `/en/`, `/de/`); internal blueprints (`auth`, `dashboard`, `admin`, `billing`) unchanged; root `/` detects language from cookie / Accept-Language header and 301-redirects; legacy URLs (`/terms`, `/privacy`, etc.) redirect to `/en/` equivalents
- German legal pages: full DSGVO-compliant `Datenschutzerklärung` (`/de/privacy`), `AGB` (`/de/terms`), and `Impressum` (`/de/imprint`) per § 5 DDG — populated with Hendrik Dreesmann's details, Kleinunternehmer § 19 UStG, Oldenburg address
- Rewritten English legal pages (`/en/terms`, `/en/privacy`, `/en/imprint`) with expanded GDPR sections, correct controller identity, proper data-processing details (Umami self-hosted, Paddle, Resend with SCCs), and German-law jurisdiction
- Language toggle (`EN | DE`) in footer; hreflang `en`, `de`, and `x-default` tags in `<head>` on all lang-prefixed pages
- `lang` cookie (1-year, SameSite=Lax) persisted on first visit; `lang` and `t` (translation dict) injected into every template context
- `i18n.py`: flat translation dicts for ~20 nav/footer keys in `en` and `de`; `LANG_BLUEPRINTS` and `SUPPORTED_LANGS` constants
- `sitemap.xml` and `robots.txt` moved to app-level root routes (not under `/<lang>`); sitemap now includes both language variants of every SEO page
- Cookie consent banner: fixed bottom bar with "Accept all" and "Manage preferences" (toggle for functional/A/B cookies); consent stored in `cookie_consent` cookie for 1 year; "Manage Cookies" link added to footer Legal section
### Changed

View File

@@ -3,9 +3,23 @@ Padelnomics - Application factory and entry point.
"""
from pathlib import Path
from quart import Quart, g, session
from quart import Quart, Response, abort, g, redirect, request, session, url_for
from .core import close_db, config, get_csrf_token, init_db, setup_request_id
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
def _detect_lang() -> str:
"""Detect preferred language from cookie then Accept-Language header."""
cookie_lang = request.cookies.get("lang", "")
if cookie_lang in SUPPORTED_LANGS:
return cookie_lang
accept = request.headers.get("Accept-Language", "")
for part in accept.split(","):
tag = part.split(";")[0].strip()[:2].lower()
if tag in SUPPORTED_LANGS:
return tag
return "en"
def create_app() -> Quart:
@@ -27,7 +41,26 @@ def create_app() -> Quart:
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * config.SESSION_LIFETIME_DAYS
# -------------------------------------------------------------------------
# Language URL routing
# -------------------------------------------------------------------------
@app.url_value_preprocessor
def pull_lang(endpoint, values):
"""Pop <lang> from URL values and stash in g.lang."""
if values and "lang" in values:
g.lang = values.pop("lang")
@app.url_defaults
def inject_lang(endpoint, values):
"""Auto-inject g.lang into url_for() calls on lang-prefixed blueprints."""
if endpoint and endpoint.partition(".")[0] in LANG_BLUEPRINTS:
values.setdefault("lang", g.get("lang", "en"))
# -------------------------------------------------------------------------
# Database lifecycle
# -------------------------------------------------------------------------
@app.before_serving
async def startup():
await init_db()
@@ -36,19 +69,20 @@ def create_app() -> Quart:
async def shutdown():
await close_db()
# Security headers
@app.after_request
async def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
if not config.DEBUG:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# -------------------------------------------------------------------------
# Per-request hooks
# -------------------------------------------------------------------------
@app.before_request
async def validate_lang():
"""404 unsupported language prefixes (e.g. /fr/terms)."""
lang = g.get("lang")
if lang is not None and lang not in SUPPORTED_LANGS:
abort(404)
# Load current user + subscription + roles before each request
@app.before_request
async def load_user():
"""Load current user + subscription + roles before each request."""
g.user = None
g.subscription = None
user_id = session.get("user_id")
@@ -81,10 +115,40 @@ def create_app() -> Quart:
"current_period_end": row["current_period_end"],
}
@app.after_request
async def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
if not config.DEBUG:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.after_request
async def set_lang_cookie(response):
"""Persist detected/current language in a long-lived cookie."""
lang = g.get("lang")
if lang and lang in SUPPORTED_LANGS:
current_cookie = request.cookies.get("lang", "")
if current_cookie != lang:
response.set_cookie(
"lang", lang,
max_age=60 * 60 * 24 * 365, # 1 year
samesite="Lax",
secure=not config.DEBUG,
httponly=False, # JS may read for analytics
)
return response
# -------------------------------------------------------------------------
# Template context globals
# -------------------------------------------------------------------------
@app.context_processor
def inject_globals():
from datetime import datetime
lang = g.get("lang", "en")
effective_lang = lang if lang in SUPPORTED_LANGS else "en"
return {
"config": config,
"user": g.get("user"),
@@ -94,8 +158,92 @@ def create_app() -> Quart:
"csrf_token": get_csrf_token,
"ab_variant": getattr(g, "ab_variant", None),
"ab_tag": getattr(g, "ab_tag", None),
"lang": effective_lang,
"t": get_translations(effective_lang),
}
# -------------------------------------------------------------------------
# App-level routes (no lang prefix)
# -------------------------------------------------------------------------
# Root: detect language and redirect
@app.route("/")
async def root():
lang = _detect_lang()
return redirect(url_for("public.landing", lang=lang), 301)
# robots.txt must live at root (not under /<lang>)
@app.route("/robots.txt")
async def robots_txt():
base = config.BASE_URL.rstrip("/")
body = (
"User-agent: *\n"
"Disallow: /admin/\n"
"Disallow: /auth/\n"
"Disallow: /dashboard/\n"
"Disallow: /billing/\n"
"Disallow: /directory/results\n"
f"Sitemap: {base}/sitemap.xml\n"
)
return Response(body, content_type="text/plain")
# sitemap.xml must live at root
@app.route("/sitemap.xml")
async def sitemap():
from datetime import UTC, datetime
from .core import fetch_all
base = config.BASE_URL.rstrip("/")
today = datetime.now(UTC).strftime("%Y-%m-%d")
# Both language variants of all SEO pages
static_paths = [
"", # landing
"/features",
"/about",
"/terms",
"/privacy",
"/imprint",
"/suppliers",
"/markets",
]
entries: list[tuple[str, str]] = []
for path in static_paths:
for lang in ("en", "de"):
entries.append((f"{base}/{lang}{path}", today))
# Planner + directory lang variants, billing (no lang)
for lang in ("en", "de"):
entries.append((f"{base}/{lang}/planner/", today))
entries.append((f"{base}/{lang}/directory/", today))
entries.append((f"{base}/billing/pricing", today))
# Published articles — both lang variants
articles = await fetch_all(
"""SELECT url_path, COALESCE(updated_at, published_at) as lastmod
FROM articles
WHERE status = 'published' AND published_at <= datetime('now')
ORDER BY published_at DESC"""
)
for article in articles:
lastmod = article["lastmod"][:10] if article["lastmod"] else today
for lang in ("en", "de"):
entries.append((f"{base}/{lang}{article['url_path']}", lastmod))
# Supplier detail pages (English only — canonical)
suppliers = await fetch_all(
"SELECT slug, created_at FROM suppliers ORDER BY name LIMIT 5000"
)
for supplier in suppliers:
lastmod = supplier["created_at"][:10] if supplier["created_at"] else today
entries.append((f"{base}/en/directory/{supplier['slug']}", lastmod))
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for loc, lastmod in entries:
xml += f" <url><loc>{loc}</loc><lastmod>{lastmod}</lastmod></url>\n"
xml += "</urlset>"
return Response(xml, content_type="application/xml")
# Health check
@app.route("/health")
async def health():
@@ -106,7 +254,35 @@ def create_app() -> Quart:
except Exception as e:
return {"status": "unhealthy", "db": str(e)}, 500
# Register blueprints
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
@app.route("/terms")
async def legacy_terms():
return redirect("/en/terms", 301)
@app.route("/privacy")
async def legacy_privacy():
return redirect("/en/privacy", 301)
@app.route("/imprint")
async def legacy_imprint():
return redirect("/en/imprint", 301)
@app.route("/about")
async def legacy_about():
return redirect("/en/about", 301)
@app.route("/features")
async def legacy_features():
return redirect("/en/features", 301)
@app.route("/suppliers")
async def legacy_suppliers():
return redirect("/en/suppliers", 301)
# -------------------------------------------------------------------------
# Blueprint registration
# -------------------------------------------------------------------------
from .admin.routes import bp as admin_bp
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
@@ -118,16 +294,21 @@ def create_app() -> Quart:
from .public.routes import bp as public_bp
from .suppliers.routes import bp as suppliers_bp
app.register_blueprint(public_bp)
# Lang-prefixed blueprints (SEO-relevant, public-facing)
app.register_blueprint(public_bp, url_prefix="/<lang>")
app.register_blueprint(planner_bp, url_prefix="/<lang>/planner")
app.register_blueprint(directory_bp, url_prefix="/<lang>/directory")
app.register_blueprint(leads_bp, url_prefix="/<lang>/leads")
app.register_blueprint(suppliers_bp, url_prefix="/<lang>/suppliers")
# Non-prefixed blueprints (internal / behind auth)
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(planner_bp)
app.register_blueprint(leads_bp)
app.register_blueprint(directory_bp)
app.register_blueprint(suppliers_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(content_bp) # last — catch-all route
# Content catch-all LAST — lives under /<lang> too
app.register_blueprint(content_bp, url_prefix="/<lang>")
# Request ID tracking
setup_request_id(app)

View File

@@ -22,8 +22,7 @@ BUILD_DIR = Path("data/content/_build")
RESERVED_PREFIXES = (
"/admin", "/auth", "/planner", "/billing", "/dashboard",
"/directory", "/leads", "/suppliers", "/health",
"/sitemap", "/static", "/markets", "/features",
"/terms", "/privacy", "/about", "/imprint", "/feedback",
"/sitemap", "/static", "/markets", "/features", "/feedback",
)
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')

View File

@@ -0,0 +1,70 @@
"""
i18n support: supported languages and UI translation strings.
No external dependencies — flat dicts, ~20 keys each.
"""
SUPPORTED_LANGS = {"en", "de"}
# Blueprints that carry a /<lang> URL prefix (SEO-relevant, public-facing).
# Internal blueprints (auth, dashboard, admin, billing) are NOT in this set.
LANG_BLUEPRINTS = {"public", "planner", "directory", "content", "leads", "suppliers"}
_TRANSLATIONS: dict[str, dict[str, str]] = {
"en": {
"nav_planner": "Planner",
"nav_quotes": "Get Quotes",
"nav_directory": "Directory",
"nav_markets": "Markets",
"nav_suppliers": "For Suppliers",
"nav_help": "Help",
"nav_feedback": "Feedback",
"nav_send": "Send",
"nav_signin": "Sign In",
"nav_signout": "Sign Out",
"nav_dashboard": "Dashboard",
"nav_admin": "Admin",
"footer_tagline": "Plan, finance, and build your padel business.",
"footer_product": "Product",
"footer_legal": "Legal",
"footer_company": "Company",
"footer_rights": "All rights reserved.",
"link_terms": "Terms",
"link_privacy": "Privacy",
"link_imprint": "Imprint",
"lang_switch_label": "DE",
},
"de": {
"nav_planner": "Planer",
"nav_quotes": "Angebote",
"nav_directory": "Verzeichnis",
"nav_markets": "Märkte",
"nav_suppliers": "Für Anbieter",
"nav_help": "Hilfe",
"nav_feedback": "Feedback",
"nav_send": "Senden",
"nav_signin": "Anmelden",
"nav_signout": "Abmelden",
"nav_dashboard": "Dashboard",
"nav_admin": "Admin",
"footer_tagline": "Plane, finanziere und baue dein Padel-Business.",
"footer_product": "Produkt",
"footer_legal": "Rechtliches",
"footer_company": "Unternehmen",
"footer_rights": "Alle Rechte vorbehalten.",
"link_terms": "AGB",
"link_privacy": "Datenschutz",
"link_imprint": "Impressum",
"lang_switch_label": "EN",
},
}
def get_translations(lang: str) -> dict[str, str]:
"""Return UI translation strings for the given language.
Falls back to English for unsupported languages (should never happen
in practice since we 404 unsupported langs in before_request).
"""
assert lang in _TRANSLATIONS, f"Unknown lang: {lang!r}"
return _TRANSLATIONS[lang]

View File

@@ -3,11 +3,9 @@ Public domain: landing page, marketing pages, legal pages, feedback.
"""
from pathlib import Path
from datetime import UTC, datetime
from quart import Blueprint, g, render_template, request, session
from quart import Blueprint, Response, render_template, request, session
from ..core import check_rate_limit, config, csrf_protect, execute, fetch_all, fetch_one
from ..core import check_rate_limit, csrf_protect, execute, fetch_all, fetch_one
bp = Blueprint(
"public",
@@ -45,12 +43,14 @@ async def features():
@bp.route("/terms")
async def terms():
return await render_template("terms.html")
lang = g.get("lang", "en")
return await render_template(f"terms_{lang}.html")
@bp.route("/privacy")
async def privacy():
return await render_template("privacy.html")
lang = g.get("lang", "en")
return await render_template(f"privacy_{lang}.html")
@bp.route("/about")
@@ -60,7 +60,8 @@ async def about():
@bp.route("/imprint")
async def imprint():
return await render_template("imprint.html")
lang = g.get("lang", "en")
return await render_template(f"imprint_{lang}.html")
@bp.route("/suppliers")
@@ -101,68 +102,6 @@ async def suppliers():
)
@bp.route("/robots.txt")
async def robots_txt():
base = config.BASE_URL.rstrip("/")
body = (
"User-agent: *\n"
"Disallow: /admin/\n"
"Disallow: /auth/\n"
"Disallow: /dashboard/\n"
"Disallow: /billing/\n"
"Disallow: /directory/results\n"
f"Sitemap: {base}/sitemap.xml\n"
)
return Response(body, content_type="text/plain")
@bp.route("/sitemap.xml")
async def sitemap():
base = config.BASE_URL.rstrip("/")
today = datetime.now(UTC).strftime("%Y-%m-%d")
# (loc, lastmod) pairs
entries: list[tuple[str, str]] = [
(f"{base}/", today),
(f"{base}/features", today),
(f"{base}/about", today),
(f"{base}/planner/", today),
(f"{base}/directory/", today),
(f"{base}/suppliers", today),
(f"{base}/billing/pricing", today),
(f"{base}/terms", today),
(f"{base}/privacy", today),
(f"{base}/imprint", today),
(f"{base}/markets", today),
]
# Add published articles with lastmod
articles = await fetch_all(
"""SELECT url_path, COALESCE(updated_at, published_at) as lastmod
FROM articles
WHERE status = 'published' AND published_at <= datetime('now')
ORDER BY published_at DESC"""
)
for article in articles:
lastmod = article["lastmod"][:10] if article["lastmod"] else today
entries.append((f"{base}{article['url_path']}", lastmod))
# Add supplier detail pages with lastmod
suppliers = await fetch_all(
"SELECT slug, created_at FROM suppliers ORDER BY name LIMIT 5000"
)
for supplier in suppliers:
lastmod = supplier["created_at"][:10] if supplier["created_at"] else today
entries.append((f"{base}/directory/{supplier['slug']}", lastmod))
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for loc, lastmod in entries:
xml += f" <url><loc>{loc}</loc><lastmod>{lastmod}</lastmod></url>\n"
xml += "</urlset>"
return Response(xml, content_type="application/xml")
# =============================================================================
# Feedback
# =============================================================================

View File

@@ -1,70 +0,0 @@
{% extends "base.html" %}
{% block title %}Imprint - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Legal imprint for Padelnomics — company information, contact details, and responsible party as required by §5 DDG.">
<meta name="robots" content="noindex">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Imprint</h1>
<p class="text-sm text-slate mb-8">Legal disclosure pursuant to §5 DDG (Digitale-Dienste-Gesetz)</p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">Service Provider</h2>
<p>
<!-- Replace with your full legal name / company name + legal form -->
[Name / Company Name]<br>
[Street and House Number]<br>
[Postcode] [City]<br>
[Country]
</p>
</section>
<section>
<h2 class="text-lg mb-2">Contact</h2>
<p>
Email: <a href="mailto:[your@email.com]">[your@email.com]</a>
</p>
<!-- Add phone number if you have one:
<p>Phone: +49 ...</p>
-->
</section>
<!-- If registered as a company (GmbH, UG, etc.), uncomment and fill in:
<section>
<h2 class="text-lg mb-2">Company Registration</h2>
<p>
Registered at: [Amtsgericht / Court of Registration]<br>
Registration number: HRB [number]<br>
VAT ID (USt-IdNr.): DE[number]
</p>
</section>
-->
<section>
<h2 class="text-lg mb-2">Responsible for Content</h2>
<p>
<!-- Person responsible for editorial content per §18 Abs. 2 MStV -->
[Full Name]<br>
[Address as above]
</p>
</section>
<section>
<h2 class="text-lg mb-2">Disclaimer</h2>
<p>
Despite careful content control we assume no liability for the content of external links.
The operators of linked pages are solely responsible for their content.
</p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Impressum - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Impressum für Padelnomics — Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz).">
<meta name="robots" content="noindex">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Impressum</h1>
<p class="text-sm text-slate mb-8">Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz) &mdash; <a href="{{ url_for('public.imprint', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">Anbieter</h2>
<p>
Hendrik Dreesmann<br>
Zum Offizierskasino 1<br>
26127 Oldenburg<br>
Deutschland
</p>
</section>
<section>
<h2 class="text-lg mb-2">Kontakt</h2>
<p>E-Mail: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a></p>
</section>
<section>
<h2 class="text-lg mb-2">Umsatzsteuer</h2>
<p>Kleinunternehmer gemäß § 19 UStG. Es wird keine Umsatzsteuer berechnet und keine Umsatzsteuer-Identifikationsnummer erteilt.</p>
</section>
<section>
<h2 class="text-lg mb-2">Verantwortlicher für den Inhalt</h2>
<p>
Hendrik Dreesmann<br>
Zum Offizierskasino 1, 26127 Oldenburg
</p>
<p class="mt-1 text-sm text-slate">(gemäß § 18 Abs. 2 MStV)</p>
</section>
<section>
<h2 class="text-lg mb-2">Haftungsausschluss</h2>
<p>Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich.</p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Imprint - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Legal imprint for Padelnomics — company information, contact details, and responsible party as required by §5 DDG.">
<meta name="robots" content="noindex">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Imprint</h1>
<p class="text-sm text-slate mb-8">Legal disclosure pursuant to §5 DDG (Digitale-Dienste-Gesetz) &mdash; <a href="{{ url_for('public.imprint', lang='de') }}" style="text-decoration:underline">Impressum auf Deutsch</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">Service Provider</h2>
<p>
Hendrik Dreesmann<br>
Zum Offizierskasino 1<br>
26127 Oldenburg<br>
Germany
</p>
</section>
<section>
<h2 class="text-lg mb-2">Contact</h2>
<p>Email: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a></p>
</section>
<section>
<h2 class="text-lg mb-2">VAT</h2>
<p>Small business owner pursuant to §19 UStG (Umsatzsteuergesetz). VAT is not charged and no VAT identification number is issued.</p>
</section>
<section>
<h2 class="text-lg mb-2">Responsible for Content</h2>
<p>
Hendrik Dreesmann<br>
Zum Offizierskasino 1, 26127 Oldenburg
</p>
<p class="mt-1 text-sm text-slate">(pursuant to §18 Abs. 2 MStV)</p>
</section>
<section>
<h2 class="text-lg mb-2">Disclaimer</h2>
<p>Despite careful content control we assume no liability for the content of external links. The operators of linked pages are solely responsible for their content.</p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -1,114 +0,0 @@
{% extends "base.html" %}
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Privacy Policy for Padelnomics. Learn how we collect, use, and protect your data. GDPR compliant. We never sell your personal information.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Privacy Policy</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026</p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">1. Information We Collect</h2>
<p>We collect information you provide directly:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Email address (required for account creation)</li>
<li>Name (optional)</li>
<li>Financial planning data (scenario inputs and projections)</li>
</ul>
<p class="mt-3">We automatically collect:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>IP address</li>
<li>Browser type</li>
<li>Usage data</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">2. How We Use Information</h2>
<p>We use your information to:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Provide and maintain the service</li>
<li>Process payments</li>
<li>Send transactional emails</li>
<li>Improve the service</li>
<li>Respond to support requests</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">3. Information Sharing</h2>
<p>We do not sell your personal information. We may share information with:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Service providers (Resend for email, Umami for privacy-friendly analytics, Paddle for payment processing)</li>
<li>Law enforcement when required by law</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">4. Data Retention</h2>
<p>We retain your data as long as your account is active. Upon deletion, we remove your data within 30 days.</p>
</section>
<section>
<h2 class="text-lg mb-2">5. Security</h2>
<p>We implement industry-standard security measures including encryption, secure sessions, and regular backups.</p>
</section>
<section id="cookies">
<h2 class="text-lg mb-2">6. Cookies</h2>
<p>We use the following cookies. You can manage your preferences at any time via the "Manage Cookies" link in the footer.</p>
<p class="mt-3 font-semibold text-sm">Essential (always active)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>session</strong> — Keeps you signed in. Secure, HttpOnly, expires after 30 days of inactivity.</li>
<li><strong>cookie_consent</strong> — Stores your cookie preferences. Expires after 1 year.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>ab_*</strong> — Assigns you to an A/B test variant to help us improve the site. Expires after 30 days. Only set if you accept functional cookies.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>Paddle.com cookies</strong> — Set by our payment provider only on pages where you initiate a purchase. Strictly necessary for processing payments. See <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Paddle's Privacy Policy</a>.</li>
</ul>
<p class="mt-3">We use <a href="https://umami.is" target="_blank" rel="noopener" style="text-decoration:underline">Umami</a> for website analytics. Umami is cookieless and does not track individual users across sessions.</p>
</section>
<section>
<h2 class="text-lg mb-2">7. Your Rights</h2>
<p>You have the right to:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Access your data</li>
<li>Correct inaccurate data</li>
<li>Delete your account and data</li>
<li>Export your data</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">8. GDPR Compliance</h2>
<p>For EU users: We process data based on consent and legitimate interest. You may contact us to exercise your GDPR rights.</p>
</section>
<section>
<h2 class="text-lg mb-2">9. Changes</h2>
<p>We may update this policy. We will notify you of significant changes via email.</p>
</section>
<section>
<h2 class="text-lg mb-2">10. Contact</h2>
<p>For privacy inquiries: {{ config.EMAIL_FROM }}</p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Datenschutzerklärung - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Datenschutzerklärung für Padelnomics. DSGVO-konform. Erfahren Sie, wie wir Ihre Daten erheben, verwenden und schützen. Wir nutzen Umami (cookielose Analyse), Paddle (Zahlungen) und Resend (E-Mail). Wir verkaufen Ihre Daten niemals.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Datenschutzerklärung</h1>
<p class="text-sm text-slate mb-8">Stand: Februar 2026 &mdash; <a href="{{ url_for('public.privacy', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">1. Verantwortlicher</h2>
<p>Verantwortlicher im Sinne der Datenschutz-Grundverordnung (DSGVO):</p>
<p class="mt-2">
Hendrik Dreesmann<br>
Zum Offizierskasino 1, 26127 Oldenburg, Deutschland<br>
E-Mail: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a>
</p>
</section>
<section>
<h2 class="text-lg mb-2">2. Erhobene Daten</h2>
<p><strong>Von Ihnen direkt bereitgestellte Daten:</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>E-Mail-Adresse (erforderlich für die Kontoerstellung und transaktionale E-Mails)</li>
<li>Name (optional, zur Personalisierung)</li>
<li>Finanzplanungseingaben (Courtanzahl, Budgets, Zeitpläne — in Ihren Szenarien gespeichert)</li>
<li>Feedback-Nachrichten, die über das Feedback-Formular eingereicht werden</li>
</ul>
<p class="mt-3"><strong>Automatisch erhobene Daten:</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Aggregierte, anonymisierte Seitenaufruf-Daten über Umami (keine IP-Speicherung, kein siteübergreifendes Tracking)</li>
<li>Session-Cookie zur Aufrechterhaltung der Anmeldung</li>
</ul>
<p class="mt-3"><strong>Beim Checkout erhobene Daten (durch Paddle, unseren Zahlungsdienstleister):</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Zahlungskartendaten, Rechnungsadresse und Umsatzsteuer-Identifikationsnummer (ausschließlich von Paddle verarbeitet; wir haben keinen Zugriff auf Rohdaten der Karte)</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">3. Rechtsgrundlagen der Verarbeitung</h2>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li><strong>Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)</strong> — Kontoverwaltung, Bereitstellung des Dienstes, Zahlungsabwicklung</li>
<li><strong>Berechtigte Interessen (Art. 6 Abs. 1 lit. f DSGVO)</strong> — Betrugsprävention, Sicherheit des Dienstes, aggregierte Analyse zur Verbesserung der Plattform</li>
<li><strong>Rechtliche Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO)</strong> — Aufbewahrung von Transaktionsdaten für steuerliche Zwecke</li>
<li><strong>Einwilligung (Art. 6 Abs. 1 lit. a DSGVO)</strong> — funktionale/A-B-Test-Cookies (sofern zugestimmt); Marketing-Kommunikation (sofern zugestimmt)</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">4. Auftragsverarbeiter und Drittanbieter</h2>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li><strong>Umami</strong> (selbst gehostet auf unserer eigenen Infrastruktur) — cookielose, datenschutzfreundliche Webanalyse. Keine Übermittlung personenbezogener Daten an Dritte.</li>
<li><strong>Paddle</strong> (paddle.com, UK/USA) — Zahlungsabwicklung und Abonnementverwaltung. Paddle agiert als Merchant of Record. Siehe <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Paddle</a>.</li>
<li><strong>Resend</strong> (resend.com, USA) — Versand transaktionaler E-Mails (Magic Links, Belege). Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Siehe <a href="https://resend.com/legal/privacy-policy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Resend</a>.</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">5. Speicherdauer</h2>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Kontodaten: gespeichert, solange Ihr Konto aktiv ist; Löschung innerhalb von 30 Tagen nach Kontoauflösung</li>
<li>Finanzszenarien: auf Wunsch mit Ihrem Konto gelöscht</li>
<li>Transaktionsdaten: 10 Jahre aufbewahrt zur Erfüllung handels- und steuerrechtlicher Pflichten (§ 257 HGB, § 147 AO)</li>
<li>Feedback: bis zu 2 Jahre zur Produktverbesserung gespeichert</li>
</ul>
</section>
<section id="cookies">
<h2 class="text-lg mb-2">6. Cookies</h2>
<p>Ihre Präferenzen können Sie jederzeit über den Link "Cookies verwalten" in der Fußzeile anpassen.</p>
<p class="mt-3 font-semibold text-sm">Notwendig (immer aktiv)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>session</strong> — Hält Sie eingeloggt. Secure, HttpOnly, SameSite=Lax. Läuft nach 30 Tagen Inaktivität ab.</li>
<li><strong>cookie_consent</strong> — Speichert Ihre Cookie-Einstellungen. Gültig 1 Jahr.</li>
<li><strong>lang</strong> — Speichert Ihre bevorzugte Sprache (en/de). Gültig 1 Jahr.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Funktional (erfordert Einwilligung)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>ab_*</strong> — Weist Ihnen eine A/B-Testvariante zu, um unsere Website zu verbessern. Läuft nach 30 Tagen ab. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Zahlung (nur beim Checkout)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>Paddle.com-Cookies</strong> — Werden von unserem Zahlungsdienstleister nur auf Seiten gesetzt, auf denen Sie einen Kauf initiieren. Technisch notwendig für die Zahlungsabwicklung. Siehe <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Paddle</a>.</li>
</ul>
<p class="mt-3">Wir nutzen <a href="https://umami.is" target="_blank" rel="noopener" style="text-decoration:underline">Umami</a> (selbst gehostet) für Webanalyse. Umami ist cookielos und erfasst keine IP-Adressen oder individuellen Nutzerverläufe.</p>
</section>
<section>
<h2 class="text-lg mb-2">7. Ihre Rechte (DSGVO)</h2>
<p>Sie haben folgende Rechte gemäß DSGVO:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li><strong>Auskunft</strong> — Kopie Ihrer personenbezogenen Daten anfordern (Art. 15 DSGVO)</li>
<li><strong>Berichtigung</strong> — Unrichtige oder unvollständige Daten korrigieren lassen (Art. 16 DSGVO)</li>
<li><strong>Löschung</strong> — Löschung Ihrer Daten beantragen, vorbehaltlich gesetzlicher Aufbewahrungspflichten (Art. 17 DSGVO)</li>
<li><strong>Einschränkung</strong> — Verarbeitung unter bestimmten Umständen einschränken lassen (Art. 18 DSGVO)</li>
<li><strong>Datenübertragbarkeit</strong> — Ihre Daten in maschinenlesbarem Format erhalten (Art. 20 DSGVO)</li>
<li><strong>Widerspruch</strong> — Verarbeitung auf Basis berechtigter Interessen widersprechen (Art. 21 DSGVO)</li>
<li><strong>Widerruf der Einwilligung</strong> — Erteilte Einwilligungen jederzeit widerrufen, ohne Auswirkung auf die Rechtmäßigkeit der bisherigen Verarbeitung</li>
</ul>
<p class="mt-3">Zur Ausübung Ihrer Rechte wenden Sie sich an <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a>. Wir antworten innerhalb von 30 Tagen.</p>
<p class="mt-3">Sie haben außerdem das Recht, sich bei der zuständigen Aufsichtsbehörde zu beschweren. In Deutschland ist dies der <a href="https://www.bfdi.bund.de" target="_blank" rel="noopener" style="text-decoration:underline">Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI)</a> bzw. die Aufsichtsbehörde Ihres Bundeslandes (für Niedersachsen: <a href="https://www.lfd.niedersachsen.de" target="_blank" rel="noopener" style="text-decoration:underline">LfD Niedersachsen</a>).</p>
</section>
<section>
<h2 class="text-lg mb-2">8. Internationale Datenübermittlung</h2>
<p>Resend verarbeitet Daten in den USA. Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Paddle unterliegt dem UK-DSGVO mit Angemessenheitsbeschluss. Umami läuft auf unserer eigenen EU-Infrastruktur — keine Daten verlassen die EU.</p>
</section>
<section>
<h2 class="text-lg mb-2">9. Änderungen dieser Datenschutzerklärung</h2>
<p>Wir können diese Erklärung regelmäßig aktualisieren. Über wesentliche Änderungen informieren wir Sie per E-Mail oder durch einen auffälligen Hinweis innerhalb des Dienstes. Das Datum oben auf dieser Seite zeigt die letzte Überarbeitung an.</p>
</section>
<section>
<h2 class="text-lg mb-2">10. Kontakt</h2>
<p>Für Datenschutzanfragen: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a></p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Privacy Policy for Padelnomics. GDPR compliant. Learn how we collect, use, and protect your data. We use Umami (cookieless analytics), Paddle (payments), and Resend (email). We never sell your personal information.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Privacy Policy</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026 &mdash; <a href="{{ url_for('public.privacy', lang='de') }}" style="text-decoration:underline">Datenschutzerklärung auf Deutsch</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">1. Controller</h2>
<p>The data controller responsible for processing your personal data is:</p>
<p class="mt-2">
Hendrik Dreesmann<br>
Zum Offizierskasino 1, 26127 Oldenburg, Germany<br>
Email: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a>
</p>
</section>
<section>
<h2 class="text-lg mb-2">2. Data We Collect</h2>
<p><strong>Data you provide directly:</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Email address (required for account creation and transactional emails)</li>
<li>Name (optional, for personalisation)</li>
<li>Financial planning inputs (court counts, budgets, timelines — stored in your scenarios)</li>
<li>Feedback messages submitted via the site feedback form</li>
</ul>
<p class="mt-3"><strong>Data collected automatically:</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Aggregated, anonymised page-view data via Umami (no IP address stored, no cross-site tracking)</li>
<li>Session cookie to keep you signed in</li>
</ul>
<p class="mt-3"><strong>Data collected at checkout (by Paddle, our payment processor):</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Payment card details, billing address, and VAT number (processed exclusively by Paddle; we never see raw card data)</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">3. Legal Basis for Processing</h2>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li><strong>Contract performance (Art. 6(1)(b) GDPR)</strong> — account management, delivering the Service, processing payments</li>
<li><strong>Legitimate interests (Art. 6(1)(f) GDPR)</strong> — fraud prevention, service security, aggregate analytics to improve the platform</li>
<li><strong>Legal obligation (Art. 6(1)(c) GDPR)</strong> — retaining transaction records for tax purposes</li>
<li><strong>Consent (Art. 6(1)(a) GDPR)</strong> — functional/A-B test cookies (where opted in); marketing communications (where opted in)</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">4. Service Providers (Sub-processors)</h2>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li><strong>Umami</strong> (self-hosted on our own infrastructure) — cookieless, privacy-first analytics. No personal data transferred to third parties.</li>
<li><strong>Paddle</strong> (paddle.com, UK/USA) — payment processing and subscription management. Paddle acts as merchant of record. See <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Paddle's Privacy Policy</a>.</li>
<li><strong>Resend</strong> (resend.com, USA) — transactional email delivery (magic links, receipts). Data is transferred under Standard Contractual Clauses. See <a href="https://resend.com/legal/privacy-policy" target="_blank" rel="noopener" style="text-decoration:underline">Resend's Privacy Policy</a>.</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">5. Data Retention</h2>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Account data: retained while your account is active; deleted within 30 days of account deletion</li>
<li>Financial scenarios: deleted with your account on request</li>
<li>Transaction records: retained for 10 years to meet German commercial and tax law obligations (§ 257 HGB, § 147 AO)</li>
<li>Feedback: retained for up to 2 years for product improvement purposes</li>
</ul>
</section>
<section id="cookies">
<h2 class="text-lg mb-2">6. Cookies</h2>
<p>You can manage your preferences at any time via the "Manage Cookies" link in the footer.</p>
<p class="mt-3 font-semibold text-sm">Essential (always active)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>session</strong> — Keeps you signed in. Secure, HttpOnly, SameSite=Lax. Expires after 30 days of inactivity.</li>
<li><strong>cookie_consent</strong> — Stores your cookie preferences. Expires after 1 year.</li>
<li><strong>lang</strong> — Stores your preferred language (en/de). Expires after 1 year.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>ab_*</strong> — Assigns you to an A/B test variant to help us improve the site. Expires after 30 days. Only set if you accept functional cookies.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>Paddle.com cookies</strong> — Set by our payment provider only on pages where you initiate a purchase. Strictly necessary for processing payments. See <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Paddle's Privacy Policy</a>.</li>
</ul>
<p class="mt-3">We use <a href="https://umami.is" target="_blank" rel="noopener" style="text-decoration:underline">Umami</a> (self-hosted) for analytics. Umami is cookieless and does not track individual users across sessions or store IP addresses.</p>
</section>
<section>
<h2 class="text-lg mb-2">7. Your Rights (GDPR)</h2>
<p>Under the GDPR, you have the following rights:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li><strong>Access</strong> — request a copy of your personal data (Art. 15)</li>
<li><strong>Rectification</strong> — correct inaccurate or incomplete data (Art. 16)</li>
<li><strong>Erasure</strong> — request deletion of your data, subject to legal retention obligations (Art. 17)</li>
<li><strong>Restriction</strong> — restrict processing in certain circumstances (Art. 18)</li>
<li><strong>Portability</strong> — receive your data in a machine-readable format (Art. 20)</li>
<li><strong>Objection</strong> — object to processing based on legitimate interests (Art. 21)</li>
<li><strong>Withdraw consent</strong> — where processing is based on consent, withdraw it at any time without affecting prior lawful processing</li>
</ul>
<p class="mt-3">To exercise any of these rights, contact us at <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a>. We will respond within 30 days.</p>
<p class="mt-3">You also have the right to lodge a complaint with your national supervisory authority. In Germany, the competent authority is the <a href="https://www.bfdi.bund.de" target="_blank" rel="noopener" style="text-decoration:underline">Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI)</a>.</p>
</section>
<section>
<h2 class="text-lg mb-2">8. International Transfers</h2>
<p>Resend processes data in the USA. Transfers are protected by Standard Contractual Clauses (SCCs) approved by the European Commission. Paddle operates under UK GDPR with an adequacy finding. Umami runs on our own EU-based infrastructure — no data leaves the EU.</p>
</section>
<section>
<h2 class="text-lg mb-2">9. Changes to This Policy</h2>
<p>We may update this policy periodically. We will notify you of significant changes via email or a prominent notice within the Service. The date at the top of this page reflects the most recent revision.</p>
</section>
<section>
<h2 class="text-lg mb-2">10. Contact</h2>
<p>For privacy inquiries: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a></p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -1,75 +0,0 @@
{% extends "base.html" %}
{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Terms of Service for Padelnomics — the padel court investment planning platform. Read our usage terms, disclaimer, and liability policy.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Terms of Service</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026</p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">1. Acceptance of Terms</h2>
<p>By accessing or using {{ config.APP_NAME }}, you agree to be bound by these Terms of Service. If you do not agree, do not use the service.</p>
</section>
<section>
<h2 class="text-lg mb-2">2. Description of Service</h2>
<p>{{ config.APP_NAME }} provides a software-as-a-service platform. Features and functionality may change over time.</p>
</section>
<section>
<h2 class="text-lg mb-2">3. User Accounts</h2>
<p>You are responsible for maintaining the security of your account. You must provide accurate information and keep it updated.</p>
</section>
<section>
<h2 class="text-lg mb-2">4. Acceptable Use</h2>
<p>You agree not to:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Violate any laws or regulations</li>
<li>Infringe on intellectual property rights</li>
<li>Transmit harmful code or malware</li>
<li>Attempt to gain unauthorized access</li>
<li>Interfere with service operation</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">5. Financial Projections Disclaimer</h2>
<p>The financial planner provides estimates based on your inputs. Projections are not guarantees of future performance. Always consult qualified financial and legal advisors before making investment decisions.</p>
</section>
<section>
<h2 class="text-lg mb-2">6. Termination</h2>
<p>We may terminate or suspend your account for violations of these terms. You may cancel your account at any time.</p>
</section>
<section>
<h2 class="text-lg mb-2">7. Disclaimer of Warranties</h2>
<p>The service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation.</p>
</section>
<section>
<h2 class="text-lg mb-2">8. Limitation of Liability</h2>
<p>We shall not be liable for any indirect, incidental, special, or consequential damages arising from use of the service.</p>
</section>
<section>
<h2 class="text-lg mb-2">9. Changes to Terms</h2>
<p>We may modify these terms at any time. Continued use after changes constitutes acceptance of the new terms.</p>
</section>
<section>
<h2 class="text-lg mb-2">10. Contact</h2>
<p>For questions about these terms, please contact us at {{ config.EMAIL_FROM }}.</p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Allgemeine Geschäftsbedingungen - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Allgemeine Geschäftsbedingungen (AGB) für Padelnomics — die Planungsplattform für Padel-Court-Investitionen. Nutzungsbedingungen, Haftungsausschluss und rechtliche Hinweise.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Allgemeine Geschäftsbedingungen (AGB)</h1>
<p class="text-sm text-slate mb-8">Stand: Februar 2026 &mdash; <a href="{{ url_for('public.terms', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">1. Geltungsbereich und Vertragsparteien</h2>
<p>Diese Allgemeinen Geschäftsbedingungen gelten für die Nutzung der Plattform Padelnomics ("der Dienst"), betrieben von Hendrik Dreesmann, Zum Offizierskasino 1, 26127 Oldenburg, Deutschland. Durch die Nutzung des Dienstes erkennen Sie diese AGB an. Entgegenstehende oder abweichende Bedingungen des Nutzers werden nicht anerkannt.</p>
</section>
<section>
<h2 class="text-lg mb-2">2. Leistungsbeschreibung</h2>
<p>Padelnomics stellt eine Software-as-a-Service-Plattform für die Planung von Padel-Court-Investitionen, die Suche nach Lieferanten, Finanzmodellierung und verwandte Werkzeuge bereit. Umfang und Funktionen können sich jederzeit ändern. Der Zugang zu bestimmten Funktionen setzt ein kostenpflichtiges Abonnement voraus.</p>
</section>
<section>
<h2 class="text-lg mb-2">3. Registrierung und Nutzerkonto</h2>
<p>Bei der Registrierung sind vollständige und wahrheitsgemäße Angaben zu machen. Sie sind allein verantwortlich für die Geheimhaltung Ihrer Zugangsdaten und für alle Aktivitäten unter Ihrem Konto. Unbefugte Nutzung ist uns unverzüglich unter <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a> zu melden.</p>
</section>
<section>
<h2 class="text-lg mb-2">4. Erlaubte Nutzung</h2>
<p>Es ist untersagt:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Geltende Gesetze oder Vorschriften zu verletzen, insbesondere deutsches Recht und EU-Verordnungen</li>
<li>Geistige Eigentumsrechte zu verletzen</li>
<li>Schädlichen Code, Malware oder unerwünschte Kommunikation zu übermitteln</li>
<li>Unbefugt auf unsere Systeme oder Konten anderer Nutzer zuzugreifen</li>
<li>Inhalte ohne schriftliche Genehmigung zu scrapen, zu spiegeln oder weiterzuverbreiten</li>
<li>Den Dienst ohne vorherige Vereinbarung zur Erbringung von Dienstleistungen für Dritte zu nutzen</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">5. Haftungsausschluss für Finanzprojektionen</h2>
<p>Der Finanzplaner, Szenarien und alle vom Dienst bereitgestellten Prognosen sind Schätzungen, die ausschließlich auf Ihren Eingaben basieren. Sie dienen ausschließlich Informationszwecken und stellen keine Finanz-, Rechts- oder Anlageberatung dar. Frühere Wertentwicklungen sind kein verlässlicher Indikator für zukünftige Ergebnisse. Vor Investitionsentscheidungen ist stets qualifizierter Fach-, Rechts- und Steuerrat einzuholen.</p>
</section>
<section>
<h2 class="text-lg mb-2">6. Abonnements und Zahlungen</h2>
<p>Kostenpflichtige Abonnements werden über Paddle (paddle.com) abgewickelt, unseren autorisierten Wiederverkäufer und Merchant of Record. Mit dem Abschluss eines Abonnements stimmen Sie den Nutzungsbedingungen und der Datenschutzerklärung von Paddle zu. Abonnements verlängern sich automatisch, sofern sie nicht vor dem Verlängerungsdatum gekündigt werden. Rückerstattungen richten sich nach der Rückerstattungsrichtlinie von Paddle und dem anwendbaren Verbraucherrecht.</p>
</section>
<section>
<h2 class="text-lg mb-2">7. Geistiges Eigentum</h2>
<p>Alle Inhalte, Designs, Quellcodes, Daten und Materialien von Padelnomics sind unser ausschließliches Eigentum oder das unserer Lizenzgeber. Sie erhalten eine beschränkte, nicht ausschließliche, nicht übertragbare Lizenz zur Nutzung des Dienstes für eigene betriebliche Zwecke. Daten, die Sie hochladen, verbleiben in Ihrem Eigentum.</p>
</section>
<section>
<h2 class="text-lg mb-2">8. Kündigung</h2>
<p>Wir können Ihr Konto bei wesentlichen Verstößen gegen diese AGB fristlos sperren oder kündigen. Sie können Ihr Konto jederzeit kündigen. Nach Kündigung erlischt Ihr Nutzungsrecht sofort. Ihre Daten werden innerhalb von 30 Tagen nach Kündigung endgültig gelöscht, sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.</p>
</section>
<section>
<h2 class="text-lg mb-2">9. Gewährleistungsausschluss</h2>
<p>Soweit gesetzlich zulässig wird der Dienst „wie besehen" und „wie verfügbar" ohne jegliche ausdrückliche oder stillschweigende Gewährleistung bereitgestellt, einschließlich der Gewährleistung der Marktgängigkeit, Eignung für einen bestimmten Zweck oder Nichtverletzung von Rechten Dritter. Wir garantieren keinen ununterbrochenen, fehlerfreien oder sicheren Betrieb.</p>
</section>
<section>
<h2 class="text-lg mb-2">10. Haftungsbeschränkung</h2>
<p>Soweit gesetzlich zulässig haftet Padelnomics nicht für mittelbare Schäden, Folgeschäden, entgangenen Gewinn, Datenverlust oder Betriebsunterbrechungen, auch wenn auf die Möglichkeit solcher Schäden hingewiesen wurde. Die Gesamthaftung für einen Anspruch ist auf die in den 12 Monaten vor dem Anspruch von Ihnen gezahlten Gebühren begrenzt. Unberührt bleiben Ansprüche aus Verletzung von Leben, Körper oder Gesundheit sowie aus grober Fahrlässigkeit oder Vorsatz.</p>
</section>
<section>
<h2 class="text-lg mb-2">11. Anwendbares Recht</h2>
<p>Diese AGB unterliegen dem Recht der Bundesrepublik Deutschland. Gerichtsstand für alle Streitigkeiten ist Oldenburg, Deutschland, soweit nicht zwingende verbraucherschutzrechtliche Vorschriften etwas anderes bestimmen.</p>
</section>
<section>
<h2 class="text-lg mb-2">12. Änderungen der AGB</h2>
<p>Wir behalten uns vor, diese AGB jederzeit zu ändern. Über wesentliche Änderungen informieren wir Sie per E-Mail oder durch einen deutlichen Hinweis innerhalb des Dienstes. Die fortgesetzte Nutzung nach Änderungen gilt als Zustimmung zu den geänderten AGB.</p>
</section>
<section>
<h2 class="text-lg mb-2">13. Kontakt</h2>
<p>Bei Fragen zu diesen AGB wenden Sie sich an: <a href="mailto:hello@padelnomics.io" style="text-decoration:underline">hello@padelnomics.io</a></p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Terms of Service for Padelnomics — the padel court investment planning platform. Read our usage terms, disclaimer, and liability policy.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Terms of Service</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026 &mdash; <a href="{{ url_for('public.terms', lang='de') }}" style="text-decoration:underline">Auf Deutsch lesen</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2 class="text-lg mb-2">1. Acceptance of Terms</h2>
<p>By accessing or using Padelnomics ("the Service"), you agree to be bound by these Terms of Service and all applicable laws and regulations. If you do not agree, do not use the Service.</p>
</section>
<section>
<h2 class="text-lg mb-2">2. Description of Service</h2>
<p>Padelnomics provides a software-as-a-service platform for padel court investment planning, supplier discovery, financial modelling, and related tools. Features and functionality may change over time. Access to certain features requires a paid subscription.</p>
</section>
<section>
<h2 class="text-lg mb-2">3. User Accounts</h2>
<p>You must provide accurate, complete, and current information when creating an account. You are solely responsible for maintaining the confidentiality of your credentials and for all activity that occurs under your account. Notify us immediately at {{ config.EMAIL_FROM }} of any unauthorised use.</p>
</section>
<section>
<h2 class="text-lg mb-2">4. Acceptable Use</h2>
<p>You agree not to:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Violate any applicable law or regulation, including German law and EU regulations</li>
<li>Infringe on any intellectual property rights</li>
<li>Transmit harmful code, malware, or unsolicited communications</li>
<li>Attempt to gain unauthorised access to our systems or other users' accounts</li>
<li>Scrape, mirror, or redistribute content without written permission</li>
<li>Use the Service to provide services to third parties without prior agreement</li>
</ul>
</section>
<section>
<h2 class="text-lg mb-2">5. Financial Projections Disclaimer</h2>
<p>The financial planner, scenarios, and all projections provided by the Service are estimates based solely on inputs you provide. They are for informational purposes only and do not constitute financial, legal, or investment advice. Past performance is not indicative of future results. Always consult qualified financial, legal, and tax advisors before making investment decisions.</p>
</section>
<section>
<h2 class="text-lg mb-2">6. Subscriptions and Payments</h2>
<p>Paid subscriptions are processed by Paddle (paddle.com), our authorised reseller and merchant of record. By subscribing you agree to Paddle's terms and privacy policy. Subscriptions renew automatically unless cancelled before the renewal date. Refunds are subject to Paddle's refund policy and applicable consumer protection law.</p>
</section>
<section>
<h2 class="text-lg mb-2">7. Intellectual Property</h2>
<p>All content, design, code, data, and materials provided by Padelnomics remain our exclusive property or that of our licensors. You receive a limited, non-exclusive, non-transferable licence to access the Service for your own internal business purposes. You retain ownership of data you upload.</p>
</section>
<section>
<h2 class="text-lg mb-2">8. Termination</h2>
<p>We may suspend or terminate your account immediately for material violations of these Terms, without prior notice or liability. You may cancel your account at any time. Upon termination, your right to access the Service ceases immediately. We will retain your data for 30 days following termination, after which it is permanently deleted.</p>
</section>
<section>
<h2 class="text-lg mb-2">9. Disclaimer of Warranties</h2>
<p>To the maximum extent permitted by applicable law, the Service is provided "as is" and "as available" without warranties of any kind, express or implied, including warranties of merchantability, fitness for a particular purpose, or non-infringement. We do not guarantee uninterrupted, error-free, or secure operation.</p>
</section>
<section>
<h2 class="text-lg mb-2">10. Limitation of Liability</h2>
<p>To the maximum extent permitted by law, Padelnomics and its operator shall not be liable for any indirect, incidental, special, consequential, or punitive damages, loss of profits, loss of data, or business interruption arising out of or related to use of the Service, even if advised of the possibility of such damages. Our total liability for any claim shall not exceed the fees paid by you in the 12 months preceding the claim.</p>
</section>
<section>
<h2 class="text-lg mb-2">11. Governing Law</h2>
<p>These Terms are governed by the laws of the Federal Republic of Germany. Any disputes shall be subject to the exclusive jurisdiction of the courts in Oldenburg, Germany, unless mandatory consumer protection law in your country of residence provides otherwise.</p>
</section>
<section>
<h2 class="text-lg mb-2">12. Changes to Terms</h2>
<p>We may modify these Terms at any time. We will provide reasonable notice of material changes via email or a prominent notice within the Service. Continued use after changes constitutes acceptance of the revised Terms.</p>
</section>
<section>
<h2 class="text-lg mb-2">13. Contact</h2>
<p>For questions about these Terms, contact us at <a href="mailto:{{ config.EMAIL_FROM }}" style="text-decoration:underline">{{ config.EMAIL_FROM }}</a>.</p>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -22,6 +22,12 @@
<!-- SEO defaults (child templates may override via block head) -->
<link rel="canonical" href="{{ config.BASE_URL }}{{ request.path }}">
{% if request.path.startswith('/en/') or request.path.startswith('/de/') %}
{% set path_suffix = request.path[3:] %}
<link rel="alternate" hreflang="en" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}">
<link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
{% endif %}
<meta property="og:title" content="{{ config.APP_NAME }}">
<meta property="og:description" content="">
<meta property="og:type" content="website">
@@ -36,8 +42,8 @@
<div class="nav-inner" style="display:grid;grid-template-columns:1fr auto 1fr">
<!-- Left: demand / buy side -->
<div class="nav-links nav-links--left">
<a href="{{ url_for('planner.index') }}">Planner</a>
<a href="{{ url_for('leads.quote_request') }}">Get Quotes</a>
<a href="{{ url_for('planner.index') }}">{{ t.nav_planner }}</a>
<a href="{{ url_for('leads.quote_request') }}">{{ t.nav_quotes }}</a>
</div>
<!-- Center: logo -->
@@ -47,40 +53,40 @@
<!-- Right: supply side + auth -->
<div class="nav-links nav-links--right">
<a href="{{ url_for('directory.index') }}">Directory</a>
<a href="{{ url_for('content.markets') }}">Markets</a>
<a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
<a href="{{ url_for('public.landing') }}#faq">Help</a>
<a href="{{ url_for('directory.index') }}">{{ t.nav_directory }}</a>
<a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a>
<a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a>
<a href="{{ url_for('public.landing') }}#faq">{{ t.nav_help }}</a>
<!-- Feedback button -->
<div style="position:relative" id="feedback-wrap">
<button type="button" onclick="document.getElementById('feedback-popover').toggleAttribute('hidden')"
style="font-size:0.75rem;padding:4px 10px;border:1px solid #E2E8F0;border-radius:6px;background:white;cursor:pointer;color:#64748B;font-family:inherit">
Feedback
{{ t.nav_feedback }}
</button>
<div id="feedback-popover" hidden
style="position:absolute;right:0;top:110%;width:280px;background:white;border:1px solid #E2E8F0;border-radius:10px;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.1);z-index:100">
<form hx-post="{{ url_for('public.feedback') }}" hx-target="#feedback-popover" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="page_url" id="feedback-page-url">
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">Send Feedback</p>
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">{{ t.nav_feedback }}</p>
<textarea name="message" rows="3" required placeholder="Ideas to improve this page..."
style="width:100%;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">Send</button>
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">{{ t.nav_send }}</button>
</form>
</div>
</div>
<script>document.getElementById('feedback-page-url').value = window.location.pathname;</script>
{% if user %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
<a href="{{ url_for('dashboard.index') }}">{{ t.nav_dashboard }}</a>
{% if is_admin %}
<a href="{{ url_for('admin.index') }}" class="nav-badge">Admin</a>
<a href="{{ url_for('admin.index') }}" class="nav-badge">{{ t.nav_admin }}</a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="nav-auth-btn">Sign Out</button>
<button type="submit" class="nav-auth-btn">{{ t.nav_signout }}</button>
</form>
{% else %}
<a href="{{ url_for('auth.login') }}" class="nav-auth-btn">Sign In</a>
<a href="{{ url_for('auth.login') }}" class="nav-auth-btn">{{ t.nav_signin }}</a>
{% endif %}
</div>
</div>
@@ -108,35 +114,49 @@
<div class="mb-1">
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:0.9375rem;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
</div>
<p class="text-sm text-slate">Plan, finance, and build your padel business.</p>
<p class="text-sm text-slate">{{ t.footer_tagline }}</p>
</div>
<div>
<p class="font-semibold text-navy text-sm mb-2">Product</p>
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_product }}</p>
<ul class="space-y-1 text-sm">
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('directory.index') }}">Supplier Directory</a></li>
<li><a href="{{ url_for('content.markets') }}">Markets</a></li>
<li><a href="{{ url_for('public.suppliers') }}">For Suppliers</a></li>
<li><a href="{{ url_for('planner.index') }}">{{ t.nav_planner }}</a></li>
<li><a href="{{ url_for('directory.index') }}">{{ t.nav_directory }}</a></li>
<li><a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a></li>
<li><a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a></li>
</ul>
</div>
<div>
<p class="font-semibold text-navy text-sm mb-2">Legal</p>
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_legal }}</p>
<ul class="space-y-1 text-sm">
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
<li><a href="{{ url_for('public.imprint') }}">Imprint</a></li>
<li><a href="{{ url_for('public.terms') }}">{{ t.link_terms }}</a></li>
<li><a href="{{ url_for('public.privacy') }}">{{ t.link_privacy }}</a></li>
<li><a href="{{ url_for('public.imprint') }}">{{ t.link_imprint }}</a></li>
<li><a href="#" onclick="document.cookie='cookie_consent=;path=/;max-age=0';location.reload();return false">Manage Cookies</a></li>
</ul>
</div>
<div>
<p class="font-semibold text-navy text-sm mb-2">Company</p>
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_company }}</p>
<ul class="space-y-1 text-sm">
<li><a href="{{ url_for('public.about') }}">About</a></li>
</ul>
</div>
</div>
<!-- Language toggle -->
{% if request.path.startswith('/en/') or request.path.startswith('/de/') %}
<div class="text-center text-xs text-slate mb-4">
{% if lang == 'en' %}
<span style="font-weight:600;color:#0F172A">EN</span>
&nbsp;|&nbsp;
<a href="{{ request.path | replace('/en/', '/de/', 1) }}" style="color:#64748B">DE</a>
{% else %}
<a href="{{ request.path | replace('/de/', '/en/', 1) }}" style="color:#64748B">EN</a>
&nbsp;|&nbsp;
<span style="font-weight:600;color:#0F172A">DE</span>
{% endif %}
</div>
{% endif %}
<p class="text-center text-xs text-slate pb-6">
&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.
&copy; {{ now.year }} {{ config.APP_NAME }}. {{ t.footer_rights }}
</p>
</footer>

View File

@@ -598,27 +598,27 @@ class TestRenderJinjaString:
class TestMarketsHub:
async def test_markets_returns_200(self, client):
resp = await client.get("/markets")
resp = await client.get("/en/markets")
assert resp.status_code == 200
async def test_markets_has_search(self, client):
resp = await client.get("/markets")
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert 'id="market-q"' in html
async def test_markets_results_partial(self, client):
resp = await client.get("/markets/results")
resp = await client.get("/en/markets/results")
assert resp.status_code == 200
async def test_markets_shows_published_articles(self, client, db):
await _create_article(slug="pub-test", url_path="/pub-test", status="published")
resp = await client.get("/markets")
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert "Title pub-test" in html
async def test_markets_hides_draft_articles(self, client, db):
await _create_article(slug="draft-test", url_path="/draft-test", status="draft")
resp = await client.get("/markets")
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert "Title draft-test" not in html
@@ -627,29 +627,29 @@ class TestMarketsHub:
slug="future-test", url_path="/future-test",
status="published", published_at="2099-01-01T00:00:00",
)
resp = await client.get("/markets")
resp = await client.get("/en/markets")
html = (await resp.data).decode()
assert "Title future-test" not in html
async def test_markets_filter_by_country(self, client, db):
await _create_article(slug="us-art", url_path="/us-art")
resp = await client.get("/markets/results?country=US")
resp = await client.get("/en/markets/results?country=US")
html = (await resp.data).decode()
assert "Title us-art" in html
resp = await client.get("/markets/results?country=DE")
resp = await client.get("/en/markets/results?country=DE")
html = (await resp.data).decode()
assert "Title us-art" not in html
class TestArticleServing:
async def test_nonexistent_article_returns_404(self, client):
resp = await client.get("/padel-court-cost-nonexistent")
resp = await client.get("/en/padel-court-cost-nonexistent")
assert resp.status_code == 404
async def test_draft_article_returns_404(self, client, db):
await _create_article(slug="draft-serve", url_path="/draft-serve", status="draft")
resp = await client.get("/draft-serve")
resp = await client.get("/en/draft-serve")
assert resp.status_code == 404
async def test_future_article_returns_404(self, client, db):
@@ -657,7 +657,7 @@ class TestArticleServing:
slug="future-serve", url_path="/future-serve",
status="published", published_at="2099-01-01T00:00:00",
)
resp = await client.get("/future-serve")
resp = await client.get("/en/future-serve")
assert resp.status_code == 404
async def test_published_article_served(self, client, db):
@@ -670,7 +670,7 @@ class TestArticleServing:
build_path.write_text("<p>Article body content</p>")
try:
resp = await client.get("/live-art")
resp = await client.get("/en/live-art")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Article body content" in html
@@ -681,7 +681,7 @@ class TestArticleServing:
async def test_article_missing_build_file_returns_404(self, client, db):
await _create_article(slug="no-build", url_path="/no-build")
resp = await client.get("/no-build")
resp = await client.get("/en/no-build")
assert resp.status_code == 404
@@ -724,7 +724,7 @@ class TestSitemapContent:
class TestRouteRegistration:
def test_markets_route_registered(self, app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert "/markets" in rules
assert "/<lang>/markets" in rules
def test_admin_content_routes_registered(self, app):
rules = [r.rule for r in app.url_map.iter_rules()]
@@ -736,7 +736,7 @@ class TestRouteRegistration:
def test_catchall_route_registered(self, app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert "/<path:url_path>" in rules
assert "/<lang>/<path:url_path>" in rules
# ════════════════════════════════════════════════════════════
@@ -1109,6 +1109,6 @@ class TestAdminDashboardLinks:
class TestFooterMarkets:
async def test_footer_has_markets_link(self, client):
resp = await client.get("/")
resp = await client.get("/en/")
html = (await resp.data).decode()
assert "/markets" in html

View File

@@ -40,13 +40,13 @@ def _assert_finite(obj, path=""):
class TestGuestMode:
async def test_planner_accessible_without_login(self, client):
"""GET /planner/ returns 200 for unauthenticated user."""
resp = await client.get("/planner/")
resp = await client.get("/en/planner/")
assert resp.status_code == 200
async def test_calculate_endpoint_works_without_login(self, client):
"""POST /planner/calculate returns valid JSON for guest."""
resp = await client.post(
"/planner/calculate",
"/en/planner/calculate",
json={"state": {"dblCourts": 4}},
)
assert resp.status_code == 200
@@ -56,20 +56,20 @@ class TestGuestMode:
async def test_scenario_routes_require_login(self, client):
"""Save/load/delete/list scenarios still require auth."""
resp = await client.post(
"/planner/scenarios/save",
"/en/planner/scenarios/save",
json={"name": "test", "state_json": "{}"},
)
assert resp.status_code in (302, 401)
async def test_planner_hides_save_for_guest(self, client):
"""Planner HTML does not render scenario controls for guests."""
resp = await client.get("/planner/")
resp = await client.get("/en/planner/")
html = (await resp.data).decode()
assert "saveScenarioBtn" not in html
async def test_planner_shows_save_for_auth(self, auth_client):
"""Planner HTML renders scenario controls for logged-in users."""
resp = await auth_client.get("/planner/")
resp = await auth_client.get("/en/planner/")
html = (await resp.data).decode()
assert "saveScenarioBtn" in html
@@ -254,32 +254,32 @@ class TestHeatScore:
class TestQuoteRequest:
async def test_quote_form_loads(self, client):
"""GET /leads/quote returns 200 with wizard shell."""
resp = await client.get("/leads/quote")
resp = await client.get("/en/leads/quote")
assert resp.status_code == 200
async def test_quote_prefill_from_params(self, client):
"""Query params pre-fill the form and start on step 2."""
resp = await client.get("/leads/quote?venue=outdoor&courts=6")
resp = await client.get("/en/leads/quote?venue=outdoor&courts=6")
assert resp.status_code == 200
async def test_quote_step_endpoint(self, client):
"""GET /leads/quote/step/1 returns 200 partial."""
resp = await client.get("/leads/quote/step/1")
resp = await client.get("/en/leads/quote/step/1")
assert resp.status_code == 200
async def test_quote_step_invalid(self, client):
"""GET /leads/quote/step/0 returns 400."""
resp = await client.get("/leads/quote/step/0")
resp = await client.get("/en/leads/quote/step/0")
assert resp.status_code == 400
async def test_quote_step_post_advances(self, client):
"""POST to step 1 with valid data returns step 2."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote/step/1",
"/en/leads/quote/step/1",
form={
"_accumulated": "{}",
"facility_type": "indoor",
@@ -294,12 +294,12 @@ class TestQuoteRequest:
async def test_quote_submit_creates_lead(self, client, db):
"""POST /leads/quote creates a lead_requests row as pending_verification."""
# Get CSRF token first
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "4",
@@ -332,12 +332,12 @@ class TestQuoteRequest:
async def test_quote_submit_without_login(self, client, db):
"""Guests get a user created and linked; lead is pending_verification."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "2",
@@ -362,12 +362,12 @@ class TestQuoteRequest:
async def test_quote_submit_with_login(self, auth_client, db, test_user):
"""Logged-in user with matching email skips verification (status='new')."""
await auth_client.get("/leads/quote")
await auth_client.get("/en/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/leads/quote",
"/en/leads/quote",
form={
"facility_type": "outdoor",
"court_count": "6",
@@ -392,12 +392,12 @@ class TestQuoteRequest:
async def test_venue_search_build_context(self, client, db):
"""Build context 'venue_search' is stored correctly."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "4",
@@ -422,12 +422,12 @@ class TestQuoteRequest:
async def test_stakeholder_type_stored(self, client, db):
"""stakeholder_type field is stored correctly."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "6",
@@ -451,12 +451,12 @@ class TestQuoteRequest:
async def test_submitted_page_has_context(self, client):
"""Guest quote submission shows 'check your email' verify page."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
"/en/leads/quote",
form={
"facility_type": "indoor",
"court_count": "6",
@@ -476,12 +476,12 @@ class TestQuoteRequest:
async def test_quote_validation_rejects_missing_fields(self, client):
"""POST /leads/quote returns 422 JSON when mandatory fields missing."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await client.post(
"/leads/quote",
"/en/leads/quote",
json={
"facility_type": "indoor",
"court_count": "4",
@@ -516,11 +516,11 @@ class TestQuoteVerification:
async def _submit_guest_quote(self, client, db, email="verify@example.com"):
"""Helper: submit a quote as a guest, return (lead_id, token)."""
await client.get("/leads/quote")
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
form = {**self.QUOTE_FORM, "contact_email": email, "csrf_token": csrf}
await client.post("/leads/quote", form=form)
await client.post("/en/leads/quote", form=form)
async with db.execute(
"SELECT id FROM lead_requests WHERE contact_email = ?", (email,)
@@ -546,12 +546,12 @@ class TestQuoteVerification:
async def test_logged_in_same_email_skips_verification(self, auth_client, db, test_user):
"""Logged-in user with matching email gets status='new' and 'matched' page."""
await auth_client.get("/leads/quote")
await auth_client.get("/en/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/leads/quote",
"/en/leads/quote",
form={
**self.QUOTE_FORM,
"contact_email": "test@example.com", # matches test_user
@@ -570,12 +570,12 @@ class TestQuoteVerification:
async def test_logged_in_different_email_needs_verification(self, auth_client, db, test_user):
"""Logged-in user with different email still needs verification."""
await auth_client.get("/leads/quote")
await auth_client.get("/en/leads/quote")
async with auth_client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
resp = await auth_client.post(
"/leads/quote",
"/en/leads/quote",
form={
**self.QUOTE_FORM,
"contact_email": "other@example.com", # different from test_user
@@ -596,7 +596,7 @@ class TestQuoteVerification:
"""GET /leads/verify with valid token sets status='new' and verified_at."""
lead_id, token = await self._submit_guest_quote(client, db)
resp = await client.get(f"/leads/verify?token={token}&lead={lead_id}")
resp = await client.get(f"/en/leads/verify?token={token}&lead={lead_id}")
assert resp.status_code == 200
async with db.execute(
@@ -610,7 +610,7 @@ class TestQuoteVerification:
"""Verification link logs the user in (sets session user_id)."""
lead_id, token = await self._submit_guest_quote(client, db)
await client.get(f"/leads/verify?token={token}&lead={lead_id}")
await client.get(f"/en/leads/verify?token={token}&lead={lead_id}")
async with client.session_transaction() as sess:
assert "user_id" in sess
@@ -624,7 +624,7 @@ class TestQuoteVerification:
await db.commit()
resp = await client.get(
f"/leads/verify?token={token}&lead={lead_id}",
f"/en/leads/verify?token={token}&lead={lead_id}",
follow_redirects=False,
)
assert resp.status_code == 302
@@ -640,17 +640,17 @@ class TestQuoteVerification:
await db.commit()
resp = await client.get(
f"/leads/verify?token={token}&lead={lead_id}",
f"/en/leads/verify?token={token}&lead={lead_id}",
follow_redirects=False,
)
assert resp.status_code == 302
async def test_verify_missing_params(self, client, db):
"""Missing token or lead params redirects."""
resp = await client.get("/leads/verify", follow_redirects=False)
resp = await client.get("/en/leads/verify", follow_redirects=False)
assert resp.status_code == 302
resp = await client.get("/leads/verify?token=abc", follow_redirects=False)
resp = await client.get("/en/leads/verify?token=abc", follow_redirects=False)
assert resp.status_code == 302
async def test_guest_quote_creates_user(self, client, db):

View File

@@ -435,7 +435,7 @@ class TestAuthRoutes:
"csrf_token": "test_token",
"email": "entrepreneur@example.com",
})
await client.post("/suppliers/signup/waitlist", form={
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
@@ -481,7 +481,7 @@ class TestSupplierRoutes:
async def test_shows_waitlist_form_when_enabled(self, client, db):
"""GET /suppliers/signup shows waitlist form when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/suppliers/signup?plan=supplier_growth")
response = await client.get("/en/suppliers/signup?plan=supplier_growth")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
@@ -492,7 +492,7 @@ class TestSupplierRoutes:
async def test_shows_normal_wizard_when_disabled(self, client, db):
"""GET /suppliers/signup shows normal wizard when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await client.get("/suppliers/signup")
response = await client.get("/en/suppliers/signup")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see wizard step 1, not waitlist form
@@ -504,7 +504,7 @@ class TestSupplierRoutes:
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/suppliers/signup/waitlist", form={
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
@@ -523,7 +523,7 @@ class TestSupplierRoutes:
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/suppliers/signup/waitlist", form={
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_pro",
@@ -540,7 +540,7 @@ class TestSupplierRoutes:
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
response = await client.post("/suppliers/signup/waitlist", form={
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_growth",
@@ -566,7 +566,7 @@ class TestSupplierRoutes:
await db.commit()
# Submit duplicate
response = await client.post("/suppliers/signup/waitlist", form={
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "existing@supplier.com",
"plan": "supplier_growth",
@@ -583,7 +583,7 @@ class TestSupplierRoutes:
async def test_rejects_invalid_supplier_email(self, client, db):
"""POST with invalid email redirects with error."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.post("/suppliers/signup/waitlist", form={
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "invalid",
"plan": "supplier_growth",
@@ -683,7 +683,7 @@ class TestPlannerExport:
async def test_export_shows_waitlist_when_enabled(self, auth_client, db):
"""GET /planner/export shows waitlist page when WAITLIST_MODE is true."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await auth_client.get("/planner/export")
response = await auth_client.get("/en/planner/export")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "coming soon" in html.lower()
@@ -693,7 +693,7 @@ class TestPlannerExport:
async def test_export_shows_normal_page_when_disabled(self, auth_client, db):
"""GET /planner/export shows normal export page when WAITLIST_MODE is false."""
with patch.object(core.config, "WAITLIST_MODE", False):
response = await auth_client.get("/planner/export")
response = await auth_client.get("/en/planner/export")
assert response.status_code == 200
html = await response.get_data(as_text=True)
# Should see normal export page, not waitlist
@@ -703,7 +703,7 @@ class TestPlannerExport:
async def test_export_requires_login(self, client, db):
"""Export page requires authentication."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/planner/export", follow_redirects=False)
response = await client.get("/en/planner/export", follow_redirects=False)
# Should redirect to login
assert response.status_code == 302 or response.status_code == 401
@@ -757,7 +757,7 @@ class TestWaitlistGateDecorator:
async def test_handles_complex_context_variables(self, client):
"""Decorator handles multiple context variables for suppliers."""
with patch.object(core.config, "WAITLIST_MODE", True):
response = await client.get("/suppliers/signup?plan=supplier_pro")
response = await client.get("/en/suppliers/signup?plan=supplier_pro")
html = await response.get_data(as_text=True)
assert response.status_code == 200
assert "supplier" in html.lower()
@@ -836,7 +836,7 @@ class TestCaptureWaitlistEmail:
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
await client.post("/suppliers/signup/waitlist", form={
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "test@example.com",
"plan": "supplier_pro",
@@ -858,7 +858,7 @@ class TestCaptureWaitlistEmail:
with patch.object(core.config, "WAITLIST_MODE", True), \
patch("padelnomics.worker.enqueue", new_callable=AsyncMock) as mock_enqueue:
await client.post("/suppliers/signup/waitlist", form={
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "test@example.com",
"plan": "supplier_pro",
@@ -917,14 +917,14 @@ class TestIntegration:
patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
# Step 1: GET supplier waitlist form
response = await client.get("/suppliers/signup?plan=supplier_pro")
response = await client.get("/en/suppliers/signup?plan=supplier_pro")
assert response.status_code == 200
html = await response.get_data(as_text=True)
assert "waitlist" in html.lower()
assert "supplier" in html.lower()
# Step 2: POST email
response = await client.post("/suppliers/signup/waitlist", form={
response = await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "supplier@example.com",
"plan": "supplier_pro",
@@ -974,7 +974,7 @@ class TestIntegration:
})
# Sign up as supplier
await client.post("/suppliers/signup/waitlist", form={
await client.post("/en/suppliers/signup/waitlist", form={
"csrf_token": "test_token",
"email": "both@example.com",
"plan": "supplier_growth",