From caeac0ed1c65b8c5f95415d2838980aa294aad3b Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 5 Jun 2026 17:35:15 +0200 Subject: [PATCH] Improve UX error message Signed-off-by: Bryan Frimin --- apps/console/src/components/PageError.tsx | 85 +++++++------- apps/trust/src/components/PageError.tsx | 81 +++++++------ .../ui/src/Layouts/ErrorLayout.stories.tsx | 27 ++++- packages/ui/src/Layouts/ErrorLayout.tsx | 108 ++++++++++++++++-- packages/ui/src/index.ts | 6 +- 5 files changed, 216 insertions(+), 91 deletions(-) diff --git a/apps/console/src/components/PageError.tsx b/apps/console/src/components/PageError.tsx index b168baee1..988252fab 100644 --- a/apps/console/src/components/PageError.tsx +++ b/apps/console/src/components/PageError.tsx @@ -13,17 +13,14 @@ // PERFORMANCE OF THIS SOFTWARE. import { useTranslate } from "@probo/i18n"; -import { IconPageCross } from "@probo/ui"; +import { + Button, + ErrorDetailMessage, + ErrorDetails, + ErrorLayout, +} from "@probo/ui"; import { useEffect, useRef } from "react"; -import { 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", -}; +import { Link, useLocation, useRouteError } from "react-router"; type Props = { resetErrorBoundary?: () => void; @@ -31,11 +28,16 @@ type Props = { }; export function PageError({ resetErrorBoundary, error: propsError }: Props) { - const error = useRouteError() ?? propsError; + const routeError = useRouteError(); + const error = routeError ?? propsError; const { __ } = useTranslate(); const location = useLocation(); const baseLocation = useRef(location); + const isFullPage = Boolean(routeError ?? propsError); + const isEmbeddedNotFound = !isFullPage + && /^\/organizations\/[^/]+/.test(location.pathname); + // Reset error boundary on page change useEffect(() => { if ( @@ -46,44 +48,49 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) { } }, [location, resetErrorBoundary]); + const actions = ( + + ); + + const layoutProps = { + fullPage: isFullPage || !isEmbeddedNotFound, + showLogo: isFullPage, + actions, + }; + if (!error || (error instanceof Error && error.message.includes("PAGE_NOT_FOUND"))) { return ( -
-

- - {__("Page not found")} -

-

- {__("The page you are looking for does not exist")} -

-
+ ); } if (error instanceof Error && error.message.includes("FORBIDDEN")) { return ( -
-

- - {__("Page not found")} -

-

- {__("The page you are looking for does not exist")} -

-
+ ); } return ( -
-

{__("Unexpected error :(")}

-
- - {__("Something went wrong")} - - {error instanceof Error - &&

{error.message}

} -
-
+ + {error instanceof Error && ( + + {error.message} + + )} + ); } diff --git a/apps/trust/src/components/PageError.tsx b/apps/trust/src/components/PageError.tsx index 4a2f290d7..2d861b80d 100644 --- a/apps/trust/src/components/PageError.tsx +++ b/apps/trust/src/components/PageError.tsx @@ -13,17 +13,14 @@ // PERFORMANCE OF THIS SOFTWARE. import { useTranslate } from "@probo/i18n"; -import { IconPageCross } from "@probo/ui"; +import { + Button, + ErrorDetailMessage, + ErrorDetails, + ErrorLayout, +} from "@probo/ui"; import { useEffect, useRef } from "react"; -import { 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", -}; +import { Link, useLocation, useRouteError } from "react-router"; type Props = { resetErrorBoundary?: () => void; @@ -31,11 +28,14 @@ type Props = { }; export function PageError({ resetErrorBoundary, error: propsError }: Props) { - const error = useRouteError() ?? propsError; + const routeError = useRouteError(); + const error = routeError ?? propsError; const { __ } = useTranslate(); const location = useLocation(); const baseLocation = useRef(location); + const isFullPage = Boolean(routeError ?? propsError); + // Reset error boundary on page change useEffect(() => { if ( @@ -46,17 +46,25 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) { } }, [location, resetErrorBoundary]); + const actions = ( + + ); + + const layoutProps = { + fullPage: isFullPage, + showLogo: isFullPage, + actions, + }; + if (!error) { return ( -
-

- - {__("Page not found")} -

-

- {__("The page you are looking for does not exist")} -

-
+ ); } @@ -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.", ); return ( -
-

- - {title} -

-

{description}

-
+ ); } return ( -
-

{__("Unexpected error :(")}

-
- - {__("Something went wrong")} - - {error instanceof Error - &&

{error.message}

} -
-
+ + {error instanceof Error && ( + + {error.message} + + )} + ); } diff --git a/packages/ui/src/Layouts/ErrorLayout.stories.tsx b/packages/ui/src/Layouts/ErrorLayout.stories.tsx index 777e80b1d..6d36dad93 100644 --- a/packages/ui/src/Layouts/ErrorLayout.stories.tsx +++ b/packages/ui/src/Layouts/ErrorLayout.stories.tsx @@ -14,7 +14,12 @@ 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 { title: "Layouts/ErrorLayout", @@ -26,7 +31,25 @@ type Story = StoryObj; export const Default: Story = { args: { + showLogo: true, title: "Something went wrong", - description: "An unexpected error occurred", + description: "We hit an unexpected error. Head back home to continue.", + actions: , + children: ( + + + Relay: Missing @required value at path 'organization' in + 'ViewerMembershipLayoutQuery'. + + + ), + }, +}; + +export const NotFound: Story = { + args: { + title: "Page not found", + description: "The page you are looking for does not exist.", + actions: , }, }; diff --git a/packages/ui/src/Layouts/ErrorLayout.tsx b/packages/ui/src/Layouts/ErrorLayout.tsx index e7dd99c9d..35e71011a 100644 --- a/packages/ui/src/Layouts/ErrorLayout.tsx +++ b/packages/ui/src/Layouts/ErrorLayout.tsx @@ -12,22 +12,106 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // 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<{ - title?: string; - description?: string; -}>; +const errorLayout = tv({ + slots: { + 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 +>; + +export function ErrorLayout({ + title, + description, + fullPage, + showLogo = false, + actions, + children, +}: ErrorLayoutProps) { + const classNames = errorLayout({ fullPage }); -export function ErrorLayout({ title, description, children }: Props) { return ( -
- -

{title}

-

{description}

- {children} +
+ + {showLogo && ( + + )} +

{title}

+ {description && ( +

{description}

+ )} + {children &&
{children}
} + {actions &&
{actions}
} +
); } + +type ErrorDetailsProps = { + summary: string; + children: ReactNode; +}; + +export function ErrorDetails({ summary, children }: ErrorDetailsProps) { + const classNames = errorDetails(); + + return ( +
+ + {summary} + + +
{children}
+
+ ); +} + +export function ErrorDetailMessage({ children }: PropsWithChildren) { + const classNames = errorDetails(); + + return

{children}

; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a29f5b0c6..209c2c928 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -14,7 +14,11 @@ // Layouts export { Drawer, Layout } from "./Layouts/Layout"; -export { ErrorLayout } from "./Layouts/ErrorLayout"; +export { + ErrorDetailMessage, + ErrorDetails, + ErrorLayout, +} from "./Layouts/ErrorLayout"; export { CenteredLayout, CenteredLayoutSkeleton,