diff --git a/CHANGELOG.md b/CHANGELOG.md index 366c173..a486845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found". +- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. Cities without articles appear in muted gray and are not clickable, preventing dead-end navigations. The `/api/markets//cities.json` endpoint includes a `has_article` boolean per city. + ### Fixed - **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise). - **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint. diff --git a/web/src/padelnomics/api.py b/web/src/padelnomics/api.py index 37c620d..8db5bae 100644 --- a/web/src/padelnomics/api.py +++ b/web/src/padelnomics/api.py @@ -8,7 +8,7 @@ daily when the pipeline runs). from quart import Blueprint, abort, jsonify from .analytics import fetch_analytics -from .core import is_flag_enabled +from .core import fetch_all, is_flag_enabled bp = Blueprint("api", __name__) @@ -59,6 +59,20 @@ async def country_cities(country_slug: str): """, [country_slug], ) + # Check which cities have published articles (any language). + article_rows = await fetch_all( + """SELECT url_path FROM articles + WHERE url_path LIKE ? AND status = 'published' + AND published_at <= datetime('now')""", + (f"/markets/{country_slug}/%",), + ) + article_slugs = set() + for a in article_rows: + parts = a["url_path"].rstrip("/").split("/") + if len(parts) >= 4: + article_slugs.add(parts[3]) + for row in rows: + row["has_article"] = row["city_slug"] in article_slugs return jsonify(rows), 200, _CACHE_HEADERS diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 1085bf3..1149d71 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -5,7 +5,7 @@ import json import time from pathlib import Path -from quart import Quart, Response, abort, g, redirect, request, session, url_for +from quart import Quart, Response, abort, g, redirect, render_template, request, session, url_for from .analytics import close_analytics_db, open_analytics_db from .core import ( @@ -270,12 +270,39 @@ def create_app() -> Quart: from .sitemap import sitemap_response return await sitemap_response(config.BASE_URL) - # Ensure unhandled exceptions are always logged (Granian doesn't show - # Quart's debug error page, so without this 500s are silent). + # ------------------------------------------------------------------------- + # Error pages + # ------------------------------------------------------------------------- + + def _error_lang() -> str: + """Best-effort language from URL path prefix (no g.lang in error handlers).""" + path = request.path + if path.startswith("/de/"): + return "de" + return "en" + + @app.errorhandler(404) + async def handle_404(error): + import re + lang = _error_lang() + t = get_translations(lang) + country_slug = None + country_name = None + m = re.match(r"^/(?:en|de)/markets/([^/]+)/[^/]+/?$", request.path) + if m: + country_slug = m.group(1) + country_name = country_slug.replace("-", " ").title() + return await render_template( + "404.html", lang=lang, t=t, country_slug=country_slug, + country_name=country_name or "", + ), 404 + @app.errorhandler(500) async def handle_500(error): app.logger.exception("Unhandled 500 error: %s", error) - return "Internal Server Error", 500 + lang = _error_lang() + t = get_translations(lang) + return await render_template("500.html", lang=lang, t=t), 500 # Health check @app.route("/health") diff --git a/web/src/padelnomics/locales/de.json b/web/src/padelnomics/locales/de.json index d3be9b9..be8981b 100644 --- a/web/src/padelnomics/locales/de.json +++ b/web/src/padelnomics/locales/de.json @@ -1825,5 +1825,16 @@ "affiliate_pros_label": "Vorteile", "affiliate_cons_label": "Nachteile", "affiliate_at_retailer": "bei {retailer}", - "affiliate_our_picks": "Unsere Empfehlungen" + "affiliate_our_picks": "Unsere Empfehlungen", + + "error_404_title": "Seite nicht gefunden", + "error_404_heading": "Diese Seite gibt es nicht", + "error_404_message": "Die gesuchte Seite wurde verschoben oder existiert noch nicht.", + "error_404_city_message": "Die Marktanalyse für diese Stadt ist noch nicht verfügbar.", + "error_404_back_home": "Zur Startseite", + "error_404_back_country": "Zurück zur {country}-Übersicht", + "error_500_title": "Etwas ist schiefgelaufen", + "error_500_heading": "Etwas ist schiefgelaufen", + "error_500_message": "Wir arbeiten an einer Lösung. Bitte versuche es gleich noch einmal.", + "error_500_back_home": "Zur Startseite" } \ No newline at end of file diff --git a/web/src/padelnomics/locales/en.json b/web/src/padelnomics/locales/en.json index 62b2a4e..b878eb0 100644 --- a/web/src/padelnomics/locales/en.json +++ b/web/src/padelnomics/locales/en.json @@ -1828,5 +1828,16 @@ "affiliate_pros_label": "Pros", "affiliate_cons_label": "Cons", "affiliate_at_retailer": "at {retailer}", - "affiliate_our_picks": "Our picks" + "affiliate_our_picks": "Our picks", + + "error_404_title": "Page Not Found", + "error_404_heading": "This page doesn't exist", + "error_404_message": "The page you're looking for may have been moved or doesn't exist yet.", + "error_404_city_message": "The market analysis for this city isn't available yet.", + "error_404_back_home": "Back to Home", + "error_404_back_country": "Back to {country} overview", + "error_500_title": "Something Went Wrong", + "error_500_heading": "Something went wrong", + "error_500_message": "We're working on fixing this. Please try again in a moment.", + "error_500_back_home": "Back to Home" } \ No newline at end of file diff --git a/web/src/padelnomics/static/js/article-maps.js b/web/src/padelnomics/static/js/article-maps.js index 5824b27..57fa3b6 100644 --- a/web/src/padelnomics/static/js/article-maps.js +++ b/web/src/padelnomics/static/js/article-maps.js @@ -43,18 +43,23 @@ data.forEach(function(c) { if (!c.lat || !c.lon) return; var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV); - var color = scoreColor(c.market_score); + var hasArticle = c.has_article !== false; + var color = hasArticle ? scoreColor(c.market_score) : '#9CA3AF'; var pop = c.population >= 1000000 ? (c.population / 1000000).toFixed(1) + 'M' : (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || '')); var tip = '' + c.city_name + '
' + (c.padel_venue_count || 0) + ' venues' - + (pop ? ' · ' + pop : '') + '
' - + 'Score ' + Math.round(c.market_score) + '/100'; - L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) + + (pop ? ' · ' + pop : ''); + if (hasArticle) { + tip += '
Score ' + Math.round(c.market_score) + '/100'; + } + var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) - .on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; }) .addTo(map); + if (hasArticle) { + marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; }); + } bounds.push([c.lat, c.lon]); }); if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] }); diff --git a/web/src/padelnomics/templates/404.html b/web/src/padelnomics/templates/404.html new file mode 100644 index 0000000..23d128e --- /dev/null +++ b/web/src/padelnomics/templates/404.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}{{ t.error_404_title }} — {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

404

+

{{ t.error_404_heading }}

+ {% if country_slug %} +

{{ t.error_404_city_message }}

+ + {{ t.error_404_back_country.replace('{country}', country_name) }} + + {% else %} +

{{ t.error_404_message }}

+ + {{ t.error_404_back_home }} + + {% endif %} +
+
+{% endblock %} diff --git a/web/src/padelnomics/templates/500.html b/web/src/padelnomics/templates/500.html new file mode 100644 index 0000000..1a10578 --- /dev/null +++ b/web/src/padelnomics/templates/500.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}{{ t.error_500_title }} — {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

500

+

{{ t.error_500_heading }}

+

{{ t.error_500_message }}

+ + {{ t.error_500_back_home }} + +
+
+{% endblock %}