Improve UX error message

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-05 17:35:15 +02:00
parent a1236f866b
commit caeac0ed1c
5 changed files with 216 additions and 91 deletions

View File

@@ -13,17 +13,14 @@
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { IconPageCross } from "@probo/ui"; import {
Button,
ErrorDetailMessage,
ErrorDetails,
ErrorLayout,
} from "@probo/ui";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useLocation, useRouteError } from "react-router"; import { Link, useLocation, useRouteError } from "react-router";
const classNames = {
wrapper: "py-10 text-center space-y-2 ",
title: "text-2xl flex gap-2 font-semibold items-center justify-center",
description: "text-base text-txt-tertiary",
detail:
"text-sm text-txt-tertiary font-mono text-start border border-border-low p-2 rounded bg-level-1 mt-2",
};
type Props = { type Props = {
resetErrorBoundary?: () => void; resetErrorBoundary?: () => void;
@@ -31,11 +28,16 @@ type Props = {
}; };
export function PageError({ resetErrorBoundary, error: propsError }: Props) { export function PageError({ resetErrorBoundary, error: propsError }: Props) {
const error = useRouteError() ?? propsError; const routeError = useRouteError();
const error = routeError ?? propsError;
const { __ } = useTranslate(); const { __ } = useTranslate();
const location = useLocation(); const location = useLocation();
const baseLocation = useRef(location); const baseLocation = useRef(location);
const isFullPage = Boolean(routeError ?? propsError);
const isEmbeddedNotFound = !isFullPage
&& /^\/organizations\/[^/]+/.test(location.pathname);
// Reset error boundary on page change // Reset error boundary on page change
useEffect(() => { useEffect(() => {
if ( if (
@@ -46,44 +48,49 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
} }
}, [location, resetErrorBoundary]); }, [location, resetErrorBoundary]);
const actions = (
<Button asChild>
<Link to="/">{__("Go home")}</Link>
</Button>
);
const layoutProps = {
fullPage: isFullPage || !isEmbeddedNotFound,
showLogo: isFullPage,
actions,
};
if (!error || (error instanceof Error && error.message.includes("PAGE_NOT_FOUND"))) { if (!error || (error instanceof Error && error.message.includes("PAGE_NOT_FOUND"))) {
return ( return (
<div className={classNames.wrapper}> <ErrorLayout
<h1 className={classNames.title}> {...layoutProps}
<IconPageCross size={26} /> title={__("Page not found")}
{__("Page not found")} description={__("The page you are looking for does not exist.")}
</h1> />
<p className={classNames.description}>
{__("The page you are looking for does not exist")}
</p>
</div>
); );
} }
if (error instanceof Error && error.message.includes("FORBIDDEN")) { if (error instanceof Error && error.message.includes("FORBIDDEN")) {
return ( return (
<div className={classNames.wrapper}> <ErrorLayout
<h1 className={classNames.title}> {...layoutProps}
<IconPageCross size={26} /> title={__("Page not found")}
{__("Page not found")} description={__("The page you are looking for does not exist.")}
</h1> />
<p className={classNames.description}>
{__("The page you are looking for does not exist")}
</p>
</div>
); );
} }
return ( return (
<div className={classNames.wrapper}> <ErrorLayout
<h1 className={classNames.title}>{__("Unexpected error :(")}</h1> {...layoutProps}
<details> title={__("Something went wrong")}
<summary className={classNames.description}> description={__("We hit an unexpected error. Head back home to continue.")}
{__("Something went wrong")} >
</summary> {error instanceof Error && (
{error instanceof Error <ErrorDetails summary={__("Technical details")}>
&& <p className={classNames.detail}>{error.message}</p>} <ErrorDetailMessage>{error.message}</ErrorDetailMessage>
</details> </ErrorDetails>
</div> )}
</ErrorLayout>
); );
} }

View File

@@ -13,17 +13,14 @@
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { IconPageCross } from "@probo/ui"; import {
Button,
ErrorDetailMessage,
ErrorDetails,
ErrorLayout,
} from "@probo/ui";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useLocation, useRouteError } from "react-router"; import { Link, useLocation, useRouteError } from "react-router";
const classNames = {
wrapper: "py-10 text-center space-y-2 ",
title: "text-2xl flex gap-2 font-semibold items-center justify-center",
description: "text-base text-txt-tertiary",
detail:
"text-sm text-txt-tertiary font-mono text-start border border-border-low p-2 rounded bg-level-1 mt-2",
};
type Props = { type Props = {
resetErrorBoundary?: () => void; resetErrorBoundary?: () => void;
@@ -31,11 +28,14 @@ type Props = {
}; };
export function PageError({ resetErrorBoundary, error: propsError }: Props) { export function PageError({ resetErrorBoundary, error: propsError }: Props) {
const error = useRouteError() ?? propsError; const routeError = useRouteError();
const error = routeError ?? propsError;
const { __ } = useTranslate(); const { __ } = useTranslate();
const location = useLocation(); const location = useLocation();
const baseLocation = useRef(location); const baseLocation = useRef(location);
const isFullPage = Boolean(routeError ?? propsError);
// Reset error boundary on page change // Reset error boundary on page change
useEffect(() => { useEffect(() => {
if ( if (
@@ -46,17 +46,25 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
} }
}, [location, resetErrorBoundary]); }, [location, resetErrorBoundary]);
const actions = (
<Button asChild>
<Link to="/">{__("Go home")}</Link>
</Button>
);
const layoutProps = {
fullPage: isFullPage,
showLogo: isFullPage,
actions,
};
if (!error) { if (!error) {
return ( return (
<div className={classNames.wrapper}> <ErrorLayout
<h1 className={classNames.title}> {...layoutProps}
<IconPageCross size={26} /> title={__("Page not found")}
{__("Page not found")} description={__("The page you are looking for does not exist.")}
</h1> />
<p className={classNames.description}>
{__("The page you are looking for does not exist")}
</p>
</div>
); );
} }
@@ -77,26 +85,25 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
"This access link is not valid. It may have been revoked or the link might be incorrect.", "This access link is not valid. It may have been revoked or the link might be incorrect.",
); );
return ( return (
<div className={classNames.wrapper}> <ErrorLayout
<h1 className={classNames.title}> {...layoutProps}
<IconPageCross size={26} /> title={title}
{title} description={description}
</h1> />
<p className={classNames.description}>{description}</p>
</div>
); );
} }
return ( return (
<div className={classNames.wrapper}> <ErrorLayout
<h1 className={classNames.title}>{__("Unexpected error :(")}</h1> {...layoutProps}
<details> title={__("Something went wrong")}
<summary className={classNames.description}> description={__("We hit an unexpected error. Head back home to continue.")}
{__("Something went wrong")} >
</summary> {error instanceof Error && (
{error instanceof Error <ErrorDetails summary={__("Technical details")}>
&& <p className={classNames.detail}>{error.message}</p>} <ErrorDetailMessage>{error.message}</ErrorDetailMessage>
</details> </ErrorDetails>
</div> )}
</ErrorLayout>
); );
} }

