Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/smart-stamps-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-app": minor
---

In case of a client error (400, 403 and 404) show a visually distinct page than for an error (500) to make it more clear to the user.
5 changes: 1 addition & 4 deletions fdm-app/app/components/blocks/helpdesk/ticket-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,7 @@ export function TicketViewer({
}

return (
<div
ref={containerRef}
className="relative flex h-[calc(100vh-16*calc(var(--spacing)))] flex-row"
>
<div ref={containerRef} className="relative flex h-full min-h-0 flex-row">
{/* Static sidebar — only rendered on xl+ screens */}
{isXl && (
<aside className="border-sidebar-border bg-background flex w-100 shrink-0 flex-col border-r">
Expand Down
277 changes: 220 additions & 57 deletions fdm-app/app/components/custom/error.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,93 @@
import * as Sentry from "@sentry/react-router"
import { ArrowLeft, Copy, Home } from "lucide-react"
import { ArrowLeft, Compass, Copy, Home, LifeBuoy } from "lucide-react"
import { useEffect, useState } from "react"
import { NavLink, useNavigate } from "react-router"
import { isRouteErrorResponse, NavLink, redirect, useLocation, useNavigate } from "react-router"
import { Button } from "~/components/ui/button"
import { useAnalytics } from "~/hooks/use-analytics"
import { clientConfig } from "~/lib/config"
import { normalizePage } from "~/lib/url-utils"

export const CLIENT_ERROR_STATUSES = [400, 403, 404]

/**
* Displays a full-screen error block with tailored messaging and navigation options.
*
* Depending on the provided error status, this component renders:
* - A specific message and navigation buttons for a 404 error, indicating that the page does not exist.
* - A generic error message along with a button to copy the formatted error details (including status, message, stack trace, page, and timestamp) to the clipboard for other errors.
* Full-screen, generic "page unavailable" state for client error statuses (400, 403, 404).
*
* If an error message is available, the component also displays the error details formatted as pretty-printed JSON. Otherwise, it shows a fallback message for non-404 errors.
* Deliberately visually distinct from the 5xx/unexpected-error UI below (no illustration, no
* grey background, no stack trace) — this is an expected, everyday state a user might land on by
* accident, not a bug. It borrows the brand-mark treatment already used on the sign-in/auth
* surfaces (a solid `#122023` badge) to give the moment real presence, with a short reassuring
* message and a clearly prioritized set of next steps, none of which reveal whether the specific
* page/resource exists or the user simply lacks permission for it.
*
* @param status - HTTP status code of the error or null.
* @param message - Detailed error message, or null if not available.
* @param stacktrace - Optional stack trace providing additional error context.
* @param page - The page where the error occurred.
* @param timestamp - The timestamp when the error was recorded.
* @param status - The real HTTP-like status (400/403/404), for analytics only — never rendered,
* so the page itself still never reveals to the *user* which case they hit.
*/
export function ErrorBlock({
export function ClientErrorPage({ status }: { status?: number | null } = {}) {
const navigate = useNavigate()
const { capture } = useAnalytics()

useEffect(() => {
capture("client_error_page_viewed", {
status: status ?? null,
page: normalizePage(window.location.pathname),
})
}, [capture, status])

return (
<div className="bg-background flex min-h-screen items-center justify-center px-4">
<div className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 flex max-w-md flex-col items-center text-center motion-safe:duration-500">
<div className="mb-6 flex aspect-square size-16 items-center justify-center rounded-2xl bg-[#122023]">
<Compass className="size-8 text-white" strokeWidth={1.75} />
</div>

<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
Deze pagina is niet beschikbaar
</h1>
<p className="text-muted-foreground mt-4 text-lg text-pretty">
Het lijkt erop dat deze pagina niet bestaat, of dat je er geen toegang tot hebt.
Controleer het adres en of je toegang hebt, of neem contact op als je denkt dat dit niet
klopt.
</p>

<div className="mt-8 flex w-full flex-col items-center gap-4">
<Button
size="lg"
className="w-full sm:w-auto"
onClick={() => {
if (window.history.length > 1) {
void navigate(-1)
} else {
void navigate("/")
}
}}
>
<ArrowLeft className="mr-2 size-4" /> Terug naar vorige pagina
</Button>
<div className="flex items-center gap-6">
<Button variant="link" className="text-muted-foreground h-auto p-0" asChild>
<NavLink to="/farm">
<Home className="mr-1.5 size-3.5" /> Mijn bedrijven
</NavLink>
</Button>
<Button variant="link" className="text-muted-foreground h-auto p-0" asChild>
<NavLink to="/support/new">
<LifeBuoy className="mr-1.5 size-3.5" /> Ondersteuning
</NavLink>
</Button>
</div>
</div>
</div>
</div>
)
}

/**
* Full-screen diagnostic UI for genuinely unexpected errors: server 5xx route-error responses,
* thrown `Error` instances (e.g. a component that throws while rendering), or any other unknown
* thrown value. Deliberately distinct from {@link ClientErrorPage} — this *is* a bug, so it shows
* the tractor illustration, the raw error details, and a way to copy them for a support request.
*/
export function UnexpectedErrorPage({
status,
message,
stacktrace,
Expand All @@ -35,7 +101,7 @@ export function ErrorBlock({
timestamp: string
}) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle")
const navigate = useNavigate()
const { capture } = useAnalytics()

useEffect(() => {
if (clientConfig.analytics.sentry) {
Expand All @@ -45,7 +111,11 @@ export function ErrorBlock({
Sentry.metrics.count("error_block.shown", 1)
})
}
}, [status, page])
capture("unexpected_error_page_viewed", {
status: status ?? null,
page: normalizePage(page),
})
}, [status, page, capture])

useEffect(() => {
if (copyState !== "idle") {
Expand Down Expand Up @@ -81,6 +151,7 @@ export function ErrorBlock({
setCopyState("failed")
}
}

return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 px-4 dark:bg-gray-900">
<div className="mb-8 w-full max-w-md overflow-hidden rounded-lg">
Expand All @@ -91,50 +162,28 @@ export function ErrorBlock({
/>
</div>
<h1 className="mb-2 text-4xl font-bold text-gray-900 dark:text-gray-100">
{status === 404 ? "Aii, deze pagina bestaat niet." : "Oeps, er lijkt iets mis te zijn."}
Oeps, er lijkt iets mis te zijn.
</h1>
<p className="mb-8 text-center text-xl text-gray-600 dark:text-gray-400">
{status === 404
? "Het lijkt erop dat de pagina die je zoekt niet bestaat."
: "Er is onverwachts wat fout gegaan. Probeer eerst opnieuw. Als het niet opnieuw lukt, kopieer dan de foutmelding en neem contact op met Ondersteuning."}
Er is onverwachts wat fout gegaan. Probeer eerst opnieuw. Als het niet opnieuw lukt, kopieer
dan de foutmelding en neem contact op met Ondersteuning.
</p>

{status === 404 ? (
<div className="flex flex-col gap-4 sm:flex-row">
<Button
onClick={() => {
if (window.history.length > 1) {
void navigate(-1)
} else {
void navigate("/")
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" /> Terug naar vorige pagina
</Button>
<Button variant="outline" asChild>
<NavLink to="/">
<Home className="mr-2 h-4 w-4" /> Terug naar de hoofdpagina
</NavLink>
</Button>
</div>
) : (
<div className="flex flex-col gap-4 sm:flex-row">
<Button asChild>
<NavLink to="/">
<Home className="mr-2 h-4 w-4" /> Terug naar de hoofdpagina
</NavLink>
</Button>
<Button variant="outline" onClick={copyStackTrace}>
<Copy className="mr-2 h-4 w-4" />
{copyState === "copied"
? "Gekopieerd!"
: copyState === "failed"
? "Browser staat niet toe om te kopiëren — selecteer de tekst hieronder en kopieer handmatig"
: "Kopieer foutmelding"}
</Button>
</div>
)}
<div className="flex flex-col gap-4 sm:flex-row">
<Button asChild>
<NavLink to="/">
<Home className="mr-2 h-4 w-4" /> Terug naar de hoofdpagina
</NavLink>
</Button>
<Button variant="outline" onClick={copyStackTrace}>
<Copy className="mr-2 h-4 w-4" />
{copyState === "copied"
? "Gekopieerd!"
: copyState === "failed"
? "Browser staat niet toe om te kopiëren — selecteer de tekst hieronder en kopieer handmatig"
: "Kopieer foutmelding"}
</Button>
</div>
{message ? (
<div className="mt-8 w-full max-w-2xl">
<h2 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Expand All @@ -144,11 +193,125 @@ export function ErrorBlock({
{errorDetails}
</pre>
</div>
) : status === 404 ? null : (
) : (
<p className="mt-8 text-gray-600 dark:text-gray-400">
Er zijn helaas geen details over de fout beschikbaar.
</p>
)}
</div>
)
}

/**
* Displays a full-screen error block with tailored messaging and navigation options.
*
* Depending on the provided error status, this component renders:
* - One unified, generic "page unavailable" message for client error statuses (400, 403, 404),
* via {@link ClientErrorPage}. This deliberately never reveals whether a specific resource
* exists or the user simply lacks permission for it — both cases look identical to the user.
* - The diagnostic {@link UnexpectedErrorPage} for anything else.
*
* @param status - HTTP status code of the error or null.
* @param message - Detailed error message, or null if not available.
* @param stacktrace - Optional stack trace providing additional error context.
* @param page - The page where the error occurred.
* @param timestamp - The timestamp when the error was recorded.
*/
export function ErrorBlock({
status,
message,
stacktrace,
page,
timestamp,
}: {
status: number | null
message: string | null
stacktrace: string | null | undefined
page: string
timestamp: string
}) {
const isClientError = status !== null && CLIENT_ERROR_STATUSES.includes(status)

if (isClientError) {
return <ClientErrorPage status={status} />
}

return (
<UnexpectedErrorPage
status={status}
message={message}
stacktrace={stacktrace}
page={page}
timestamp={timestamp}
/>
)
}

/**
* Classifies the current route error and renders the right UI for it. Meant to be used from a
* route module's `ErrorBoundary` export (pass its `error` prop straight through) — including
* ones nested under a layout route that wants to keep its own shell (sidebar/header) around this
* component rather than losing it entirely, as `root.tsx`'s top-level boundary would.
*
* - Redirects to sign-in for a `401` route error response.
* - Renders the generic, friendly {@link ClientErrorPage} for expected client errors (400, 403,
* 404) — a route/resource that doesn't exist or one the user lacks permission for.
* - Renders the diagnostic {@link UnexpectedErrorPage} for everything else: `5xx` route error
* responses, thrown `Error` instances (e.g. a component that throws while rendering — a real
* bug, not an expected state), or any other unknown thrown value. Client-side errors that
* weren't already captured server-side are reported to Sentry here.
*/
export function RouteErrorFallback({ error }: { error: unknown }) {
const location = useLocation()
const page = location.pathname
const timestamp = new Date().toISOString()

if (isRouteErrorResponse(error)) {
// Redirect to signin page if authentication is not provided
if (error.status === 401) {
const currentPath = location.pathname + location.search + location.hash
const signInUrl = `./signin?redirectTo=${encodeURIComponent(currentPath)}`
throw redirect(signInUrl)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (CLIENT_ERROR_STATUSES.includes(error.status)) {
return <ClientErrorPage status={error.status} />
}

// Server-side errors are already captured in Sentry via handleError / reportError.
// No need to capture again client-side.
return (
<UnexpectedErrorPage
status={error.status}
message={error.statusText}
stacktrace={error.data}
page={page}
timestamp={timestamp}
/>
)
}

if (error instanceof Error) {
// Client-side JS error (e.g. a component that threw while rendering) — not captured
// server-side, so capture here.
Sentry.captureException(error)
return (
<UnexpectedErrorPage
status={500}
message={error.message}
stacktrace={error.stack}
page={page}
timestamp={timestamp}
/>
)
}

if (error === null) {
return null
}

Sentry.captureException(error)
return (
<UnexpectedErrorPage status={500} message="Unknown Error" stacktrace={null} page={page} timestamp={timestamp} />
)
}
Loading
Loading