feat(client): unified EmptyState component for empty/error placeholders

Replaces the cards-with-halo SkeletonCard fallback (which produced a
washed-out, hard-to-read placeholder) with a single canonical empty
state used everywhere in the app.

What's new:

- EmptyStateIllustration.tsx — custom inline SVG: two stacked monitor
  cards (front blue, back gray, both with hairline strokes and a soft
  fade toward the bottom), set against a faint grid of solid + dashed
  background lines. Uses only lightened palette tokens (#7DA8E8 blue,
  #B6C2CF gray, #CBD5E1 grid).
- EmptyState.tsx — the layout: illustration + title + description +
  optional CTA + optional secondary link + optional inline alert slot.
  Supports a compact horizontal variant for in-panel use.

Migrations:

- Fallback.tsx: BaseFallback / EmptyFallback / EmptyMonitorFallback /
  ErrorFallback now delegate to EmptyState. Public APIs unchanged so
  every existing consumer (BasePageWithStates, MonitorBasePageWithStates,
  Pages/Uptime, Pages/Notifications, Pages/Infrastructure, etc.) keeps
  working without edits.
- BasePage.tsx: PageSpeedKeyPriorityFallback rebuilt on top of EmptyState
  with the warning slot. Fixes the previous reports of "white cloudy
  area" + half-faded text — message and link now render in consistent
  dark text with green Settings link.

No prop changes for any page-level consumer. The illustration is a
single SVG with no external assets.
This commit is contained in:
gorkem-bwl committed 2026-04-25 21:19:52 -04:00
1 parent 29f1041cc3
commit ff208710d7
5 files changed
+451 -144

No files matched your search

@@ -1,15 +1,14 @@
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
ErrorFallback,
EmptyFallback,
EmptyMonitorFallback,
BaseFallback,
} from "@/Components/design-elements/Fallback";
import { EmptyState } from "@/Components/design-elements/EmptyState";
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
import CircularProgress from "@mui/material/CircularProgress";
import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
@@ -21,32 +20,58 @@ import { Link as RouterLink } from "react-router-dom";
import { Typography } from "@mui/material";
export const PageSpeedKeyPriorityFallback = () => {
const theme = useTheme();
return (
<BaseFallback>
<Alert
severity="warning"
sx={{
width: "100%",
maxWidth: 600,
}}
>
<Typography>
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
settingsLink: (
<Link
component={RouterLink}
to="/settings"
color="inherit"
fontWeight="inherit"
/>
),
<EmptyState
title="PageSpeed monitor needs an API key"
alert={
<Stack
direction="row"
alignItems="flex-start"
gap={theme.spacing(LAYOUT.SM)}
sx={{
width: "100%",
p: theme.spacing(LAYOUT.MD),
borderRadius: 1,
border: `1px solid ${theme.palette.warning.main}`,
backgroundColor: theme.palette.warning.light,
textAlign: "left",
}}
>
<Box
component="span"
sx={{
color: theme.palette.warning.dark,
fontSize: 18,
lineHeight: 1,
mt: "2px",
}}
/>
</Typography>
</Alert>
</BaseFallback>
>
⚠
</Box>
<Typography
sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}
>
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
settingsLink: (
<Link
component={RouterLink}
to="/settings"
sx={{
color: theme.palette.primary.main,
fontWeight: 600,
textDecoration: "underline",
}}
/>
),
}}
/>
</Typography>
</Stack>
}
/>
);
};
@@ -0,0 +1,155 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { EmptyStateIllustration } from "./EmptyStateIllustration";
import type { ReactNode } from "react";
interface EmptyStateProps {
title: string;
description?: ReactNode;
actionText?: string;
actionTo?: string;
onAction?: () => void;
secondaryText?: string;
secondaryHref?: string;
compact?: boolean;
alert?: ReactNode;
children?: ReactNode;
}
export const EmptyState = ({
title,
description,
actionText,
actionTo,
onAction,
secondaryText,
secondaryHref,
compact = false,
alert,
children,
}: EmptyStateProps) => {
const theme = useTheme();
if (compact) {
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.MD)}
sx={{ py: theme.spacing(LAYOUT.MD), px: theme.spacing(LAYOUT.SM) }}
>
<EmptyStateIllustration
width={80}
height={60}
/>
<Stack sx={{ flex: 1 }}>
<Typography
sx={{
fontSize: 15,
fontWeight: 600,
color: theme.palette.text.primary,
}}
>
{title}
</Typography>
{description && (
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: 1.55,
}}
>
{description}
</Typography>
)}
</Stack>
</Stack>
);
}
return (
<Stack
alignItems="center"
justifyContent="center"
sx={{
py: theme.spacing(LAYOUT.XXL),
px: theme.spacing(LAYOUT.MD),
textAlign: "center",
}}
>
<EmptyStateIllustration />
<Typography
sx={{
mt: theme.spacing(LAYOUT.MD),
mb: theme.spacing(LAYOUT.XS),
fontSize: 16,
fontWeight: 600,
color: theme.palette.text.primary,
}}
>
{title}
</Typography>
{description && (
<Typography
sx={{
maxWidth: 360,
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: 1.55,
mb: theme.spacing(LAYOUT.LG),
}}
>
{description}
</Typography>
)}
{alert && (
<Stack sx={{ width: "100%", maxWidth: 520, mb: theme.spacing(LAYOUT.MD) }}>
{alert}
</Stack>
)}
{(actionText || secondaryText) && (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.MD)}
>
{actionText && (actionTo || onAction) && (
<Button
variant="contained"
color="primary"
{...(actionTo
? { component: RouterLink, to: actionTo }
: { onClick: onAction })}
>
{actionText}
</Button>
)}
{secondaryText && secondaryHref && (
<Typography
component="a"
href={secondaryHref}
target="_blank"
rel="noopener"
sx={{
fontSize: 13,
fontWeight: 500,
color: theme.palette.primary.main,
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{secondaryText} →
</Typography>
)}
</Stack>
)}
{children}
</Stack>
);
};
@@ -0,0 +1,214 @@
interface EmptyStateIllustrationProps {
width?: number | string;
height?: number | string;
}
export const EmptyStateIllustration = ({
width = 160,
height = 120,
}: EmptyStateIllustrationProps) => {
return (
<svg
viewBox="0 0 160 120"
width={width}
height={height}
aria-hidden="true"
>
<defs>
<linearGradient
id="emptyStateCardFade"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="white"
stopOpacity="1"
/>
<stop
offset="65%"
stopColor="white"
stopOpacity="1"
/>
<stop
offset="100%"
stopColor="white"
stopOpacity="0.35"
/>
</linearGradient>
<mask
id="emptyStateCardMask"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="160"
height="120"
>
<rect
x="0"
y="0"
width="160"
height="120"
fill="url(#emptyStateCardFade)"
/>
</mask>
</defs>
<g
stroke="#CBD5E1"
strokeWidth="0.75"
>
<line
x1="16"
y1="0"
x2="16"
y2="120"
/>
<line
x1="56"
y1="0"
x2="56"
y2="120"
strokeDasharray="3 3"
/>
<line
x1="104"
y1="0"
x2="104"
y2="120"
strokeDasharray="3 3"
/>
<line
x1="144"
y1="0"
x2="144"
y2="120"
/>
<line
x1="0"
y1="12"
x2="160"
y2="12"
/>
<line
x1="0"
y1="44"
x2="160"
y2="44"
strokeDasharray="3 3"
/>
<line
x1="0"
y1="76"
x2="160"
y2="76"
strokeDasharray="3 3"
/>
<line
x1="0"
y1="108"
x2="160"
y2="108"
/>
</g>
<g mask="url(#emptyStateCardMask)">
<rect
x="38"
y="22"
width="92"
height="42"
rx="6"
fill="#F5F7FA"
stroke="#D5DBE3"
strokeWidth="0.5"
/>
<rect
x="48"
y="32"
width="14"
height="22"
rx="2"
fill="#B6C2CF"
opacity="0.5"
/>
<rect
x="68"
y="34"
width="50"
height="2.5"
rx="1.25"
fill="#B6C2CF"
opacity="0.7"
/>
<rect
x="68"
y="42"
width="38"
height="2.5"
rx="1.25"
fill="#B6C2CF"
opacity="0.55"
/>
<rect
x="68"
y="50"
width="44"
height="2.5"
rx="1.25"
fill="#B6C2CF"
opacity="0.55"
/>
<rect
x="22"
y="50"
width="92"
height="42"
rx="6"
fill="#EEF4FE"
stroke="#7DA8E8"
strokeWidth="0.5"
strokeOpacity="0.4"
/>
<rect
x="32"
y="60"
width="14"
height="22"
rx="2"
fill="#7DA8E8"
opacity="0.35"
/>
<rect
x="52"
y="62"
width="50"
height="2.5"
rx="1.25"
fill="#7DA8E8"
opacity="0.55"
/>
<rect
x="52"
y="70"
width="38"
height="2.5"
rx="1.25"
fill="#7DA8E8"
opacity="0.4"
/>
<rect
x="52"
y="78"
width="44"
height="2.5"
rx="1.25"
fill="#7DA8E8"
opacity="0.4"
/>
</g>
</svg>
);
};
@@ -1,12 +1,8 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useNavigate } from "react-router";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { EmptyState } from "./EmptyState";
import type { StackProps } from "@mui/material/Stack";
interface BaseFallbackProps extends StackProps {
@@ -15,30 +11,13 @@ interface BaseFallbackProps extends StackProps {
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
alignItems={"center"}
margin={isSmall ? "inherit" : "auto"}
marginTop={isSmall ? "33%" : "10%"}
width={{
sm: "90%",
md: "70%",
lg: "50%",
xl: "40%",
}}
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.shape.borderRadius}
sx={{
borderStyle: "dashed",
}}
alignItems="center"
justifyContent="center"
sx={{ width: "100%", py: theme.spacing(LAYOUT.LG) }}
{...props}
>
<SkeletonCard showHalo={true} />
{children}
</Stack>
);
@@ -51,21 +30,19 @@ export const ErrorFallback = ({
title: string;
subtitle: string;
}) => {
const theme = useTheme();
return (
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(LAYOUT.XS)}
color={theme.palette.text.secondary}
>
{title}
</Typography>
<Typography>{subtitle}</Typography>
</BaseFallback>
<EmptyState
title={title}
description={subtitle}
/>
);
};
const bulletsToDescription = (bullets: unknown): string | undefined => {
if (!Array.isArray(bullets) || bullets.length === 0) return undefined;
return bullets.join(" • ");
};
export const EmptyFallback = ({
title,
bullets,
@@ -73,52 +50,21 @@ export const EmptyFallback = ({
actionLink,
}: {
title: string;
bullets: any;
bullets: unknown;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
<Typography component="h1">{title}</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
maxWidth: "1100px",
width: "100%",
}}
>
{Array.isArray(bullets) &&
bullets?.map((bullet: string) => (
<BulletPointCheck
text={bullet}
key={`${bullet}-${Math.random()}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="primary"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
<EmptyState
title={title}
description={bulletsToDescription(bullets)}
actionText={actionButtonText}
actionTo={actionLink}
/>
);
};
export const EmptyMonitorFallback = ({
page,
title,
bullets,
actionButtonText,
@@ -126,51 +72,16 @@ export const EmptyMonitorFallback = ({
}: {
page: string;
title: string;
bullets: any;
bullets: unknown;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
maxWidth: "1100px",
width: "100%",
}}
>
{Array.isArray(bullets) &&
bullets?.map((bullet: string, index: number) => (
<BulletPointCheck
text={bullet}
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="primary"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
<EmptyState
title={title}
description={bulletsToDescription(bullets)}
actionText={actionButtonText}
actionTo={actionLink}
/>
);
};
@@ -1,5 +1,7 @@
export * from "./Breadcrumb";
export * from "./BasePage";
export * from "./EmptyState";
export * from "./EmptyStateIllustration";
export * from "./Fallback";
export * from "./SkeletonCard";
export * from "./BulletPointCheck";