merge: custom 404/500 error pages + smarter map city clicks
This commit is contained in:
@@ -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/<country>/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 = '<strong>' + c.city_name + '</strong><br>'
|
||||
+ (c.padel_venue_count || 0) + ' venues'
|
||||
+ (pop ? ' · ' + pop : '') + '<br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
|
||||
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
|
||||
+ (pop ? ' · ' + pop : '');
|
||||
if (hasArticle) {
|
||||
tip += '<br><span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
|
||||
}
|
||||
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] });
|
||||
|
||||
23
web/src/padelnomics/templates/404.html
Normal file
23
web/src/padelnomics/templates/404.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.error_404_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-page py-12">
|
||||
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">404</p>
|
||||
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_404_heading }}</h1>
|
||||
{% if country_slug %}
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_city_message }}</p>
|
||||
<a href="/{{ lang }}/markets/{{ country_slug }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_404_back_country.replace('{country}', country_name) }}
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_message }}</p>
|
||||
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_404_back_home }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
web/src/padelnomics/templates/500.html
Normal file
16
web/src/padelnomics/templates/500.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.error_500_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-page py-12">
|
||||
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">500</p>
|
||||
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_500_heading }}</h1>
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_500_message }}</p>
|
||||
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_500_back_home }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user