View File

@@ -14,7 +14,12 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { ErrorLayout } from "./ErrorLayout"; import { Button } from "../Atoms/Button/Button";
import {
ErrorDetailMessage,
ErrorDetails,
ErrorLayout,
} from "./ErrorLayout";
export default { export default {
title: "Layouts/ErrorLayout", title: "Layouts/ErrorLayout",
@@ -26,7 +31,25 @@ type Story = StoryObj<typeof ErrorLayout>;
export const Default: Story = { export const Default: Story = {
args: { args: {
showLogo: true,
title: "Something went wrong", title: "Something went wrong",
description: "An unexpected error occurred", description: "We hit an unexpected error. Head back home to continue.",
actions: <Button>Go home</Button>,
children: (
<ErrorDetails summary="Technical details">
<ErrorDetailMessage>
Relay: Missing @required value at path &apos;organization&apos; in
&apos;ViewerMembershipLayoutQuery&apos;.
</ErrorDetailMessage>
</ErrorDetails>
),
},
};
export const NotFound: Story = {
args: {
title: "Page not found",
description: "The page you are looking for does not exist.",
actions: <Button>Go home</Button>,
}, },
}; };

View File

@@ -12,22 +12,106 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import type { PropsWithChildren } from "react"; import type { PropsWithChildren, ReactNode } from "react";
import { tv, type VariantProps } from "tailwind-variants";
import { IconCircleInfo } from "../Atoms/Icons"; import { Card } from "../Atoms/Card/Card";
import { IconChevronDown } from "../Atoms/Icons";
import { Logo } from "../Atoms/Logo/Logo";
type Props = PropsWithChildren<{ const errorLayout = tv({
title?: string; slots: {
description?: string; root: "w-full flex items-center justify-center px-6 py-16 bg-level-0 text-txt-primary",
}>; card: "w-full max-w-md flex flex-col items-center text-center px-8 py-10",
brand: "w-[110px] mb-8",
title: "text-xl font-semibold tracking-tight",
description: "text-sm text-txt-secondary mt-2 leading-relaxed max-w-xs",
details: "mt-6 w-full border-t border-border-mid pt-6",
actions: "mt-8 w-full flex justify-center",
},
variants: {
fullPage: {
true: {
root: "min-h-screen",
},
false: {
root: "py-12",
},
},
},
defaultVariants: {
fullPage: true,
},
});
const errorDetails = tv({
slots: {
root: "w-full text-start group",
summary:
"text-sm text-txt-tertiary cursor-pointer select-none list-none flex items-center justify-center gap-1 hover:text-txt-secondary transition-colors [&::-webkit-details-marker]:hidden",
marker: "transition-transform group-open:rotate-180",
panel: "mt-3 rounded-lg bg-subtle px-3 py-2.5",
message: "text-xs font-mono text-txt-tertiary break-all leading-relaxed",
},
});
type ErrorLayoutProps = PropsWithChildren<
{
title: string;
description?: string;
actions?: ReactNode;
showLogo?: boolean;
} & VariantProps<typeof errorLayout>
>;
export function ErrorLayout({
title,
description,
fullPage,
showLogo = false,
actions,
children,
}: ErrorLayoutProps) {
const classNames = errorLayout({ fullPage });
export function ErrorLayout({ title, description, children }: Props) {
return ( return (
<div className="min-h-screen w-full gap-2 flex flex-col items-center justify-center"> <div className={classNames.root()}>
<IconCircleInfo className="text-txt-danger" size={40} /> <Card className={classNames.card()}>
<h1 className="text-4xl font-bold">{title}</h1> {showLogo && (
<p className="text-txt-secondary">{description}</p> <Logo withPicto className={classNames.brand()} />
{children} )}
<h1 className={classNames.title()}>{title}</h1>
{description && (
<p className={classNames.description()}>{description}</p>
)}
{children && <div className={classNames.details()}>{children}</div>}
{actions && <div className={classNames.actions()}>{actions}</div>}
</Card>
</div> </div>
); );
} }
type ErrorDetailsProps = {
summary: string;
children: ReactNode;
};
export function ErrorDetails({ summary, children }: ErrorDetailsProps) {
const classNames = errorDetails();
return (
<details className={classNames.root()}>
<summary className={classNames.summary()}>
{summary}
<IconChevronDown size={14} className={classNames.marker()} />
</summary>
<div className={classNames.panel()}>{children}</div>
</details>
);
}
export function ErrorDetailMessage({ children }: PropsWithChildren) {
const classNames = errorDetails();
return <p className={classNames.message()}>{children}</p>;
}

View File

@@ -14,7 +14,11 @@
// Layouts // Layouts
export { Drawer, Layout } from "./Layouts/Layout"; export { Drawer, Layout } from "./Layouts/Layout";
export { ErrorLayout } from "./Layouts/ErrorLayout"; export {
ErrorDetailMessage,
ErrorDetails,
ErrorLayout,
} from "./Layouts/ErrorLayout";
export { export {
CenteredLayout, CenteredLayout,
CenteredLayoutSkeleton, CenteredLayoutSkeleton,