Compare commits

..

10 Commits

Author SHA1 Message Date
Deeman
bed07974cb fix(pseo): error details collapse + UNIQUE constraint on slug
Some checks failed
CI / test (push) Failing after 32s
CI / tag (push) Has been skipped
# Conflicts:
#	CHANGELOG.md
2026-03-10 21:47:38 +01:00
Deeman
207fa18fda fix(pseo): error details collapse + UNIQUE constraint on slug
1. Stop HTMX polling on job rows that have an error set, so the
   <details> element stays open when clicked (was being replaced
   every 2s by the poll cycle).

2. Migration 0030: drop redundant single-column UNIQUE on
   articles.slug — the real uniqueness key is (url_path, language).
   The slug index is kept for lookups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:33:38 +01:00
Deeman
8cc1cef780 merge: map country names + localised dropdown + avg/top score tooltip
Some checks failed
CI / test (push) Failing after 33s
CI / tag (push) Has been skipped
2026-03-10 17:22:53 +01:00
Deeman
236f0d1061 fix(markets): map country names, localised dropdown + avg/top score tooltip
- Expand dim_countries.sql CASE to cover 22 missing countries (PL, RO,
  CO, HU, ZA, KE, BR, CZ, QA, NZ, HR, LV, MT, CR, CY, PA, SV, DO,
  PE, VE, EE, ID) that fell through to bare ISO codes
- Add 19 missing entries to COUNTRY_LABELS (i18n.py) + both locale files
  (EN + DE dir_country_* keys) including IE which was in SQL but not i18n
- Localise map tooltips: routes.py injects country_name via
  get_country_name(), JS uses c.country_name instead of c.country_name_en
- Localise dropdown: apply country_name filter to option labels
- Show avg + top score in map tooltip with separate color dots and new
  map_score_avg / map_score_top i18n keys (EN: "Avg. Score" / "Top City",
  DE: "Ø Score" / "Top-Stadt")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:21:59 +01:00
Deeman
44617ea783 docs: document prod_query.py in CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:16:34 +01:00
Deeman
301f3b76c3 feat: add scripts/prod_query.py — SSH query tool for prod DuckDB
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
Runs read-only SQL against analytics.duckdb (default) or lakehouse.duckdb
on the prod server over SSH. SQL is base64-encoded to avoid shell escaping.
Supports TSV (default) and JSON output. Blocks mutation keywords.

For lakehouse, works around the DuckDB catalog naming issue (SQLMesh views
reference "local" but the file creates catalog "lakehouse") by attaching
the file as the "local" catalog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:15:38 +01:00
Deeman
018eacb0f3 feat(analytics): add Microsoft Clarity with consent-gated loading
Some checks failed
CI / tag (push) Has been cancelled
CI / test (push) Has been cancelled
Gate Clarity behind functional cookie consent (TTDSG § 25 + GDPR).
Script loads on page if consent already given, bootstraps immediately
on banner accept without reload. Privacy policy (EN + DE) updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:14:58 +01:00
Deeman
abacaac3f5 docs: add IndexNow integration to CHANGELOG
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:04:46 +01:00
Deeman
241e0de78e merge: IndexNow integration for instant Bing/Yandex URL submission 2026-03-10 15:53:46 +01:00
Deeman
fc21c25c82 feat(seo): add IndexNow integration for instant Bing/Yandex URL submission
Push-notify search engines (Bing, Yandex, Seznam, Naver) when content
changes instead of waiting for sitemap crawls. Especially valuable for
batch article publishing and supplier directory updates.

- Add INDEXNOW_KEY config var and key verification route
- New seo/_indexnow.py: async fire-and-forget POST to IndexNow API
- notify_indexnow() wrapper in sitemap.py expands paths to all lang variants
- Integrated at all article publish/unpublish/edit and supplier create points
- Bulk operations batch all URLs into a single IndexNow request
- Skips silently when key is empty (dev environments)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:52:45 +01:00
22 changed files with 535 additions and 23 deletions

View File

