Updated loading states and error states accross app
This commit is contained in:
162
src/components/UI/ErrorState.tsx
Normal file
162
src/components/UI/ErrorState.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faTriangleExclamation,
|
||||
faRotateRight,
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faClipboard,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useState, type FC } from "react";
|
||||
|
||||
type Variant = "inline" | "card" | "banner";
|
||||
|
||||
export type ErrorStateProps = {
|
||||
/** Main heading shown to the user */
|
||||
title?: string;
|
||||
/** Friendly message for the user */
|
||||
message?: string;
|
||||
/** Raw error to help devs (object, string, whatever) */
|
||||
error?: unknown;
|
||||
/** Called when user clicks Retry */
|
||||
onRetry?: () => Promise<void> | void;
|
||||
/** Show a Retry button */
|
||||
showRetry?: boolean;
|
||||
/** Optional custom icon */
|
||||
icon?: IconDefinition;
|
||||
/** Visual style */
|
||||
variant?: Variant;
|
||||
/** Additional actions (e.g. “Report”) */
|
||||
actions?: React.ReactNode;
|
||||
/** Test id for testing */
|
||||
"data-testid"?: string;
|
||||
/** ClassName passthrough */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function formatError(err: unknown) {
|
||||
if (!err) return "";
|
||||
if (typeof err === "string") return err;
|
||||
if (err instanceof Error) return err.stack || err.message;
|
||||
try {
|
||||
return JSON.stringify(err, null, 2);
|
||||
} catch {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
const baseStyles = "w-full text-left flex items-start gap-3 rounded-md border";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
inline: "p-3 border-red-200 bg-red-50 text-red-800",
|
||||
card: "p-4 border-red-200 bg-red-50 text-red-800 shadow-sm",
|
||||
banner: "p-3 border-red-200 bg-red-50 text-red-800 rounded-none border-x-0",
|
||||
};
|
||||
|
||||
export const ErrorState: FC<ErrorStateProps> = ({
|
||||
title = "Something went wrong",
|
||||
message = "Please try again or contact support if the problem persists.",
|
||||
error,
|
||||
onRetry,
|
||||
showRetry = !!onRetry,
|
||||
icon = faTriangleExclamation,
|
||||
variant = "inline",
|
||||
actions,
|
||||
className = "",
|
||||
...rest
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
const details = formatError(error);
|
||||
|
||||
async function handleRetry() {
|
||||
if (!onRetry) return;
|
||||
try {
|
||||
setRetrying(true);
|
||||
await onRetry();
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDetails() {
|
||||
if (!details) return;
|
||||
navigator.clipboard?.writeText(details).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={`${baseStyles} ${variants[variant]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<FontAwesomeIcon icon={icon} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
{message && <p className="text-sm opacity-90">{message}</p>}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{showRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} className="h-4 w-4" />
|
||||
{retrying ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{details && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="error-details"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={expanded ? faChevronUp : faChevronDown}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{expanded ? "Hide details" : "Show details"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyDetails}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
|
||||
aria-label="Copy error details"
|
||||
>
|
||||
<FontAwesomeIcon icon={faClipboard} className="h-4 w-4" />
|
||||
Copy details
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
{/* Dev details (collapsible) */}
|
||||
{expanded && details && (
|
||||
<pre
|
||||
id="error-details"
|
||||
className="mt-2 max-h-64 overflow-auto text-xs leading-relaxed bg-white/60 text-red-900 border rounded p-3"
|
||||
>
|
||||
{details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorState;
|
||||
14
src/components/UI/Loading.tsx
Normal file
14
src/components/UI/Loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
type LoadingProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const Loading = ({ message }: LoadingProps) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-gray-500 mb-2"></div>
|
||||
{message && <p className="text-lg text-gray-500">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
Reference in New Issue
Block a user