feat: add styled 404/500 error pages with i18n support

Custom error templates extending base.html with centered layout.
404 is context-aware: detects /markets/{country}/{city} paths and
shows city-specific message with link back to country overview.
Both pages support EN/DE translations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-06 09:55:13 +01:00
parent 831233cb29
commit ed48936dad
5 changed files with 94 additions and 6 deletions

View File

@@ -5,7 +5,7 @@ import json
import time import time
from pathlib import Path 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 .analytics import close_analytics_db, open_analytics_db
from .core import ( from .core import (
@@ -270,12 +270,39 @@ def create_app() -> Quart:
from .sitemap import sitemap_response from .sitemap import sitemap_response
return await sitemap_response(config.BASE_URL) 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) @app.errorhandler(500)
async def handle_500(error): async def handle_500(error):
app.logger.exception("Unhandled 500 error: %s", 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 # Health check
@app.route("/health") @app.route("/health")

View File

@@ -1825,5 +1825,16 @@
"affiliate_pros_label": "Vorteile", "affiliate_pros_label": "Vorteile",
"affiliate_cons_label": "Nachteile", "affiliate_cons_label": "Nachteile",
"affiliate_at_retailer": "bei {retailer}", "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"
} }

View File

@@ -1828,5 +1828,16 @@
"affiliate_pros_label": "Pros", "affiliate_pros_label": "Pros",
"affiliate_cons_label": "Cons", "affiliate_cons_label": "Cons",
"affiliate_at_retailer": "at {retailer}", "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"
} }

View 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 %}

View 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 %}