@@ -74,6 +74,24 @@ DUCKDB_PATH=local.duckdb SERVING_DUCKDB_PATH=analytics.duckdb \
```
## Production queries
Use `scripts/prod_query.py` to query the production DuckDB over SSH. **Always prefer this over raw SSH commands** — it handles escaping, enforces read-only, and blocks mutation keywords.
```bash
# Query analytics.duckdb (serving tables — default)
uv run python scripts/prod_query.py "SELECT COUNT(*) FROM serving.location_profiles"
# Query lakehouse.duckdb (foundation/staging tables)
uv run python scripts/prod_query.py --db lakehouse "SELECT * FROM foundation.dim_countries LIMIT 5"
# JSON output
uv run python scripts/prod_query.py --json "SELECT COUNT(*) FROM serving.location_profiles"
# Limit rows (default 500)
uv run python scripts/prod_query.py --max-rows 1000 "SELECT ..."
```
## Architecture documentation
| Topic | File |

View File

@@ -74,12 +74,13 @@ GSC_SERVICE_ACCOUNT_PATH=
GSC_SITE_URL=
BING_WEBMASTER_API_KEY=
BING_SITE_URL=
CLARITY_PROJECT_ID=ENC[AES256_GCM,data:PQ==,iv:GqQLR3UERBEGtqpZXAkZ8ETyVdj7+pk4YwuBPVxcjyE=,tag:1uuH2Gw3zE78Pugy6i6eDg==,type:str]
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str]
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-03-03T15:16:35Z
sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
sops_lastmodified=2026-03-10T15:07:09Z
sops_mac=ENC[AES256_GCM,data:mYPhIGSZIN+nqFEQE5VmLGaoTOvxFQ7fXvOHWcYtjr+AL/Zmnt81bo8Icgja5IMQPplSWoBo4J/7N08kSHATuBDuvCxNrsJaqTzCriTwfXq0WFa5yvoce/Sd29JEDAN505L+mR1PovhfIPndTR/E1bLvcyTz2NuAq5VGSg6KcUU=,iv:6mrSOWqIOItVt7Dp6jNecvzLjaTw/qQMr5b28I/bZWU=,tag:bT6Zi0Mb5Ci0CZBqr9iB3g==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -50,6 +50,8 @@ GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHTwBr/bNnCeqsTrUqAAYM5Vs=,tag:4jBxqgpyomzMLwiC9XpfVQ==,type:str]
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
INDEXNOW_KEY=ENC[AES256_GCM,data:3AJnmPOQJoKw525QR7jx6QBzV9kznUsWqHRmQjv1cU8=,iv:4XRmcPKrFE8S3GzsfNbxUdaUNaKc6z9T+ihUUwjZ8Y0=,tag:wERrhY9whJ9yTEgt8ewaMQ==,type:str]
CLARITY_PROJECT_ID=ENC[AES256_GCM,data:mLQ4vvtDFpZOpCg=,iv:S58K5Qf32EFlAuh8xkjo603wVpCOhNodZLJ4ZyaGF6c=,tag:LUQor6rIRc7unYCyytSgSg==,type:str]
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
CENSUS_API_KEY=ENC[AES256_GCM,data:9RbKlxSD17LqIuuNXaOKSgZ8LnFh9Wbze3XHgpctfV/1TqBMZTIedQ==,iv:WwsmR3HLUEcgUpLliGRaUPhGM9vFNPMGXSAQQ6+9UVc=,tag:R4EMNy5MxxvK0UTaCL0umA==,type:str]
@@ -64,7 +66,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
sops_lastmodified=2026-03-05T15:55:19Z
sops_mac=ENC[AES256_GCM,data:orLypjurBTYmk3um0bDQV3wFxj1pjCsjOf2D+AZyoIYY88MeY8BjK8mg8BWhmJYlGWqHH1FCpoJS+2SECv2Bvgejqvx/C/HSysA8et5CArM/p/MBbcupLAKOD8bTXorKMRDYPkWpK/snkPToxIZZd7dNj/zSU+OhRp5qLGCHkvM=,iv:eBn93z4DSk8UPHgP/Jf/Kz+3KwoKIQ9Et72pbLFcLP8=,tag:79kzPIKp0rtHGhH1CkXqwg==,type:str]
sops_lastmodified=2026-03-10T15:05:54Z
sops_mac=ENC[AES256_GCM,data:85sRBn6/gjXZFgyZlFk2RyMQGYK/e6rVC879F7/APj0xeguY5q4ui4OaE7OpO+joRMoLbE+rCWjYEyTeToTTdCNJ30yLiwlTrKR+tnmegJ/8wUAdyJtI8KO6XxKZpAesbiKl+o4F38iBZMhuZ6iybQx1RGF8SzQRu+E3fUEIiKk=,iv:/uW53lMRNNk/a/bzPvWqwDzP0un5/1muBDvDLSRet58=,tag:56LC972g6HSipvwxfSepJg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- **pSEO error details collapse** — clicking "Error" on a job row expanded the details, but they collapsed after ~2s because HTMX polling replaced the `<tr>`. Jobs with errors now stop polling, keeping the `<details>` element stable.
- **UNIQUE constraint on article slug** — `ON CONFLICT(url_path, language)` upsert failed because a separate single-column `UNIQUE` on `slug` fired first. Migration 0030 drops the redundant `UNIQUE` from `slug` (keeps the index for lookups and the composite `UNIQUE(url_path, language)`).
- **Map country names** — 22 countries (PL, RO, CO, HU, ZA, KE, BR, CZ, QA, NZ, HR, LV, MT, CR, CY, PA, SV, DO, PE, VE, EE, ID) that appeared as bare ISO codes on the markets map and dropdown now show proper English/German names. Added country names to `dim_countries.sql`, `COUNTRY_LABELS` (i18n.py), and both locale files. Map tooltips and dropdown are now fully localised via `get_country_name()`.
- **Map score tooltip clarity** — tooltip now shows both "Avg. Score" (country average) and "Top City" (highest location score) with separate color dots, making clear the map bubble color represents the country average — not a cap.
### Added
- **Microsoft Clarity integration** — consent-gated heatmaps and session recordings (project ID via `CLARITY_PROJECT_ID` env var). Script only loads when the user has accepted functional cookies; bootstraps immediately on consent without requiring a reload. Privacy policy (EN + DE) updated with Clarity disclosure: data collection, sub-processor, cookies (`_clck`, `_clsk`), and international transfers.
- **IndexNow integration** — push-notify Bing, Yandex, Seznam, and Naver when articles are published/unpublished/edited or suppliers are created. Bulk operations batch all URLs into a single request. Skips silently in dev (no key configured). Serves key verification file at `/{key}.txt`.
### Fixed
- **SEO audit fixes** — sitemap: replaced `/market-score` with `/padelnomics-score`, added `/opportunity-map`, removed `/billing/pricing` (blocked by robots.txt), deduplicated articles query (was producing 4 entries per article instead of 2). Fixed `/market-score` redirect chain (1 hop instead of 2). Moved default OG tags inside `{% block head %}` so child templates replace rather than duplicate them. Added JSON-LD WebPage + BreadcrumbList schema to features, planner, and directory pages. Added meta descriptions to export pages.

124
scripts/prod_query.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Run a read-only SQL query against the production DuckDB (analytics or lakehouse).
Usage:
uv run python scripts/prod_query.py "SELECT COUNT(*) FROM serving.location_profiles"
uv run python scripts/prod_query.py --db lakehouse "SELECT * FROM foundation.dim_countries LIMIT 5"
echo "SELECT 1" | uv run python scripts/prod_query.py --stdin
The script SSHes to the prod server, runs the query via Python/DuckDB, and prints
tab-separated results with a header row. Read-only: DuckDB is opened with read_only=True.
For lakehouse queries, automatically aliases the catalog as "local" so SQLMesh views work.
Designed for Claude Code to call without nested shell escaping nightmares.
"""
import argparse
import base64
import subprocess
import sys
SSH_HOST = "hetzner_root"
SSH_USER = "padelnomics_service"
DB_PATHS = {
"analytics": "/opt/padelnomics/data/analytics.duckdb",
"lakehouse": "/opt/padelnomics/data/lakehouse.duckdb",
}
MAX_ROWS = 500
TIMEOUT_SECONDS = 30
# Mutation keywords blocked (defense in depth — DB is read_only anyway)
BLOCKED_KEYWORDS = {"CREATE", "DROP", "ALTER", "INSERT", "UPDATE", "DELETE", "ATTACH", "COPY", "EXPORT", "INSTALL", "LOAD"}
# Remote Python script template. Receives SQL as base64 via {b64_sql}.
# Uses ATTACH + USE to alias the lakehouse catalog as "local" for SQLMesh view compat.
REMOTE_SCRIPT = """\
import duckdb, json, sys, base64
db_path = "{db_path}"
sql = base64.b64decode("{b64_sql}").decode()
max_rows = {max_rows}
output_json = {output_json}
try:
if "lakehouse" in db_path:
con = duckdb.connect(":memory:")
con.execute(f"ATTACH '{db_path}' AS local (READ_ONLY)")
con.execute("USE local")
else:
con = duckdb.connect(db_path, read_only=True)
result = con.execute(sql)
cols = [d[0] for d in result.description]
rows = result.fetchmany(max_rows)
if output_json:
print(json.dumps({{"columns": cols, "rows": [list(r) for r in rows], "count": len(rows)}}, default=str))
else:
print("\\t".join(cols))
for row in rows:
print("\\t".join(str(v) if v is not None else "NULL" for v in row))
if len(rows) == max_rows:
print(f"... truncated at {{max_rows}} rows", file=sys.stderr)
except Exception as e:
print(f"ERROR: {{e}}", file=sys.stderr)
sys.exit(1)
"""
def main():
parser = argparse.ArgumentParser(description="Query prod DuckDB over SSH")
parser.add_argument("sql", nargs="?", help="SQL query to run")
parser.add_argument("--stdin", action="store_true", help="Read SQL from stdin")
parser.add_argument(
"--db",
choices=list(DB_PATHS.keys()),
default="analytics",
help="Which database (default: analytics)",
)
parser.add_argument(
"--max-rows", type=int, default=MAX_ROWS, help=f"Max rows (default: {MAX_ROWS})"
)
parser.add_argument("--json", action="store_true", help="Output JSON instead of TSV")
args = parser.parse_args()
sql = args.sql
if args.stdin or sql is None:
sql = sys.stdin.read().strip()
if not sql:
print("ERROR: No SQL provided", file=sys.stderr)
sys.exit(1)
sql_upper = sql.upper()
for kw in BLOCKED_KEYWORDS:
if kw in sql_upper:
print(f"ERROR: Blocked keyword '{kw}' in query", file=sys.stderr)
sys.exit(1)
b64_sql = base64.b64encode(sql.encode()).decode()
remote_script = REMOTE_SCRIPT.format(
db_path=DB_PATHS[args.db],
b64_sql=b64_sql,
max_rows=args.max_rows,
output_json=args.json,
)
cmd = [
"ssh", SSH_HOST,
f"sudo -u {SSH_USER} bash -lc 'cd /opt/padelnomics && uv run python3 -'",
]
result = subprocess.run(
cmd,
input=remote_script,
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS + 10,
)
if result.stdout:
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
sys.exit(result.returncode)
if __name__ == "__main__":
main()

View File

@@ -148,6 +148,28 @@ SELECT
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
WHEN 'PL' THEN 'Poland'
WHEN 'RO' THEN 'Romania'
WHEN 'CO' THEN 'Colombia'
WHEN 'HU' THEN 'Hungary'
WHEN 'ZA' THEN 'South Africa'
WHEN 'KE' THEN 'Kenya'
WHEN 'BR' THEN 'Brazil'
WHEN 'CZ' THEN 'Czech Republic'
WHEN 'QA' THEN 'Qatar'
WHEN 'NZ' THEN 'New Zealand'
WHEN 'HR' THEN 'Croatia'
WHEN 'LV' THEN 'Latvia'
WHEN 'MT' THEN 'Malta'
WHEN 'CR' THEN 'Costa Rica'
WHEN 'CY' THEN 'Cyprus'
WHEN 'PA' THEN 'Panama'
WHEN 'SV' THEN 'El Salvador'
WHEN 'DO' THEN 'Dominican Republic'
WHEN 'PE' THEN 'Peru'
WHEN 'VE' THEN 'Venezuela'
WHEN 'EE' THEN 'Estonia'
WHEN 'ID' THEN 'Indonesia'
ELSE ac.country_code
END AS country_name_en,
LOWER(REGEXP_REPLACE(
@@ -172,6 +194,28 @@ SELECT
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
WHEN 'PL' THEN 'Poland'
WHEN 'RO' THEN 'Romania'
WHEN 'CO' THEN 'Colombia'
WHEN 'HU' THEN 'Hungary'
WHEN 'ZA' THEN 'South Africa'
WHEN 'KE' THEN 'Kenya'
WHEN 'BR' THEN 'Brazil'
WHEN 'CZ' THEN 'Czech Republic'
WHEN 'QA' THEN 'Qatar'
WHEN 'NZ' THEN 'New Zealand'
WHEN 'HR' THEN 'Croatia'
WHEN 'LV' THEN 'Latvia'
WHEN 'MT' THEN 'Malta'
WHEN 'CR' THEN 'Costa Rica'
WHEN 'CY' THEN 'Cyprus'
WHEN 'PA' THEN 'Panama'
WHEN 'SV' THEN 'El Salvador'
WHEN 'DO' THEN 'Dominican Republic'
WHEN 'PE' THEN 'Peru'
WHEN 'VE' THEN 'Venezuela'
WHEN 'EE' THEN 'Estonia'
WHEN 'ID' THEN 'Indonesia'
ELSE ac.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,

View File

@@ -1105,6 +1105,10 @@ async def supplier_new():
category, tier, contact_name, contact_email, contact_role,
services_offered, linkedin_url, instagram_url, youtube_url, now),
)
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
await notify_indexnow([f"/directory/{slug}"])
await flash(f"Supplier '{name}' created.", "success")
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
@@ -2625,20 +2629,28 @@ async def articles_bulk():
)
if action == "publish":
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
)
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
await notify_indexnow([r["url_path"] for r in affected])
elif action == "unpublish":
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
)
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
await notify_indexnow([r["url_path"] for r in affected])
elif action == "toggle_noindex":
await execute(
@@ -2685,16 +2697,26 @@ async def articles_bulk():
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
await notify_indexnow([r["url_path"] for r in affected])
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
await notify_indexnow([r["url_path"] for r in affected])
elif action == "toggle_noindex":
await execute(
@@ -2808,8 +2830,10 @@ async def article_new():
(url_path, article_slug, title, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head, article_type),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
if status == "published":
await notify_indexnow([url_path])
await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles"))
@@ -2881,6 +2905,9 @@ async def article_edit(article_id: int):
(title, url_path, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
)
if status == "published":
from ..sitemap import notify_indexnow
await notify_indexnow([url_path])
await flash("Article updated.", "success")
return redirect(url_for("admin.articles"))
@@ -2976,8 +3003,11 @@ async def article_publish(article_id: int):
(new_status, now, article_id),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
toggled = await fetch_one("SELECT url_path FROM articles WHERE id = ?", (article_id,))
if toggled:
await notify_indexnow([toggled["url_path"]])
if request.headers.get("HX-Request"):
updated = await fetch_one(

View File

@@ -5,7 +5,7 @@
{% set pct = [((job.progress_current / job.progress_total) * 100) | int, 100] | min if job.progress_total else 0 %}
<tr id="job-{{ job.id }}"
{% if job.status == 'pending' %}
{% if job.status == 'pending' and not job.error %}
hx-get="{{ url_for('pseo.pseo_job_status', job_id=job.id) }}"
hx-trigger="every 2s"
hx-target="this"

View File

@@ -284,6 +284,12 @@ def create_app() -> Quart:
from .sitemap import sitemap_response
return await sitemap_response(config.BASE_URL)
# IndexNow key verification — only register if key is configured
if config.INDEXNOW_KEY:
@app.route(f"/{config.INDEXNOW_KEY}.txt")
async def indexnow_key():
return Response(config.INDEXNOW_KEY, content_type="text/plain")
# -------------------------------------------------------------------------
# Error pages
# -------------------------------------------------------------------------

View File

@@ -18,7 +18,7 @@ from ..core import (
fetch_all,
fetch_one,
)
from ..i18n import get_translations
from ..i18n import get_country_name, get_translations
bp = Blueprint(
"content",
@@ -208,10 +208,14 @@ async def markets():
SELECT country_code, country_name_en, country_slug,
city_count, total_venues,
avg_market_score, avg_opportunity_score,
top_opportunity_score,
lat, lon
FROM serving.pseo_country_overview
ORDER BY total_venues DESC
""")
lang = g.get("lang", "en")
for c in map_countries:
c["country_name"] = get_country_name(c["country_code"], lang)
# Sort so user's country renders last (on top in Leaflet z-order)
user_country = g.get("user_country", "")
if user_country and map_countries:

View File

@@ -55,7 +55,7 @@
hx-include="#market-q, #market-region">
<option value="">{{ t.mkt_all_countries }}</option>
{% for c in countries %}
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c | country_name(lang) }}</option>
{% endfor %}
</select>
</div>
@@ -86,7 +86,7 @@
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
<script>
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",cities:"{{ t.map_cities }}"};
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",cities:"{{ t.map_cities }}",score_avg:"{{ t.map_score_avg }}",score_top:"{{ t.map_score_top }}"};
(function() {
var sc = PNMarkers.scoreColor;
var T = window.__MAP_T;
@@ -105,9 +105,13 @@ window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
var score = c.avg_opportunity_score || 0;
var hex = sc(score);
var tip = '<strong>' + c.country_name_en + '</strong><br>'
var topScore = c.top_opportunity_score || 0;
var topHex = sc(topScore);
var tip = '<strong>' + c.country_name + '</strong><br>'
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
+ '<span style="color:' + hex + ';font-weight:600;">' + T.score_label + ': ' + score + '/100</span><br>'
+ '<span style="color:' + hex + ';font-weight:600;">' + T.score_avg + ': ' + score + '/100</span><br>'
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + topHex + ';vertical-align:middle;margin-right:4px;"></span>'
+ '<span style="color:' + topHex + ';font-weight:600;">' + T.score_top + ': ' + topScore + '/100</span><br>'
+ '<span style="color:#94A3B8;font-size:0.75rem;">' + c.total_venues + ' ' + T.venues + ' · ' + c.city_count + ' ' + T.cities + '</span>';
L.marker([c.lat, c.lon], { icon: PNMarkers.makeIcon({ size: size, color: hex }) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })

View File

@@ -72,6 +72,8 @@ class Config:
GSC_SITE_URL: str = os.getenv("GSC_SITE_URL", "")
BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
BING_SITE_URL: str = os.getenv("BING_SITE_URL", "")
INDEXNOW_KEY: str = os.getenv("INDEXNOW_KEY", "")
CLARITY_PROJECT_ID: str = os.getenv("CLARITY_PROJECT_ID", "")
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")

View File

@@ -49,6 +49,25 @@ COUNTRY_LABELS: dict[str, str] = {
"AU": "Australia",
"ZA": "South Africa",
"EG": "Egypt",
"PL": "Poland",
"RO": "Romania",
"CO": "Colombia",
"HU": "Hungary",
"KE": "Kenya",
"CZ": "Czech Republic",
"QA": "Qatar",
"NZ": "New Zealand",
"HR": "Croatia",
"LV": "Latvia",
"MT": "Malta",
"CR": "Costa Rica",
"CY": "Cyprus",
"PA": "Panama",
"SV": "El Salvador",
"DO": "Dominican Republic",
"PE": "Peru",
"VE": "Venezuela",
"IE": "Ireland",
}
_LOCALES_DIR = Path(__file__).parent / "locales"

View File

@@ -345,6 +345,25 @@
"dir_country_AU": "Australien",
"dir_country_ZA": "Südafrika",
"dir_country_EG": "Ägypten",
"dir_country_PL": "Polen",
"dir_country_RO": "Rumänien",
"dir_country_CO": "Kolumbien",
"dir_country_HU": "Ungarn",
"dir_country_KE": "Kenia",
"dir_country_CZ": "Tschechien",
"dir_country_QA": "Katar",
"dir_country_NZ": "Neuseeland",
"dir_country_HR": "Kroatien",
"dir_country_LV": "Lettland",
"dir_country_MT": "Malta",
"dir_country_CR": "Costa Rica",
"dir_country_CY": "Zypern",
"dir_country_PA": "Panama",
"dir_country_SV": "El Salvador",
"dir_country_DO": "Dominikanische Republik",
"dir_country_PE": "Peru",
"dir_country_VE": "Venezuela",
"dir_country_IE": "Irland",
"sp_back": "Zurück zum Verzeichnis",
"sp_verified": "Verifiziert ✓",
"sp_request_quote": "Angebot anfragen →",
@@ -620,6 +639,8 @@
"map_existing_venues": "bestehende Anlagen",
"map_km_nearest": "km zur nächsten Anlage",
"map_no_nearby": "Keine Anlagen in der Nähe",
"map_score_avg": "Ø Score",
"map_score_top": "Top-Stadt",
"waitlist_markets_title": "Marktdaten — Demnächst verfügbar",
"waitlist_markets_sub": "Detaillierte Marktberichte für Padel-Investoren: Baukosten, Umsatz-Benchmarks, Auslastungsdaten und ROI-Analysen nach Stadt und Region.",
"waitlist_markets_feature1": "Echte Kostendaten aus laufenden Anlagen in über 30 Ländern",

View File

@@ -345,6 +345,25 @@
"dir_country_AU": "Australia",
"dir_country_ZA": "South Africa",
"dir_country_EG": "Egypt",
"dir_country_PL": "Poland",
"dir_country_RO": "Romania",
"dir_country_CO": "Colombia",
"dir_country_HU": "Hungary",
"dir_country_KE": "Kenya",
"dir_country_CZ": "Czech Republic",
"dir_country_QA": "Qatar",
"dir_country_NZ": "New Zealand",
"dir_country_HR": "Croatia",
"dir_country_LV": "Latvia",
"dir_country_MT": "Malta",
"dir_country_CR": "Costa Rica",
"dir_country_CY": "Cyprus",
"dir_country_PA": "Panama",
"dir_country_SV": "El Salvador",
"dir_country_DO": "Dominican Republic",
"dir_country_PE": "Peru",
"dir_country_VE": "Venezuela",
"dir_country_IE": "Ireland",
"sp_back": "Back to Directory",
"sp_verified": "Verified ✓",
"sp_request_quote": "Request Quote →",
@@ -620,6 +639,8 @@
"map_existing_venues": "existing venues",
"map_km_nearest": "km to nearest court",
"map_no_nearby": "No nearby courts",
"map_score_avg": "Avg. Score",
"map_score_top": "Top City",
"waitlist_markets_title": "Markets Intelligence — Coming Soon",
"waitlist_markets_sub": "Deep-dive market reports for padel investors: construction costs, revenue benchmarks, occupancy data, and ROI analysis by city and region.",
"waitlist_markets_feature1": "Real cost data from operating venues across 30+ countries",

View File

@@ -0,0 +1,93 @@
"""Drop UNIQUE constraint from articles.slug column.
The single-column UNIQUE on slug conflicts with the ON CONFLICT(url_path, language)
upsert in pSEO generation, causing 'UNIQUE constraint failed: articles.slug' errors
when re-running generation for the same template.
The slug is already unique by construction ({template_slug}-{lang}-{natural_key}),
and the real uniqueness key is (url_path, language). The idx_articles_slug index
is kept for fast lookups.
"""
def up(conn) -> None:
# ── 1. Drop FTS triggers + virtual table ──────────────────────────────────
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
conn.execute("DROP TRIGGER IF EXISTS articles_au")
conn.execute("DROP TABLE IF EXISTS articles_fts")
# ── 2. Recreate articles without UNIQUE on slug ───────────────────────────
conn.execute("""
CREATE TABLE articles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_slug TEXT,
language TEXT NOT NULL DEFAULT 'en',
date_modified TEXT,
seo_head TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT,
group_key TEXT DEFAULT NULL,
noindex INTEGER NOT NULL DEFAULT 0,
article_type TEXT NOT NULL DEFAULT 'editorial',
UNIQUE(url_path, language)
)
""")
conn.execute("""
INSERT INTO articles_new
(id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, template_slug, language,
date_modified, seo_head, created_at, updated_at, group_key,
noindex, article_type)
SELECT id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, template_slug, language,
date_modified, seo_head, created_at, updated_at, group_key,
noindex, article_type
FROM articles
""")
conn.execute("DROP TABLE articles")
conn.execute("ALTER TABLE articles_new RENAME TO articles")
# ── 3. Recreate indexes ───────────────────────────────────────────────────
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_article_type ON articles(article_type)")
# ── 4. Recreate FTS + triggers ────────────────────────────────────────────
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
)
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")

View File

@@ -3,14 +3,14 @@
{% 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.">
<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), Microsoft Clarity (Sitzungsaufzeichnungen, nur mit Einwilligung), 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>
<p class="text-sm text-slate mb-8">Stand: März 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">
@@ -36,6 +36,7 @@
<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>Anonymisierte Interaktionsdaten (Klicks, Scrolltiefe, Sitzungsaufzeichnungen) über Microsoft Clarity — nur mit Ihrer Einwilligung</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>
@@ -60,6 +61,7 @@
<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>
<li><strong>Microsoft Clarity</strong> (clarity.microsoft.com, USA) — Heatmaps und Sitzungsaufzeichnungen zur Verbesserung der Nutzererfahrung. Nur aktiv mit Ihrer Einwilligung (funktionale Cookies). Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC). Siehe <a href="https://privacy.microsoft.com/privacystatement" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Microsoft</a>.</li>
</ul>
</section>
@@ -87,6 +89,8 @@
<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>
<li><strong>_clck</strong> — Microsoft Clarity Nutzerkennung. Gültig 12 Monate. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
<li><strong>_clsk</strong> — Microsoft Clarity Sitzungskennung. Gültig bis zum Ende der Sitzung. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Zahlung (nur beim Checkout)</p>
@@ -115,7 +119,7 @@
<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>
<p>Resend und Microsoft Clarity verarbeiten 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>

View File

@@ -3,14 +3,14 @@
{% 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.">
<meta name="description" content="Privacy Policy for Padelnomics. GDPR compliant. Learn how we collect, use, and protect your data. We use Umami (cookieless analytics), Microsoft Clarity (session recordings, with consent), 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>
<p class="text-sm text-slate mb-8">Last updated: March 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">
@@ -36,6 +36,7 @@
<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>Anonymised interaction data (clicks, scroll depth, session recordings) via Microsoft Clarity — only with your consent</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>
@@ -60,6 +61,7 @@
<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>
<li><strong>Microsoft Clarity</strong> (clarity.microsoft.com, USA) — heatmaps and session recordings for UX improvement. Only active with your consent (functional cookies). Data is transferred under Standard Contractual Clauses. See <a href="https://privacy.microsoft.com/privacystatement" target="_blank" rel="noopener" style="text-decoration:underline">Microsoft Privacy Statement</a>.</li>
</ul>
</section>
@@ -87,6 +89,8 @@
<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>
<li><strong>_clck</strong> — Microsoft Clarity user identifier. Expires after 12 months. Only set if you accept functional cookies.</li>
<li><strong>_clsk</strong> — Microsoft Clarity session identifier. Expires at end of session. Only set if you accept functional cookies.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
@@ -115,7 +119,7 @@
<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>
<p>Resend and Microsoft Clarity process 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>

View File

@@ -0,0 +1,60 @@
"""IndexNow push notifications — instant URL submission to Bing, Yandex, Seznam, Naver.
Fire-and-forget: logs errors but never raises. Content publishing must not fail
because IndexNow is down.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
_INDEXNOW_ENDPOINT = "https://api.indexnow.org/IndexNow"
_BATCH_LIMIT = 10_000 # IndexNow max URLs per request
async def notify_urls(
urls: list[str], host: str, key: str, timeout_seconds: int = 10
) -> bool:
"""POST changed URLs to IndexNow. Returns True on success, False on failure.
Skips silently if key is empty (dev environments).
Batches into chunks of 10,000 URLs per IndexNow spec.
"""
assert 1 <= timeout_seconds <= 60, "timeout_seconds must be 1-60"
if not key:
return False
if not urls:
return True
key_location = f"https://{host}/{key}.txt"
try:
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
for offset in range(0, len(urls), _BATCH_LIMIT):
batch = urls[offset : offset + _BATCH_LIMIT]
response = await client.post(
_INDEXNOW_ENDPOINT,
json={
"host": host,
"key": key,
"keyLocation": key_location,
"urlList": batch,
},
)
# IndexNow returns 200 or 202 on success
if response.status_code not in (200, 202):
logger.warning(
"IndexNow returned %d for %d URLs: %s",
response.status_code,
len(batch),
response.text[:200],
)
return False
logger.info("IndexNow accepted %d URLs", len(batch))
return True
except Exception:
logger.warning("IndexNow notification failed", exc_info=True)
return False

View File

@@ -1,10 +1,14 @@
"""Sitemap generation with in-memory TTL cache and hreflang alternates."""
import logging
import time
from urllib.parse import urlparse
from quart import Response
from .core import fetch_all
from .core import config, fetch_all
logger = logging.getLogger(__name__)
# Process-local cache — valid for the current single-Hypercorn-worker deployment
# (Dockerfile: `--workers 1`). If worker count increases, replace with a
@@ -128,3 +132,22 @@ async def sitemap_response(base_url: str) -> Response:
content_type="application/xml",
headers={"Cache-Control": f"public, max-age={CACHE_TTL_SECONDS}"},
)
async def notify_indexnow(paths: list[str]) -> None:
"""Ping IndexNow with full URLs for the given paths (both lang variants).
Paths should be lang-agnostic (e.g. "/blog/my-article"). Each path gets
expanded to all supported language variants.
"""
if not config.INDEXNOW_KEY or not paths:
return
base = config.BASE_URL.rstrip("/")
urls = []
for path in paths:
for lang in LANGS:
urls.append(f"{base}/{lang}{path}")
host = urlparse(config.BASE_URL).hostname or "padelnomics.io"
from .seo._indexnow import notify_urls
await notify_urls(urls, host=host, key=config.INDEXNOW_KEY)

View File

@@ -151,6 +151,14 @@
function dismiss(value) {
document.cookie = COOKIE_NAME + '=' + value
+ ';path=/;max-age=' + MAX_AGE + ';SameSite=Lax';
// Bootstrap Clarity immediately on functional consent (no reload needed)
if (value.indexOf('functional') !== -1 && window.clarity === undefined) {
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "{{ config.CLARITY_PROJECT_ID }}");
}
banner.classList.remove('cb-enter');
banner.classList.add('cb-exit');
setTimeout(function () { banner.style.display = 'none'; }, 280);

View File

@@ -18,6 +18,20 @@
<!-- Umami Analytics -->
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
<!-- Microsoft Clarity (consent-gated) -->
{% if config.CLARITY_PROJECT_ID %}
<script type="text/javascript">
(function(){
if (!/cookie_consent=[^;]*functional/.test(document.cookie)) return;
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "{{ config.CLARITY_PROJECT_ID }}");
})();
</script>
{% endif %}
<!-- Paddle.js (only on checkout pages via block override) -->
{% block paddle %}{% endblock %}