Polish public error and 404 screens with brand atmosphere.
Local Build and Deploy / deploy (push) Successful in 1m31s
Local Build and Deploy / deploy (push) Successful in 1m31s
Shared ErrorScreen composition, i18n for en/nl/it, Sentry on route errors, and a self-contained global-error fallback that matches the hotel gold theme. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
b22725d3a9
commit
1f552c2822
8 files changed
+348
-80
No files matched your search
+32
-42
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { Home, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { ErrorScreen } from "@/components/error-screen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route-segment error boundary. Renders inside the root layout (so the shell
|
* Route-segment error boundary. Renders inside the root layout (so the shell
|
||||||
* stays), shows a friendly card, and offers a reset. The raw error is logged to
|
* stays). Never shows the raw error to the user.
|
||||||
* the console, never shown to the user.
|
|
||||||
*/
|
*/
|
||||||
export default function ErrorPage({
|
export default function ErrorPage({
|
||||||
error,
|
error,
|
||||||
@@ -15,51 +18,38 @@ export default function ErrorPage({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.error");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<ErrorScreen
|
||||||
<div className="content-card">
|
code={t("code")}
|
||||||
<div className="content-card-head">
|
title={t("title")}
|
||||||
<span className="content-card-icon" aria-hidden>
|
subtitle={t("subtitle")}
|
||||||
⚠️
|
body={t("body")}
|
||||||
</span>
|
actions={
|
||||||
<div className="content-card-head-text">
|
<>
|
||||||
<p className="content-card-title">Something went wrong</p>
|
<button type="button" className="btn btn-primary" onClick={() => reset()}>
|
||||||
<p className="content-card-subtitle">
|
<RefreshCw className="error-screen-btn-icon" aria-hidden />
|
||||||
An unexpected error occurred
|
{t("tryAgain")}
|
||||||
</p>
|
</button>
|
||||||
</div>
|
<Link className="btn btn-outline" href="/">
|
||||||
</div>
|
<Home className="error-screen-btn-icon" aria-hidden />
|
||||||
<div className="content-card-body">
|
{t("backHome")}
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
</Link>
|
||||||
Try again — if the problem persists, please contact a member of
|
</>
|
||||||
staff.
|
}
|
||||||
|
footer={
|
||||||
|
error.digest ? (
|
||||||
|
<p className="error-screen-digest">
|
||||||
|
{t("reference", { digest: error.digest })}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
) : null
|
||||||
<button
|
}
|
||||||
type="button"
|
/>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => reset()}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<Link className="btn btn-outline" href="/">
|
|
||||||
Back home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{error.digest ? (
|
|
||||||
<p
|
|
||||||
className="muted"
|
|
||||||
style={{ marginTop: "0.75rem", fontSize: "0.75rem" }}
|
|
||||||
>
|
|
||||||
Reference: {error.digest}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+81
-25
@@ -6,7 +6,7 @@ import { useEffect } from "react";
|
|||||||
/**
|
/**
|
||||||
* Last-resort boundary for errors thrown in the root layout itself. It replaces
|
* Last-resort boundary for errors thrown in the root layout itself. It replaces
|
||||||
* the whole document, so it must render its own <html>/<body> and can't rely on
|
* the whole document, so it must render its own <html>/<body> and can't rely on
|
||||||
* the app shell or its CSS variables.
|
* the app shell or CSS variables from globals.css.
|
||||||
*/
|
*/
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
@@ -27,35 +27,91 @@ export default function GlobalError({
|
|||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
placeItems: "center",
|
placeItems: "center",
|
||||||
fontFamily: "Nunito, system-ui, sans-serif",
|
padding: "1.5rem",
|
||||||
background: "#0f1117",
|
fontFamily:
|
||||||
color: "#e5e7eb",
|
'Nunito, ui-rounded, "Segoe UI", system-ui, sans-serif',
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 80% 60% at 50% -10%, rgba(238, 180, 37, 0.22), transparent 55%), linear-gradient(165deg, #141821 0%, #0c0e14 45%, #10141c 100%)",
|
||||||
|
color: "#f3f4f6",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: "center", padding: "2rem", maxWidth: 420 }}>
|
<style>{`
|
||||||
<div style={{ fontSize: "2.5rem" }}>⚠️</div>
|
@keyframes error-rise {
|
||||||
<h1 style={{ margin: "0.5rem 0", fontSize: "1.4rem" }}>
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
Something went wrong
|
to { opacity: 1; transform: translateY(0); }
|
||||||
</h1>
|
}
|
||||||
<p style={{ color: "#9ca3af", marginTop: 0 }}>
|
.ge-panel {
|
||||||
The hotel ran into an unexpected error. Please try again.
|
animation: error-rise 0.45s ease-out both;
|
||||||
|
width: min(100%, 26rem);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.25rem 1.75rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
border: 1px solid rgba(238, 180, 37, 0.22);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 0.06),
|
||||||
|
rgba(255, 255, 255, 0.02)
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px rgba(0, 0, 0, 0.35),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.ge-code {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-family: "Pixelify Sans", "Courier New", monospace;
|
||||||
|
font-size: clamp(2.5rem, 8vw, 3.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1;
|
||||||
|
color: #eeb425;
|
||||||
|
text-shadow: 0 0 40px rgba(238, 180, 37, 0.35);
|
||||||
|
}
|
||||||
|
.ge-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.ge-body {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.ge-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.65rem 1.35rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #eeb425;
|
||||||
|
color: #1a1a2e;
|
||||||
|
transition: transform 0.15s ease, filter 0.15s ease;
|
||||||
|
}
|
||||||
|
.ge-btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
||||||
|
.ge-btn:active { transform: translateY(0); }
|
||||||
|
.ge-digest {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="ge-panel" role="alert">
|
||||||
|
<p className="ge-code">Error</p>
|
||||||
|
<h1 className="ge-title">Something went wrong</h1>
|
||||||
|
<p className="ge-body">
|
||||||
|
The hotel ran into an unexpected error. Please try again — if it
|
||||||
|
keeps happening, contact staff.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button type="button" className="ge-btn" onClick={() => reset()}>
|
||||||
type="button"
|
|
||||||
onClick={() => reset()}
|
|
||||||
style={{
|
|
||||||
marginTop: "1rem",
|
|
||||||
padding: "0.55rem 1.4rem",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#f59e0b",
|
|
||||||
color: "#1a1a2e",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
|
{error.digest ? (
|
||||||
|
<p className="ge-digest">Reference: {error.digest}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1357,3 +1357,130 @@ details[open] > summary .details-open\:block {
|
|||||||
details[open] > summary .details-open\:rotate-180 {
|
details[open] > summary .details-open\:rotate-180 {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Public error / 404 screen ───────────────────────────── */
|
||||||
|
@keyframes error-screen-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: min(70vh, 36rem);
|
||||||
|
padding: 2rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-glow {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 10% 15% auto;
|
||||||
|
height: 12rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
color-mix(in srgb, var(--color-primary) 35%, transparent),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
filter: blur(8px);
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.25rem 1.75rem 2rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--border-subtle));
|
||||||
|
background: linear-gradient(
|
||||||
|
165deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
|
||||||
|
var(--color-surface)
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 48px color-mix(in srgb, rgb(0, 0, 0) 18%, transparent),
|
||||||
|
inset 0 1px 0 color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
|
animation: error-screen-rise 0.45s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-code {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-family: var(--font-pixel), "Courier New", monospace;
|
||||||
|
font-size: clamp(2.75rem, 9vw, 3.75rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-primary-readable, var(--color-primary));
|
||||||
|
text-shadow: 0 0 36px color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-title {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-subtitle {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-body {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-btn-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-footer {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-screen-digest {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.error-screen-panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-13
@@ -1,22 +1,29 @@
|
|||||||
|
import { Home, Newspaper } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ErrorScreen } from "@/components/error-screen";
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
const t = await getTranslations("pages.notFound");
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<ErrorScreen
|
||||||
<ContentCard icon="🧭" title="Page not found" subtitle="Error 404">
|
code={t("code")}
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
title={t("title")}
|
||||||
The page you’re looking for doesn’t exist or has moved.
|
subtitle={t("subtitle")}
|
||||||
</p>
|
body={t("body")}
|
||||||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
actions={
|
||||||
|
<>
|
||||||
<Link className="btn btn-primary" href="/">
|
<Link className="btn btn-primary" href="/">
|
||||||
Back home
|
<Home className="error-screen-btn-icon" aria-hidden />
|
||||||
|
{t("backHome")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="btn btn-outline" href="/news">
|
<Link className="btn btn-outline" href="/news">
|
||||||
Latest news
|
<Newspaper className="error-screen-btn-icon" aria-hidden />
|
||||||
|
{t("latestNews")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</>
|
||||||
</ContentCard>
|
}
|
||||||
</main>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared public error / empty-route composition.
|
||||||
|
* Used by route error.tsx and not-found.tsx (inside the app shell).
|
||||||
|
*/
|
||||||
|
export function ErrorScreen({
|
||||||
|
code,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
body,
|
||||||
|
actions,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
body: string;
|
||||||
|
actions: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="error-screen">
|
||||||
|
<div className="error-screen-glow" aria-hidden />
|
||||||
|
<section className="error-screen-panel" aria-labelledby="error-screen-title">
|
||||||
|
<p className="error-screen-code">{code}</p>
|
||||||
|
<h1 id="error-screen-title" className="error-screen-title">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="error-screen-subtitle">{subtitle}</p>
|
||||||
|
<p className="error-screen-body">{body}</p>
|
||||||
|
<div className="error-screen-actions">{actions}</div>
|
||||||
|
{footer ? <div className="error-screen-footer">{footer}</div> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,23 @@
|
|||||||
"darkMode": "Dark mode"
|
"darkMode": "Dark mode"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
|
"error": {
|
||||||
|
"code": "Error",
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"subtitle": "The hotel hit an unexpected snag.",
|
||||||
|
"body": "Try again in a moment. If it keeps happening, contact staff with the reference below.",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"backHome": "Back home",
|
||||||
|
"reference": "Reference: {digest}"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"code": "404",
|
||||||
|
"title": "Page not found",
|
||||||
|
"subtitle": "This room doesn’t exist — or the furniture was moved.",
|
||||||
|
"body": "Check the link, or head back to the lobby.",
|
||||||
|
"backHome": "Back home",
|
||||||
|
"latestNews": "Latest news"
|
||||||
|
},
|
||||||
"staff": {
|
"staff": {
|
||||||
"title": "Staff",
|
"title": "Staff",
|
||||||
"subtitle": "The team behind the hotel",
|
"subtitle": "The team behind the hotel",
|
||||||
|
|||||||
@@ -40,6 +40,23 @@
|
|||||||
"darkMode": "Tema scuro"
|
"darkMode": "Tema scuro"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
|
"error": {
|
||||||
|
"code": "Errore",
|
||||||
|
"title": "Qualcosa è andato storto",
|
||||||
|
"subtitle": "L'hotel ha incontrato un imprevisto.",
|
||||||
|
"body": "Riprova tra poco. Se continua, contatta lo staff indicando il riferimento qui sotto.",
|
||||||
|
"tryAgain": "Riprova",
|
||||||
|
"backHome": "Torna alla home",
|
||||||
|
"reference": "Riferimento: {digest}"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"code": "404",
|
||||||
|
"title": "Pagina non trovata",
|
||||||
|
"subtitle": "Questa stanza non esiste — o i mobili sono stati spostati.",
|
||||||
|
"body": "Controlla il link, oppure torna alla lobby.",
|
||||||
|
"backHome": "Torna alla home",
|
||||||
|
"latestNews": "Ultime notizie"
|
||||||
|
},
|
||||||
"staff": {
|
"staff": {
|
||||||
"title": "Staff",
|
"title": "Staff",
|
||||||
"subtitle": "Il team dietro l'hotel",
|
"subtitle": "Il team dietro l'hotel",
|
||||||
|
|||||||
@@ -40,6 +40,23 @@
|
|||||||
"darkMode": "Donkere modus"
|
"darkMode": "Donkere modus"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
|
"error": {
|
||||||
|
"code": "Fout",
|
||||||
|
"title": "Er ging iets mis",
|
||||||
|
"subtitle": "Het hotel liep tegen een onverwachte snag aan.",
|
||||||
|
"body": "Probeer het zo opnieuw. Blijft het probleem? Neem contact op met de staff en noem de referentie hieronder.",
|
||||||
|
"tryAgain": "Opnieuw proberen",
|
||||||
|
"backHome": "Terug naar home",
|
||||||
|
"reference": "Referentie: {digest}"
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"code": "404",
|
||||||
|
"title": "Pagina niet gevonden",
|
||||||
|
"subtitle": "Deze kamer bestaat niet — of de meubels zijn verplaatst.",
|
||||||
|
"body": "Controleer de link, of ga terug naar de lobby.",
|
||||||
|
"backHome": "Terug naar home",
|
||||||
|
"latestNews": "Laatste nieuws"
|
||||||
|
},
|
||||||
"staff": {
|
"staff": {
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
"subtitle": "Het team achter het hotel",
|
"subtitle": "Het team achter het hotel",
|
||||||
|
|||||||
Reference in new issue
Block a user